diff --git a/README.md b/README.md index 5b8017f..88e6176 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@ ## Open Text Summaries API +### Sitemap +- Endpoint: GET /sitemap.xml +- Description: Returns an XML sitemap including homepage, static pages (/about, /privacy, /terms, /contact), index pages (/books, /categories), and all book detail pages. +- How to submit to Google: In Google Search Console, add your property (domain) and submit the sitemap URL, e.g., https://your-domain.com/sitemap.xml + This API lets you ingest Books (with optional Category and Summary) in a create-or-update (upsert) fashion. It is designed so your client can safely resend the same data without creating duplicates. - Method and route: POST /api/ingest/books @@ -61,7 +66,8 @@ Accepted request shapes Field reference - Book - title (string, required) - - author (string, required) + - author (string, required if `authors` not provided) + - authors (array, optional; preferred. If provided, `author` may be omitted. The first name is also stored in the legacy `author` field for backward compatibility.) - slug (string, optional; defaults to slugified title) - description (string, optional) - publication_date (YYYY-MM-DD string, optional) @@ -148,7 +154,8 @@ Status: 422 } Notes -- This endpoint performs minimal validation: each item must have title and author. Other fields are optional. +- Authors are normalized to a separate `authors` table and linked via a pivot. You may send `author` (string) or `authors` (array). If `authors` is provided, the first name is also saved to the legacy `author` column on books for compatibility. +- This endpoint performs minimal validation: each item must have title and author/authors. Other fields are optional. - Slugs are derived from names/titles if not provided, using Laravel's Str::slug. - There is no pagination or retrieval endpoint here; this endpoint is for ingestion only. - No authentication/rate limiting is configured by default; add it before exposing this API to untrusted clients. diff --git a/app/Http/Controllers/Api/IngestController.php b/app/Http/Controllers/Api/IngestController.php index b231135..657fcd3 100644 --- a/app/Http/Controllers/Api/IngestController.php +++ b/app/Http/Controllers/Api/IngestController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\Book; use App\Models\Category; use App\Models\Summary; +use App\Models\Author; use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Support\Facades\DB; @@ -42,11 +43,13 @@ class IngestController extends Controller // Basic validation (minimal to satisfy requirement) $title = $item['title'] ?? null; $author = $item['author'] ?? null; - if (!$title || !$author) { + $authorsArr = $item['authors'] ?? null; // optional array of names + $hasAuthorInfo = $author || (is_array($authorsArr) && count($authorsArr) > 0); + if (!$title || !$hasAuthorInfo) { $results[] = [ 'index' => $index, 'status' => 'error', - 'message' => 'title and author are required.', + 'message' => 'title and author/authors are required.', ]; continue; } @@ -95,19 +98,22 @@ class IngestController extends Controller $bookSlug = Str::slug($item['title']); } + // Determine a primary author name for backward-compatible matching + $primaryAuthorName = $item['author'] ?? (is_array($item['authors'] ?? null) && count($item['authors']) > 0 ? ($item['authors'][0] ?? null) : null); + $bookQuery = Book::query(); if ($bookSlug) { $bookQuery->where('slug', $bookSlug); } else { - // Fallback: match by title+author - $bookQuery->where('title', $item['title'])->where('author', $item['author']); + // Fallback: match by title+author (string). If authors[] provided, use first one. + $bookQuery->where('title', $item['title'])->where('author', $primaryAuthorName); } $book = $bookQuery->first(); $bookData = [ 'title' => $item['title'] ?? ($book->title ?? null), 'slug' => $bookSlug ?? ($book->slug ?? null), - 'author' => $item['author'] ?? ($book->author ?? null), + 'author' => $primaryAuthorName ?? ($book->author ?? null), 'description' => $item['description'] ?? ($book->description ?? null), 'publication_date' => $item['publication_date'] ?? ($book->publication_date ?? null), 'cover_image' => $item['cover_image'] ?? ($book->cover_image ?? null), @@ -126,6 +132,41 @@ class IngestController extends Controller $bookStatus = 'created'; } + // Handle authors upsert and pivot sync + $names = []; + if (!empty($item['author'])) { + $names[] = $item['author']; + } + if (!empty($item['authors']) && is_array($item['authors'])) { + $names = array_merge($names, $item['authors']); + } + $names = array_values(array_unique(array_filter(array_map(function ($n) { return is_string($n) ? trim($n) : null; }, $names)))); + if (count($names) > 0) { + $authorIds = []; + foreach ($names as $name) { + $slug = Str::slug($name); + $author = Author::where('slug', $slug)->first(); + if (!$author) { + $author = Author::where('name', $name)->first(); + } + if ($author) { + // ensure slug and name are up to date + $author->fill([ + 'name' => $name, + 'slug' => $author->slug ?: $slug, + ])->save(); + } else { + $author = Author::create([ + 'name' => $name, + 'slug' => $slug, + ]); + } + $authorIds[] = $author->id; + } + // Replace current authors with provided set + $book->authors()->sync($authorIds); + } + // Handle summary upsert $summaryData = $item['summary'] ?? null; $summaryResult = null; diff --git a/app/Http/Controllers/Auth/ConfirmPasswordController.php b/app/Http/Controllers/Auth/ConfirmPasswordController.php new file mode 100644 index 0000000..3559954 --- /dev/null +++ b/app/Http/Controllers/Auth/ConfirmPasswordController.php @@ -0,0 +1,39 @@ +middleware('auth'); + } +} diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php new file mode 100644 index 0000000..465c39c --- /dev/null +++ b/app/Http/Controllers/Auth/ForgotPasswordController.php @@ -0,0 +1,22 @@ +middleware('guest')->except('logout'); + $this->middleware('auth')->only('logout'); + } +} diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php new file mode 100644 index 0000000..961ea36 --- /dev/null +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -0,0 +1,72 @@ +middleware('guest'); + } + + /** + * Get a validator for an incoming registration request. + * + * @param array $data + * @return \Illuminate\Contracts\Validation\Validator + */ + protected function validator(array $data) + { + return Validator::make($data, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]); + } + + /** + * Create a new user instance after a valid registration. + * + * @param array $data + * @return \App\Models\User + */ + protected function create(array $data) + { + return User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + ]); + } +} diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php new file mode 100644 index 0000000..fe965b2 --- /dev/null +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -0,0 +1,29 @@ +middleware('auth'); + $this->middleware('signed')->only('verify'); + $this->middleware('throttle:6,1')->only('verify', 'resend'); + } +} diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index c012dc2..d79cb21 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Book; use App\Models\Category; +use App\Models\Author; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -12,10 +13,67 @@ class BookController extends Controller /** * Display a listing of the resource. */ - public function index() + public function index(Request $request) { - $books = Book::with('category')->get(); - return view('books.index', compact('books')); + $query = Book::query()->with(['category', 'authors', 'summary'])->orderByDesc('created_at'); + + // Text search + if ($search = trim((string) $request->get('q'))) { + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + // Category filter (by id or slug) + if ($cat = $request->get('category')) { + if (is_numeric($cat)) { + $query->where('category_id', (int) $cat); + } else { + $query->whereHas('category', function ($q) use ($cat) { + $q->where('slug', $cat)->orWhere('name', 'like', "%{$cat}%"); + }); + } + } + + // Author filter (by id, slug, or name) + if ($author = $request->get('author')) { + $query->whereHas('authors', function ($q) use ($author) { + if (is_numeric($author)) { + $q->where('authors.id', (int) $author); + } else { + $q->where('authors.slug', $author) + ->orWhere('authors.name', 'like', "%{$author}%"); + } + }); + } + + // Publication date range + if ($from = $request->get('date_from')) { + $query->whereDate('publication_date', '>=', $from); + } + if ($to = $request->get('date_to')) { + $query->whereDate('publication_date', '<=', $to); + } + + $books = $query->paginate(12)->withQueryString(); + + // For filter dropdowns + $categories = Category::orderBy('name')->get(); + $authors = Author::orderBy('name')->get(); + + return view('books.index', [ + 'books' => $books, + 'categories' => $categories, + 'authors' => $authors, + 'filters' => [ + 'q' => $request->get('q'), + 'category' => $request->get('category'), + 'author' => $request->get('author'), + 'date_from' => $request->get('date_from'), + 'date_to' => $request->get('date_to'), + ], + ]); } /** @@ -41,9 +99,18 @@ class BookController extends Controller 'category_id' => 'required|exists:categories,id', ]); - Book::create([ + // Generate a unique slug for the title + $baseSlug = Str::slug($request->title); + $slug = $baseSlug; + $i = 2; + while (Book::where('slug', $slug)->exists()) { + $slug = $baseSlug . '-' . $i; + $i++; + } + + $book = Book::create([ 'title' => $request->title, - 'slug' => Str::slug($request->title), + 'slug' => $slug, 'author' => $request->author, 'description' => $request->description, 'publication_date' => $request->publication_date, @@ -51,6 +118,22 @@ class BookController extends Controller 'category_id' => $request->category_id, ]); + // Normalize author: link to authors table via pivot + if (!empty($request->author)) { + $name = trim($request->author); + $slug = Str::slug($name); + $author = Author::where('slug', $slug)->first(); + if (!$author) { + $author = Author::where('name', $name)->first(); + } + if ($author) { + $author->fill(['name' => $name, 'slug' => $author->slug ?: $slug])->save(); + } else { + $author = Author::create(['name' => $name, 'slug' => $slug]); + } + $book->authors()->sync([$author->id]); + } + return redirect()->route('books.index') ->with('success', 'Book created successfully.'); } @@ -60,7 +143,17 @@ class BookController extends Controller */ public function show(Book $book) { - return view('books.show', compact('book')); + $book->load(['summary', 'category', 'authors', 'reviews.user']); + $avgRating = (float) round($book->reviews()->avg('rating') ?? 0, 1); + $reviewsCount = (int) $book->reviews()->count(); + $userReview = auth()->check() ? $book->reviews()->where('user_id', auth()->id())->first() : null; + + return view('books.show', [ + 'book' => $book, + 'avgRating' => $avgRating, + 'reviewsCount' => $reviewsCount, + 'userReview' => $userReview, + ]); } /** @@ -86,9 +179,30 @@ class BookController extends Controller 'category_id' => 'required|exists:categories,id', ]); + // If title changed, compute a unique slug (preserving current slug if still matching) + $baseSlug = Str::slug($request->title); + $slug = $baseSlug; + if ($book->slug !== $baseSlug) { + $i = 2; + while (Book::where('slug', $slug)->where('id', '!=', $book->id)->exists()) { + $slug = $baseSlug . '-' . $i; + $i++; + } + } else { + // Ensure no collision with other records even if same base + if (Book::where('slug', $baseSlug)->where('id', '!=', $book->id)->exists()) { + $i = 2; + $slug = $baseSlug . '-' . $i; + while (Book::where('slug', $slug)->where('id', '!=', $book->id)->exists()) { + $i++; + $slug = $baseSlug . '-' . $i; + } + } + } + $book->update([ 'title' => $request->title, - 'slug' => Str::slug($request->title), + 'slug' => $slug, 'author' => $request->author, 'description' => $request->description, 'publication_date' => $request->publication_date, @@ -96,6 +210,25 @@ class BookController extends Controller 'category_id' => $request->category_id, ]); + // Normalize author: sync pivot to the single provided author + if (!empty($request->author)) { + $name = trim($request->author); + $slug = Str::slug($name); + $author = Author::where('slug', $slug)->first(); + if (!$author) { + $author = Author::where('name', $name)->first(); + } + if ($author) { + $author->fill(['name' => $name, 'slug' => $author->slug ?: $slug])->save(); + } else { + $author = Author::create(['name' => $name, 'slug' => $slug]); + } + $book->authors()->sync([$author->id]); + } else { + // If author field is cleared, detach all author links + $book->authors()->detach(); + } + return redirect()->route('books.index') ->with('success', 'Book updated successfully.'); } diff --git a/app/Http/Controllers/ContactController.php b/app/Http/Controllers/ContactController.php new file mode 100644 index 0000000..448d125 --- /dev/null +++ b/app/Http/Controllers/ContactController.php @@ -0,0 +1,39 @@ +validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email', + 'message' => 'required|string|max:5000', + ]); + + $to = 'mike@opentextsummaries.com'; + + $subject = 'Contact form message from ' . $data['name']; + $body = "Name: {$data['name']}\nEmail: {$data['email']}\n\nMessage:\n{$data['message']}"; + + // Send using simple raw email (will go to log in local env per .env) + Mail::raw($body, function ($message) use ($to, $subject, $data) { + $message->to($to) + ->subject($subject) + ->replyTo($data['email'], $data['name']); + }); + + return back()->with('success', 'Thank you! Your message has been sent.'); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..77ec359 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,7 +2,11 @@ namespace App\Http\Controllers; -abstract class Controller +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Routing\Controller as BaseController; + +class Controller extends BaseController { - // + use AuthorizesRequests, ValidatesRequests; } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php new file mode 100644 index 0000000..cb77aad --- /dev/null +++ b/app/Http/Controllers/HomeController.php @@ -0,0 +1,51 @@ +middleware('auth'); + } + + /** + * Show the user dashboard with stats. + */ + public function index(Request $request) + { + $user = $request->user(); + + // Aggregate counts + $readlistCount = $user->readlistBooks()->count(); + $readlistWithSummaryCount = $user->readlistBooks()->whereHas('summary')->count(); + $reviewsCount = $user->reviews()->count(); + $reportsCount = SummaryReport::where('user_id', $user->id)->count(); + $requestsCount = SummaryRequest::where('user_id', $user->id)->count(); + + // Recent activity + $latestReadlist = $user->readlistBooks()->with(['authors', 'summary'])->orderByPivot('created_at', 'desc')->limit(5)->get(); + $latestReviews = $user->reviews()->with('book')->latest()->limit(5)->get(); + $latestRequests = SummaryRequest::where('user_id', $user->id)->latest()->limit(5)->get(); + + return view('home', compact( + 'readlistCount', + 'readlistWithSummaryCount', + 'reviewsCount', + 'reportsCount', + 'requestsCount', + 'latestReadlist', + 'latestReviews', + 'latestRequests' + )); + } +} diff --git a/app/Http/Controllers/ReadlistController.php b/app/Http/Controllers/ReadlistController.php new file mode 100644 index 0000000..a66c045 --- /dev/null +++ b/app/Http/Controllers/ReadlistController.php @@ -0,0 +1,58 @@ +middleware('auth'); + } + + /** + * Display the user's readlist. + */ + public function index(Request $request) + { + $user = $request->user(); + $books = $user->readlistBooks()->with(['category', 'authors', 'summary'])->orderByPivot('created_at', 'desc')->paginate(12); + + return view('readlist.index', [ + 'books' => $books, + ]); + } + + /** + * Add a book to the readlist. + */ + public function store(Request $request, Book $book) + { + $user = $request->user(); + $user->readlistBooks()->syncWithoutDetaching([$book->id]); + + if ($request->wantsJson()) { + return response()->json(['status' => 'added']); + } + + return back()->with('success', 'Book added to your readlist.'); + } + + /** + * Remove a book from the readlist. + */ + public function destroy(Request $request, Book $book) + { + $user = $request->user(); + $user->readlistBooks()->detach($book->id); + + if ($request->wantsJson()) { + return response()->json(['status' => 'removed']); + } + + return back()->with('success', 'Book removed from your readlist.'); + } +} diff --git a/app/Http/Controllers/SitemapController.php b/app/Http/Controllers/SitemapController.php new file mode 100644 index 0000000..d51043a --- /dev/null +++ b/app/Http/Controllers/SitemapController.php @@ -0,0 +1,62 @@ +orderBy('updated_at', 'desc')->chunk(500, function ($books) use (&$urls) { + foreach ($books as $book) { + $urls[] = [URL::to('/books/' . $book->slug), optional($book->updated_at)]; + } + }); + + $xml = $this->buildXml($urls); + + return response($xml, 200)->header('Content-Type', 'application/xml'); + } + + /** + * Build a basic XML sitemap string. + * + * @param array $urls + */ + protected function buildXml(array $urls): string + { + $xmlHeader = ''; + $urlsetOpen = ''; + $urlsetClose = ''; + + $body = ''; + foreach ($urls as [$loc, $lastmod]) { + $body .= "\n \n"; + $body .= ' ' . e($loc) . "\n"; + if ($lastmod) { + $body .= ' ' . $lastmod->format('c') . "\n"; + } + $body .= " "; + } + + return $xmlHeader . "\n" . $urlsetOpen . $body . "\n" . $urlsetClose . "\n"; + } +} diff --git a/app/Http/Controllers/StaticPageController.php b/app/Http/Controllers/StaticPageController.php new file mode 100644 index 0000000..e05a3ca --- /dev/null +++ b/app/Http/Controllers/StaticPageController.php @@ -0,0 +1,24 @@ +middleware('auth'); + } + + /** + * Store a newly created summary report in storage. + */ + public function store(Request $request, Book $book) + { + $validated = $request->validate([ + 'reason' => 'required|string|max:100', + 'details' => 'nullable|string|max:5000', + ]); + + // Try to prevent duplicate reports from the same user for the same book + $report = SummaryReport::firstOrCreate( + [ + 'user_id' => $request->user()->id, + 'book_id' => $book->id, + ], + [ + 'summary_id' => optional($book->summary)->id, + 'reason' => $validated['reason'], + 'details' => $validated['details'] ?? null, + 'status' => 'open', + ] + ); + + if (!$report->wasRecentlyCreated) { + // If already exists, update reason/details in case user adds more context + $report->update([ + 'reason' => $validated['reason'], + 'details' => $validated['details'] ?? $report->details, + 'summary_id' => optional($book->summary)->id, + ]); + } + + return back()->with('success', 'Thanks! Your report has been submitted. We will review it soon.'); + } +} diff --git a/app/Http/Controllers/SummaryRequestController.php b/app/Http/Controllers/SummaryRequestController.php new file mode 100644 index 0000000..a2b5e60 --- /dev/null +++ b/app/Http/Controllers/SummaryRequestController.php @@ -0,0 +1,91 @@ +middleware('auth'); + } + + /** List the current user's requests */ + public function index(Request $request) + { + $requests = SummaryRequest::where('user_id', $request->user()->id) + ->orderByDesc('created_at') + ->paginate(12); + + return view('requests.index', [ + 'requests' => $requests, + ]); + } + + /** Show the create form */ + public function create(Request $request) + { + $prefill = [ + 'gutenberg' => $request->query('gutenberg'), + 'title' => $request->query('title'), + ]; + return view('requests.create', $prefill); + } + + /** Store the request */ + public function store(Request $request) + { + $data = $request->validate([ + 'gutenberg' => 'required|string|max:255', // can be URL or ID + 'title' => 'nullable|string|max:255', + 'notes' => 'nullable|string|max:2000', + ]); + + $gutenbergId = $this->parseGutenbergId($data['gutenberg']); + if (!$gutenbergId) { + return back()->withErrors(['gutenberg' => 'Please provide a valid Project Gutenberg URL or numeric ID.'])->withInput(); + } + + $requestModel = SummaryRequest::firstOrCreate([ + 'user_id' => $request->user()->id, + 'gutenberg_id' => $gutenbergId, + ], [ + 'title' => $data['title'] ?? null, + 'notes' => $data['notes'] ?? null, + 'status' => 'pending', + ]); + + // If already exists, update title/notes to latest provided + if (!$requestModel->wasRecentlyCreated) { + $requestModel->update([ + 'title' => $data['title'] ?? $requestModel->title, + 'notes' => $data['notes'] ?? $requestModel->notes, + ]); + } + + return redirect()->route('requests.index')->with('success', 'Your summary request has been submitted. We will process it soon.'); + } + + private function parseGutenbergId(string $input): ?int + { + $input = trim($input); + // If pure number + if (ctype_digit($input)) { + return (int) $input; + } + + // Try to extract from URL + $patterns = [ + '/gutenberg\.org\/(?:ebooks|etext)\/(\d+)/i', // https://www.gutenberg.org/ebooks/1342 + '/gutenberg\.org\/(?:files)\/(\d+)\//i', // https://www.gutenberg.org/files/1342/1342-h/1342-h.htm + ]; + foreach ($patterns as $pattern) { + if (preg_match($pattern, $input, $m)) { + return (int) $m[1]; + } + } + return null; + } +} diff --git a/app/Http/Controllers/SummaryReviewController.php b/app/Http/Controllers/SummaryReviewController.php new file mode 100644 index 0000000..509e119 --- /dev/null +++ b/app/Http/Controllers/SummaryReviewController.php @@ -0,0 +1,55 @@ +middleware('auth'); + } + + /** + * Store or update a user's review for a book summary. + */ + public function store(Request $request, Book $book) + { + $data = $request->validate([ + 'rating' => 'required|integer|min:1|max:5', + 'comment' => 'nullable|string|max:2000', + ]); + + $review = SummaryReview::updateOrCreate( + [ + 'user_id' => $request->user()->id, + 'book_id' => $book->id, + ], + [ + 'summary_id' => optional($book->summary)->id, + 'rating' => $data['rating'], + 'comment' => $data['comment'] ?? null, + ] + ); + + return back()->with('success', $review->wasRecentlyCreated ? 'Thanks for your review!' : 'Your review has been updated.'); + } + + /** + * Remove the specified review from storage. + */ + public function destroy(Request $request, SummaryReview $review) + { + $this->authorizeAction($request, $review); + $review->delete(); + return back()->with('success', 'Your review has been deleted.'); + } + + protected function authorizeAction(Request $request, SummaryReview $review): void + { + abort_unless($review->user_id === $request->user()->id, 403, 'Unauthorized'); + } +} diff --git a/app/Http/Middleware/GoatCounterMiddleware.php b/app/Http/Middleware/GoatCounterMiddleware.php new file mode 100644 index 0000000..ca75374 --- /dev/null +++ b/app/Http/Middleware/GoatCounterMiddleware.php @@ -0,0 +1,57 @@ +goatCounter = $goatCounter; + } + + /** + * Handle an incoming request. + */ + public function handle(Request $request, Closure $next) + { + $response = $next($request); + + // Only track "real" HTML page requests: + if ( + $request->isMethod('GET') && + !$request->ajax() && + $this->isTrackable($request) + ) { + $this->goatCounter->trackPageview( + '/' . ltrim($request->path(), '/'), + $request->route()?->getName() ?? ucfirst(str_replace('/', ' ', $request->path())), + $request->headers->get('referer') + ); + } + + return $response; + } + + protected function isTrackable(Request $request): bool + { + $path = $request->path(); + + // Ignore file extensions (css, js, images, fonts, etc.) + if (preg_match('/\.(css|js|png|jpe?g|gif|svg|ico|woff2?|ttf|map)$/i', $path)) { + return false; + } + + // Optionally skip API routes + if ($request->is('api/*')) { + return false; + } + + return true; + } +} diff --git a/app/Models/Author.php b/app/Models/Author.php new file mode 100644 index 0000000..6f0094f --- /dev/null +++ b/app/Models/Author.php @@ -0,0 +1,22 @@ +belongsToMany(Book::class, 'author_book')->withTimestamps(); + } +} diff --git a/app/Models/Book.php b/app/Models/Book.php index 55ef4d1..e5ac91a 100644 --- a/app/Models/Book.php +++ b/app/Models/Book.php @@ -16,6 +16,14 @@ class Book extends Model 'category_id', ]; + /** + * Use slug for route model binding and URL generation. + */ + public function getRouteKeyName(): string + { + return 'slug'; + } + /** * Get the category that owns the book. */ @@ -31,4 +39,36 @@ class Book extends Model { return $this->hasOne(Summary::class); } + + /** + * The authors that belong to the book. + */ + public function authors() + { + return $this->belongsToMany(Author::class, 'author_book')->withTimestamps(); + } + + /** + * Users who saved this book to their readlist. + */ + public function savedByUsers() + { + return $this->belongsToMany(User::class, 'readlist_items')->withTimestamps(); + } + + /** + * Reports submitted for this book's summary. + */ + public function reports() + { + return $this->hasMany(SummaryReport::class); + } + + /** + * Reviews left by users for this book's summary. + */ + public function reviews() + { + return $this->hasMany(SummaryReview::class); + } } diff --git a/app/Models/SummaryReport.php b/app/Models/SummaryReport.php new file mode 100644 index 0000000..6889a58 --- /dev/null +++ b/app/Models/SummaryReport.php @@ -0,0 +1,32 @@ +belongsTo(User::class); + } + + public function book() + { + return $this->belongsTo(Book::class); + } + + public function summary() + { + return $this->belongsTo(Summary::class); + } +} diff --git a/app/Models/SummaryRequest.php b/app/Models/SummaryRequest.php new file mode 100644 index 0000000..7b71569 --- /dev/null +++ b/app/Models/SummaryRequest.php @@ -0,0 +1,21 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/SummaryReview.php b/app/Models/SummaryReview.php new file mode 100644 index 0000000..61f5798 --- /dev/null +++ b/app/Models/SummaryReview.php @@ -0,0 +1,31 @@ +belongsTo(User::class); + } + + public function book() + { + return $this->belongsTo(Book::class); + } + + public function summary() + { + return $this->belongsTo(Summary::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..8d7d855 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -45,4 +45,20 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + /** + * Books saved by the user in their readlist. + */ + public function readlistBooks() + { + return $this->belongsToMany(Book::class, 'readlist_items')->withTimestamps(); + } + + /** + * Reviews authored by the user. + */ + public function reviews() + { + return $this->hasMany(SummaryReview::class); + } } diff --git a/app/Services/GoatCounterService.php b/app/Services/GoatCounterService.php new file mode 100644 index 0000000..f913226 --- /dev/null +++ b/app/Services/GoatCounterService.php @@ -0,0 +1,60 @@ +endpoint = 'http://gc.c0rrup7.net/api/v0/count'; + $this->token = '13nus05lo9d441ihqhk5d3w15s1hoi0h9pr013j1jwqyebgma8un'; // store in config/services.php + } + + /** + * Track a single pageview + */ + public function trackPageview(string $path, string $title = null, string $referrer = null): array + { + return $this->sendHits([[ + 'path' => $path, + 'title' => $title, + 'ref' => $referrer, + ]]); + } + + /** + * Track a custom event + */ + public function trackEvent(string $event, string $title = null, string $referrer = null): array + { + return $this->sendHits([[ + 'event' => $event, + 'title' => $title, + 'ref' => $referrer, + ]]); + } + + /** + * Send multiple hits in one request + */ + public function sendHits(array $hits): array + { + $response = Http::withToken($this->token) + ->acceptJson() + ->asJson() + ->post($this->endpoint, [ + 'no_sessions' => true, // don’t start session cookies from backend + 'hits' => $hits, + ]); + + return [ + 'status' => $response->status(), + 'response' => $response->json(), + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c5..ef683ff 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { - // + $middleware->append(GoatCounterMiddleware::class); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index a56d88f..5b5cb29 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0", - "laravel/tinker": "^2.10.1" + "laravel/tinker": "^2.10.1", + "laravel/ui": "^4.6" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index cbc346f..4fd9c00 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cd40520ddd6242a873530a735cb961c4", + "content-hash": "95cfaf5da898358678b4502356676f79", "packages": [ { "name": "brick/math", @@ -1458,6 +1458,69 @@ }, "time": "2025-01-27T14:24:01+00:00" }, + { + "name": "laravel/ui", + "version": "v4.6.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/ui.git", + "reference": "7d6ffa38d79f19c9b3e70a751a9af845e8f41d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/ui/zipball/7d6ffa38d79f19c9b3e70a751a9af845e8f41d88", + "reference": "7d6ffa38d79f19c9b3e70a751a9af845e8f41d88", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.21|^10.0|^11.0|^12.0", + "illuminate/filesystem": "^9.21|^10.0|^11.0|^12.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0", + "illuminate/validation": "^9.21|^10.0|^11.0|^12.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0" + }, + "require-dev": { + "orchestra/testbench": "^7.35|^8.15|^9.0|^10.0", + "phpunit/phpunit": "^9.3|^10.4|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Ui\\UiServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Ui\\": "src/", + "Illuminate\\Foundation\\Auth\\": "auth-backend/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel UI utilities and presets.", + "keywords": [ + "laravel", + "ui" + ], + "support": { + "source": "https://github.com/laravel/ui/tree/v4.6.1" + }, + "time": "2025-01-28T15:15:29+00:00" + }, { "name": "league/commonmark", "version": "2.7.1", diff --git a/config/services.php b/config/services.php index 6182e4b..b16a42a 100644 --- a/config/services.php +++ b/config/services.php @@ -34,5 +34,8 @@ return [ 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), ], ], - + 'goatcounter' => [ + 'url' => env('GOATCOUNTER_URL', 'http://gc.c0rrup7.net'), + 'token' => env('GOATCOUNTER_TOKEN', '13nus05lo9d441ihqhk5d3w15s1hoi0h9pr013j1jwqyebgma8un'), + ] ]; diff --git a/database/migrations/2025_09_13_175800_create_authors_table.php b/database/migrations/2025_09_13_175800_create_authors_table.php new file mode 100644 index 0000000..fdaae89 --- /dev/null +++ b/database/migrations/2025_09_13_175800_create_authors_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('bio')->nullable(); + $table->timestamps(); + }); + + Schema::create('author_book', function (Blueprint $table) { + $table->id(); + $table->foreignId('author_id')->constrained()->onDelete('cascade'); + $table->foreignId('book_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + $table->unique(['author_id', 'book_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('author_book'); + Schema::dropIfExists('authors'); + } +}; diff --git a/database/migrations/2025_09_13_221500_create_readlist_items_table.php b/database/migrations/2025_09_13_221500_create_readlist_items_table.php new file mode 100644 index 0000000..7cc86df --- /dev/null +++ b/database/migrations/2025_09_13_221500_create_readlist_items_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('book_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['user_id', 'book_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('readlist_items'); + } +}; diff --git a/database/migrations/2025_09_13_223000_create_summary_reports_table.php b/database/migrations/2025_09_13_223000_create_summary_reports_table.php new file mode 100644 index 0000000..e4b9941 --- /dev/null +++ b/database/migrations/2025_09_13_223000_create_summary_reports_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('book_id')->constrained()->cascadeOnDelete(); + $table->foreignId('summary_id')->nullable()->constrained()->nullOnDelete(); + $table->string('reason'); // e.g., inaccurate, incomplete, offensive, other + $table->text('details')->nullable(); + $table->string('status')->default('open'); // open, reviewed, resolved + $table->timestamps(); + + $table->unique(['user_id', 'book_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('summary_reports'); + } +}; diff --git a/database/migrations/2025_09_13_223500_create_summary_requests_table.php b/database/migrations/2025_09_13_223500_create_summary_requests_table.php new file mode 100644 index 0000000..c5059f5 --- /dev/null +++ b/database/migrations/2025_09_13_223500_create_summary_requests_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('gutenberg_id'); + $table->string('title')->nullable(); + $table->text('notes')->nullable(); + $table->string('status')->default('pending'); // pending, processing, completed, failed, rejected + $table->timestamps(); + + $table->unique(['user_id', 'gutenberg_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('summary_requests'); + } +}; diff --git a/database/migrations/2025_09_13_224500_create_summary_reviews_table.php b/database/migrations/2025_09_13_224500_create_summary_reviews_table.php new file mode 100644 index 0000000..5a5f00a --- /dev/null +++ b/database/migrations/2025_09_13_224500_create_summary_reviews_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('book_id')->constrained()->cascadeOnDelete(); + $table->foreignId('summary_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedTinyInteger('rating'); // 1-5 + $table->text('comment')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'book_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('summary_reviews'); + } +}; diff --git a/package-lock.json b/package-lock.json index b7adaf6..edbcbee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,10 +5,13 @@ "packages": { "": { "devDependencies": { + "@popperjs/core": "^2.11.6", "@tailwindcss/vite": "^4.0.0", "axios": "^1.11.0", + "bootstrap": "^5.2.3", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", + "sass": "^1.56.1", "tailwindcss": "^4.0.0", "vite": "^7.0.4" } @@ -518,6 +521,341 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.50.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz", @@ -1141,6 +1479,40 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1185,6 +1557,22 @@ "node": ">=8" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -1443,6 +1831,20 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1627,6 +2029,24 @@ "node": ">= 0.4" } }, + "node_modules/immutable": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1637,6 +2057,31 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/jiti": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", @@ -1926,6 +2371,35 @@ "node": ">= 0.4" } }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2007,6 +2481,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2063,6 +2545,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2124,6 +2620,27 @@ "tslib": "^2.1.0" } }, + "node_modules/sass": { + "version": "1.92.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.92.1.tgz", + "integrity": "sha512-ffmsdbwqb3XeyR8jJR6KelIXARM9bFQe8A6Q3W4Klmwy5Ckd5gz7jgUNHo4UOqutU5Sk1DtKLbpDP0nLCg1xqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -2247,6 +2764,20 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index a5707d8..9fc76f3 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,13 @@ "dev": "vite" }, "devDependencies": { + "@popperjs/core": "^2.11.6", "@tailwindcss/vite": "^4.0.0", "axios": "^1.11.0", + "bootstrap": "^5.2.3", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", + "sass": "^1.56.1", "tailwindcss": "^4.0.0", "vite": "^7.0.4" } diff --git a/resources/css/app.css b/resources/css/app.css index 3e6abea..f1d8c73 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,11 +1 @@ -@import 'tailwindcss'; - -@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; -@source '../../storage/framework/views/*.php'; -@source '../**/*.blade.php'; -@source '../**/*.js'; - -@theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; -} +@import "tailwindcss"; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 5f1390b..46f7a33 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -1,4 +1,34 @@ +import 'bootstrap'; + +/** + * We'll load the axios HTTP library which allows us to easily issue requests + * to our Laravel back-end. This library automatically handles sending the + * CSRF token as a header based on the value of the "XSRF" token cookie. + */ + import axios from 'axios'; window.axios = axios; window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; + +/** + * Echo exposes an expressive API for subscribing to channels and listening + * for events that are broadcast by Laravel. Echo and event broadcasting + * allows your team to easily build robust real-time web applications. + */ + +// import Echo from 'laravel-echo'; + +// import Pusher from 'pusher-js'; +// window.Pusher = Pusher; + +// window.Echo = new Echo({ +// broadcaster: 'pusher', +// key: import.meta.env.VITE_PUSHER_APP_KEY, +// cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1', +// wsHost: import.meta.env.VITE_PUSHER_HOST ?? `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`, +// wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80, +// wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443, +// forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https', +// enabledTransports: ['ws', 'wss'], +// }); diff --git a/resources/sass/_variables.scss b/resources/sass/_variables.scss new file mode 100644 index 0000000..172daaa --- /dev/null +++ b/resources/sass/_variables.scss @@ -0,0 +1,7 @@ +// Body +$body-bg: #f8fafc; + +// Typography +$font-family-sans-serif: 'Nunito', sans-serif; +$font-size-base: 0.9rem; +$line-height-base: 1.6; diff --git a/resources/sass/app.scss b/resources/sass/app.scss new file mode 100644 index 0000000..1026a0b --- /dev/null +++ b/resources/sass/app.scss @@ -0,0 +1,8 @@ +// Fonts +@import url('https://fonts.bunny.net/css?family=Nunito'); + +// Variables +@import 'variables'; + +// Bootstrap +@import 'bootstrap/scss/bootstrap'; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..ec29e75 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,54 @@ +@extends('layouts.app') + +@section('content') +
+
+
+

{{ __('Login') }}

+
+
+
+ @csrf + +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('password') +

{{ $message }}

+ @enderror +
+ +
+ + + @if (Route::has('password.request')) + + {{ __('Forgot Your Password?') }} + + @endif +
+ +
+ +
+
+
+
+
+@endsection diff --git a/resources/views/auth/passwords/confirm.blade.php b/resources/views/auth/passwords/confirm.blade.php new file mode 100644 index 0000000..f8c8e61 --- /dev/null +++ b/resources/views/auth/passwords/confirm.blade.php @@ -0,0 +1,49 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
{{ __('Confirm Password') }}
+ +
+ {{ __('Please confirm your password before continuing.') }} + +
+ @csrf + +
+ + +
+ + + @error('password') + + {{ $message }} + + @enderror +
+
+ +
+
+ + + @if (Route::has('password.request')) + + {{ __('Forgot Your Password?') }} + + @endif +
+
+
+
+
+
+
+
+@endsection diff --git a/resources/views/auth/passwords/email.blade.php b/resources/views/auth/passwords/email.blade.php new file mode 100644 index 0000000..d1ac783 --- /dev/null +++ b/resources/views/auth/passwords/email.blade.php @@ -0,0 +1,47 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
{{ __('Reset Password') }}
+ +
+ @if (session('status')) + + @endif + +
+ @csrf + +
+ + +
+ + + @error('email') + + {{ $message }} + + @enderror +
+
+ +
+
+ +
+
+
+
+
+
+
+
+@endsection diff --git a/resources/views/auth/passwords/reset.blade.php b/resources/views/auth/passwords/reset.blade.php new file mode 100644 index 0000000..dccf6c6 --- /dev/null +++ b/resources/views/auth/passwords/reset.blade.php @@ -0,0 +1,65 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
{{ __('Reset Password') }}
+ +
+
+ @csrf + + + +
+ + +
+ + + @error('email') + + {{ $message }} + + @enderror +
+
+ +
+ + +
+ + + @error('password') + + {{ $message }} + + @enderror +
+
+ +
+ + +
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+@endsection diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 0000000..5a48119 --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,55 @@ +@extends('layouts.app') + +@section('content') +
+
+
+

{{ __('Register') }}

+
+
+
+ @csrf + +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('password') +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ +
+
+
+
+
+@endsection diff --git a/resources/views/auth/verify.blade.php b/resources/views/auth/verify.blade.php new file mode 100644 index 0000000..9f8c1bc --- /dev/null +++ b/resources/views/auth/verify.blade.php @@ -0,0 +1,28 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
{{ __('Verify Your Email Address') }}
+ +
+ @if (session('resent')) + + @endif + + {{ __('Before proceeding, please check your email for a verification link.') }} + {{ __('If you did not receive the email') }}, +
+ @csrf + . +
+
+
+
+
+
+@endsection diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php new file mode 100644 index 0000000..27a17af --- /dev/null +++ b/resources/views/books/index.blade.php @@ -0,0 +1,97 @@ +@extends('layouts.app') + +@section('content') +

All Books

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + Reset +
+
+ + @if($books->count() === 0) +
No books found.
+ @else +
+ @foreach($books as $book) +
+ @if($book->cover_image) + {{ $book->title }} cover + @else +
No Cover
+ @endif +
+

{{ $book->title }}

+

+ @if($book->authors && $book->authors->count()) + {{ $book->authors->pluck('name')->join(', ') }} + @elseif($book->author) + {{ $book->author }} + @endif +

+ @if($book->category) +

Category: {{ $book->category->name }}

+ @endif + + @auth +
+ @php($inReadlist = auth()->user()->readlistBooks()->where('book_id', $book->id)->exists()) + @if(!$inReadlist) +
+ @csrf + +
+ @else +
+ @csrf + @method('DELETE') + +
+ @endif +
+ @endauth +
+
+ @endforeach +
+ +
+ {{ $books->links() }} +
+ @endif +@endsection diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php new file mode 100644 index 0000000..8d5e0ff --- /dev/null +++ b/resources/views/books/show.blade.php @@ -0,0 +1,184 @@ +@extends('layouts.app') + +@section('content') +
+
+ @if($book->cover_image) + {{ $book->title }} cover + @endif +
+

{{ $book->title }}

+

+ @if($book->authors && $book->authors->count()) + {{ $book->authors->pluck('name')->join(', ') }} + @elseif($book->author) + {{ $book->author }} + @endif +

+ @if($book->category) +

Category: {{ $book->category->name }}

+ @endif + @if($book->publication_date) +

Published: {{ $book->publication_date }}

+ @endif + @if($book->description) +
{!! nl2br(e($book->description)) !!}
+ @endif + + @auth +
+ @php($inReadlist = auth()->user()->readlistBooks()->where('book_id', $book->id)->exists()) + @if(!$inReadlist) +
+ @csrf + +
+ @else +
+ @csrf + @method('DELETE') + +
+ @endif +
+ @endauth +
+
+
+
+ @if (session('success')) +
+ {{ session('success') }} +
+ @endif + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+

Summary

+ @if($book->summary) +
+ {!! nl2br(e($book->summary->content)) !!} +
+ @if($book->summary->key_insights) +

Key insights

+

{!! nl2br(e($book->summary->key_insights)) !!}

+ @endif + @else +

No summary available for this book yet.

+ @auth + + @else +

Want one? Log in to request a summary.

+ @endauth + @endif + +
+

Is this summary inaccurate?

+ @auth +
+ @csrf +
+ + +
+
+ + +
+ +
+ @else +

Please log in to report issues with this summary.

+ @endauth +
+
+ + +
+

Reviews

+ +
+
+ @for($i=1;$i<=5;$i++) + + + + @endfor +
+
{{ $avgRating > 0 ? number_format($avgRating, 1) : 'No ratings yet' }} @if($reviewsCount) • {{ $reviewsCount }} {{ Str::plural('review', $reviewsCount) }} @endif
+
+ + @auth +
+

{{ $userReview ? 'Update your review' : 'Leave a review' }}

+
+ @csrf +
+ + +
+
+ + +
+
+ +
+
+ @if($userReview) +
+ @csrf + @method('DELETE') + +
+ @endif +
+ @else +

Please log in to leave a review.

+ @endauth + + @if($book->reviews->count()) +
    + @foreach($book->reviews->sortByDesc('created_at') as $review) +
  • +
    +
    + {{ $review->user->name }} + • {{ $review->created_at->diffForHumans() }} +
    +
    + @for($i=1;$i<=5;$i++) + + @endfor +
    +
    + @if($review->comment) +

    {{ $review->comment }}

    + @endif +
  • + @endforeach +
+ @else +

No reviews yet. Be the first to review this summary.

+ @endif +
+
+@endsection diff --git a/resources/views/categories/create.blade.php b/resources/views/categories/create.blade.php index 3a17449..2b9aa59 100644 --- a/resources/views/categories/create.blade.php +++ b/resources/views/categories/create.blade.php @@ -1,49 +1,40 @@ - - - - - - Create Category - Open Text Summaries - - - -
-
-

Create New Category

-
+@extends('layouts.app') -
-
- @csrf - -
- - - @error('name') -

{{ $message }}

- @enderror -
- -
- - - @error('description') -

{{ $message }}

- @enderror -
- -
- - - Cancel - -
-
-
+@section('content') +
+

Create New Category

- - + +
+
+ @csrf + +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('description') +

{{ $message }}

+ @enderror +
+ +
+ + + Cancel + +
+
+
+@endsection diff --git a/resources/views/categories/edit.blade.php b/resources/views/categories/edit.blade.php index ff3d794..6c343b4 100644 --- a/resources/views/categories/edit.blade.php +++ b/resources/views/categories/edit.blade.php @@ -1,50 +1,41 @@ - - - - - - Edit Category - Open Text Summaries - - - -
-
-

Edit Category

-
+@extends('layouts.app') -
-
- @csrf - @method('PUT') - -
- - - @error('name') -

{{ $message }}

- @enderror -
- -
- - - @error('description') -

{{ $message }}

- @enderror -
- -
- - - Cancel - -
-
-
+@section('content') +
+

Edit Category

- - + +
+
+ @csrf + @method('PUT') + +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('description') +

{{ $message }}

+ @enderror +
+ +
+ + + Cancel + +
+
+
+@endsection diff --git a/resources/views/categories/index.blade.php b/resources/views/categories/index.blade.php index aab5cb1..f43a07c 100644 --- a/resources/views/categories/index.blade.php +++ b/resources/views/categories/index.blade.php @@ -1,74 +1,65 @@ - - - - - - Categories - Open Text Summaries - - - -
-
-

Categories

- - Add New Category - -
+@extends('layouts.app') - @if (session('success')) - - @endif - -
- - - - - - - - - - @forelse ($categories as $category) - - - - - - @empty - - - - @endforelse - -
NameDescriptionActions
-
{{ $category->name }}
-
-
{{ Str::limit($category->description, 100) }}
-
-
- - View - - - Edit - -
- @csrf - @method('DELETE') - -
-
-
No categories found
-
- - +@section('content') +
+

Categories

+ + Add New Category +
- - + + @if (session('success')) + + @endif + +
+ + + + + + + + + + @forelse ($categories as $category) + + + + + + @empty + + + + @endforelse + +
NameDescriptionActions
+
{{ $category->name }}
+
+
{{ Str::limit($category->description, 100) }}
+
+
+ + View + + + Edit + +
+ @csrf + @method('DELETE') + +
+
+
No categories found
+
+ + +@endsection diff --git a/resources/views/categories/show.blade.php b/resources/views/categories/show.blade.php index b519be1..b023d55 100644 --- a/resources/views/categories/show.blade.php +++ b/resources/views/categories/show.blade.php @@ -1,65 +1,56 @@ - - - - - - {{ $category->name }} - Open Text Summaries - - - -
-
-

{{ $category->name }}

+@extends('layouts.app') + +@section('content') +
+

{{ $category->name }}

+
+ +
+

Category Details

+ +
+

Name: {{ $category->name }}

-
-

Category Details

- -
-

Name: {{ $category->name }}

-
- -
-

Description:

-

{{ $category->description }}

-
- -
- - Edit - -
- @csrf - @method('DELETE') - -
-
+
+

Description:

+

{{ $category->description }}

-
-

Books in this Category

- - @if($category->books->count() > 0) -
- @foreach($category->books as $book) -
-

{{ $book->title }}

-

By {{ $book->author }}

-

{{ Str::limit($book->description, 100) }}

- View Details → -
- @endforeach -
- @else -

No books found in this category.

- @endif -
- -
- Back to Categories +
+ + Edit + +
+ @csrf + @method('DELETE') + +
- - + +
+

Books in this Category

+ + @if($category->books->count() > 0) +
+ @foreach($category->books as $book) +
+

{{ $book->title }}

+

By {{ $book->author }}

+

{{ Str::limit($book->description, 100) }}

+ View Details → +
+ @endforeach +
+ @else +

No books found in this category.

+ @endif +
+ + +@endsection diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php new file mode 100644 index 0000000..78c35eb --- /dev/null +++ b/resources/views/home.blade.php @@ -0,0 +1,127 @@ +@extends('layouts.app') + +@section('content') +
+
+

Dashboard

+

Welcome back, {{ auth()->user()->name }}. Here are your reading stats.

+
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + +
+
+
Readlist items
+
{{ $readlistCount ?? 0 }}
+
+
+
Summaries in Readlist
+
{{ $readlistWithSummaryCount ?? 0 }}
+
+
+
Reviews written
+
{{ $reviewsCount ?? 0 }}
+
+
+
Reports submitted
+
{{ $reportsCount ?? 0 }}
+
+
+
Summary requests
+
{{ $requestsCount ?? 0 }}
+
+
+ +
+ + + + +
+
+

Your latest reviews

+
+ +
+ + +
+
+

Your summary requests

+ All requests +
+
+ @forelse(($latestRequests ?? []) as $r) +
+
+
+
Gutenberg #{{ $r->gutenberg_id }}
+
{{ $r->title ?? '—' }}
+
+ {{ ucfirst($r->status) }} +
+
{{ $r->created_at->diffForHumans() }}
+
+ @empty +
No requests yet. Request a summary.
+ @endforelse +
+
+
+
+@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..d2fec48 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + {{ config('app.name', 'OpenTextSummaries') }} + + + + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @endif + + +
+ +
+
+
+ + + + + {{ config('app.name', 'OpenTextSummaries') }} + + +
+
+
+ + +
+
+ @yield('content') +
+
+ + +
+
+
+ + + + {{ config('app.name', 'OpenTextSummaries') }} +
+ +

© {{ date('Y') }} {{ config('app.name', 'OpenTextSummaries') }}. All rights reserved.

+
+
+
+ + + + + diff --git a/resources/views/readlist/index.blade.php b/resources/views/readlist/index.blade.php new file mode 100644 index 0000000..74ea4e7 --- /dev/null +++ b/resources/views/readlist/index.blade.php @@ -0,0 +1,49 @@ +@extends('layouts.app') + +@section('content') +
+

My Readlist

+
+ + @if (session('success')) +
+ {{ session('success') }} +
+ @endif + + @if($books->count() === 0) +
+ Your readlist is empty. Browse all books to add some. +
+ @else +
+ @foreach($books as $book) +
+ @if($book->cover_image) + {{ $book->title }} cover + @else +
No Cover
+ @endif +
+

{{ $book->title }}

+

+ @if($book->authors && $book->authors->count()) + {{ $book->authors->pluck('name')->join(', ') }} + @elseif($book->author) + {{ $book->author }} + @endif +

+
+ @csrf + @method('DELETE') + +
+
+
+ @endforeach +
+
+ {{ $books->links() }} +
+ @endif +@endsection diff --git a/resources/views/requests/create.blade.php b/resources/views/requests/create.blade.php new file mode 100644 index 0000000..1e152f5 --- /dev/null +++ b/resources/views/requests/create.blade.php @@ -0,0 +1,51 @@ +@extends('layouts.app') + +@section('content') +
+

Request a Book Summary

+ +
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+ @csrf + +
+ + +

Paste the Project Gutenberg URL for the book you want summarized, or just its numeric ID.

+
+ +
+ + +
+ +
+ + +

Add any context or what you are most interested in (themes, characters, key insights, etc.).

+
+ +
+ + My requests +
+
+ +
+

+ Books featured on this site are sourced from Project Gutenberg and are part of the public domain. Summaries are generated using AI and may contain inaccuracies. +

+
+
+
+@endsection diff --git a/resources/views/requests/index.blade.php b/resources/views/requests/index.blade.php new file mode 100644 index 0000000..1471732 --- /dev/null +++ b/resources/views/requests/index.blade.php @@ -0,0 +1,58 @@ +@extends('layouts.app') + +@section('content') +
+
+

My Summary Requests

+ New request +
+ + @if (session('success')) +
+ {{ session('success') }} +
+ @endif + + @if($requests->count() === 0) +
+ You haven't requested any summaries yet. +
+ @else +
+ + + + + + + + + + + @foreach($requests as $r) + + + + + + + @endforeach + +
Gutenberg IDTitleStatusRequested
+ {{ $r->gutenberg_id }} + {{ $r->title ?? '—' }} + {{ ucfirst($r->status) }} + {{ $r->created_at->diffForHumans() }}
+
+
+ {{ $requests->links() }} +
+ @endif + +
+

+ Books featured on this site are sourced from Project Gutenberg and are part of the public domain. Summaries are generated using AI and may contain inaccuracies. +

+
+
+@endsection diff --git a/resources/views/static/about.blade.php b/resources/views/static/about.blade.php new file mode 100644 index 0000000..b2241ae --- /dev/null +++ b/resources/views/static/about.blade.php @@ -0,0 +1,17 @@ +@extends('layouts.app') + +@section('content') +
+

About

+
+

Welcome to {{ config('app.name', 'OpenTextSummaries') }} — a place to quickly learn from books through concise summaries.

+

Our mission is to make knowledge accessible by providing brief, readable takeaways from great works.

+ +
+

+ Books featured on this site are sourced from Project Gutenberg and are part of the public domain. Summaries are generated using AI and may contain inaccuracies. +

+
+
+
+@endsection diff --git a/resources/views/static/contact.blade.php b/resources/views/static/contact.blade.php new file mode 100644 index 0000000..f64aa2d --- /dev/null +++ b/resources/views/static/contact.blade.php @@ -0,0 +1,52 @@ +@extends('layouts.app') + +@section('content') +
+

Contact

+
+ @if (session('success')) +
+ {{ session('success') }} +
+ @endif + +
+ @csrf + +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('message') +

{{ $message }}

+ @enderror +
+ +
+ +
+
+ +
+

+ Books featured on this site are sourced from Project Gutenberg and are part of the public domain. Summaries are generated using AI and may contain inaccuracies. +

+
+
+
+@endsection diff --git a/resources/views/static/privacy.blade.php b/resources/views/static/privacy.blade.php new file mode 100644 index 0000000..c4add99 --- /dev/null +++ b/resources/views/static/privacy.blade.php @@ -0,0 +1,17 @@ +@extends('layouts.app') + +@section('content') +
+

Privacy Policy

+
+

Your privacy matters to us. We collect only the information necessary to provide and improve our services, such as account details you voluntarily provide.

+

We do not sell your personal information. For questions, please contact us via the contact page.

+ +
+

+ Books featured on this site are sourced from Project Gutenberg and are part of the public domain. Summaries are generated using AI and may contain inaccuracies. +

+
+
+
+@endsection diff --git a/resources/views/static/terms.blade.php b/resources/views/static/terms.blade.php new file mode 100644 index 0000000..087875f --- /dev/null +++ b/resources/views/static/terms.blade.php @@ -0,0 +1,17 @@ +@extends('layouts.app') + +@section('content') +
+

Terms of Service

+
+

By using {{ config('app.name', 'OpenTextSummaries') }}, you agree to use the service responsibly and abide by applicable laws. We provide content "as is" without warranties of any kind.

+

We may update these terms from time to time. Continued use of the site constitutes acceptance of the revised terms.

+ +
+

+ Books featured on this site are sourced from Project Gutenberg and are part of the public domain. Summaries are generated using AI and may contain inaccuracies. +

+
+
+
+@endsection diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index b0be682..c6be47a 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -4,12 +4,26 @@ + + + + + {{ config('app.name', 'Open Text Summaries') }} + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) @vite(['resources/css/app.css', 'resources/js/app.js']) @@ -74,53 +88,47 @@
@@ -171,10 +179,10 @@ OpenTextSummaries
diff --git a/routes/web.php b/routes/web.php index 97de8ee..0911519 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,11 +4,71 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\CategoryController; use App\Http\Controllers\BookController; use App\Http\Controllers\SummaryController; +use App\Http\Controllers\ReadlistController; +use App\Http\Controllers\StaticPageController; +use App\Http\Controllers\ContactController; +use App\Http\Controllers\SummaryReportController; +use App\Http\Controllers\SummaryRequestController; +use App\Http\Controllers\SummaryReviewController; +use App\Http\Controllers\SitemapController; +use App\Models\Book; Route::get('/', function () { - return view('welcome'); + $latestBooks = Book::with(['category', 'authors', 'summary']) + ->orderBy('created_at', 'desc') + ->limit(6) + ->get(); + return view('welcome', compact('latestBooks')); +}); + +// Static pages +Route::get('/about', [StaticPageController::class, 'about'])->name('about'); +Route::get('/privacy', [StaticPageController::class, 'privacy'])->name('privacy'); +Route::get('/terms', [StaticPageController::class, 'terms'])->name('terms'); + +// Contact +Route::get('/contact', [ContactController::class, 'show'])->name('contact.show'); +Route::post('/contact', [ContactController::class, 'send'])->name('contact.send'); + +// Bind the {book} route parameter to resolve by slug or numeric ID (backward compatible) +Route::bind('book', function ($value) { + return App\Models\Book::where('slug', $value) + ->orWhere(function ($q) use ($value) { + if (is_numeric($value)) { + $q->where('id', (int) $value); + } + }) + ->firstOrFail(); }); Route::resource('categories', CategoryController::class); Route::resource('books', BookController::class); Route::resource('summaries', SummaryController::class); + +// Readlist + Reports + Requests routes +Route::middleware('auth')->group(function () { + Route::get('/readlist', [ReadlistController::class, 'index'])->name('readlist.index'); + Route::post('/readlist/{book}', [ReadlistController::class, 'store'])->name('readlist.store'); + Route::delete('/readlist/{book}', [ReadlistController::class, 'destroy'])->name('readlist.destroy'); + + // Summary reports + Route::post('/books/{book}/report', [SummaryReportController::class, 'store'])->name('summary-reports.store'); + + // Summary requests + Route::get('/requests', [SummaryRequestController::class, 'index'])->name('requests.index'); + Route::get('/requests/create', [SummaryRequestController::class, 'create'])->name('requests.create'); + Route::post('/requests', [SummaryRequestController::class, 'store'])->name('requests.store'); + + // Summary reviews + Route::post('/books/{book}/reviews', [SummaryReviewController::class, 'store'])->name('reviews.store'); + Route::delete('/reviews/{review}', [SummaryReviewController::class, 'destroy'])->name('reviews.destroy'); +}); + +Auth::routes(); + +// Dashboard route (alias for /home) +Route::get('/dashboard', [App\Http\Controllers\HomeController::class, 'index'])->name('dashboard'); +Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home'); + +// Sitemap +Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap'); diff --git a/vite.config.js b/vite.config.js index 29fbfe9..89f26f5 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,13 +1,14 @@ import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; -import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [ laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], + input: [ + 'resources/css/app.css', + 'resources/js/app.js', + ], refresh: true, }), - tailwindcss(), ], });