diff --git a/app/Http/Controllers/ComicController.php b/app/Http/Controllers/ComicController.php index c6c7f2b..b69fafd 100644 --- a/app/Http/Controllers/ComicController.php +++ b/app/Http/Controllers/ComicController.php @@ -3,11 +3,14 @@ namespace App\Http\Controllers; use App\Helper\ZhConversion; +use App\Jobs\ComicInsert; +use App\Jobs\ComicUpsert; use App\Jobs\ImageUpsert; use App\Jobs\RemotePrefetch; use App\Models\Author; use App\Models\Chapter; use App\Models\Comic; +use App\Models\ReadingHistory; use App\Remote\CopyManga; use App\Remote\ImageFetcher; use GuzzleHttp\Exception\GuzzleException; @@ -19,6 +22,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response as IlluminateHttpResponse; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Inertia\Inertia; use Inertia\Response; @@ -30,12 +34,15 @@ class ComicController extends Controller { /** + * Autoload classes + * * @param CopyManga $copyManga * @param ZhConversion $zhConversion */ - public function __construct(private readonly CopyManga $copyManga, private readonly ZhConversion $zhConversion) - { - } + public function __construct( + private readonly CopyManga $copyManga, + private readonly ZhConversion $zhConversion + ) {} /** * Convert SC to ZH @@ -61,10 +68,8 @@ class ComicController extends Controller */ public function favourites(Request $request): Response { - $favourites = $this->scToZh($request->user()->favourites()->with(['authors'])->orderBy('upstream_updated_at', 'desc')->get()); - return Inertia::render('Comic/Favourites', [ - 'favourites' => $favourites, + 'favourites' => $this->scToZh($request->user()->favourites()->with(['authors'])->orderBy('upstream_updated_at', 'desc')->get()), ]); } @@ -84,16 +89,16 @@ class ComicController extends Controller // Fetch from remote $remoteComic = $this->copyManga->comic($request->pathword); - $comic = new Comic; - $comic->pathword = $remoteComic['comic']['path_word']; - $comic->name = $remoteComic['comic']['name']; - $comic->cover = $remoteComic['comic']['cover']; - $comic->upstream_updated_at = $remoteComic['comic']['datetime_updated']; - $comic->uuid = $remoteComic['comic']['uuid']; - $comic->alias = explode(',', $remoteComic['comic']['alias']); - $comic->description = $remoteComic['comic']['brief']; - $comic->metadata = $remoteComic; - $comic->save(); + $comic = Comic::create([ + 'pathword' => $remoteComic['comic']['path_word'], + 'name' => $remoteComic['comic']['name'], + 'cover' => $remoteComic['comic']['cover'], + 'upstream_updated_at' => $remoteComic['comic']['datetime_updated'], + 'uuid' => $remoteComic['comic']['uuid'], + 'alias' => explode(',', $remoteComic['comic']['alias']), + 'description' => $remoteComic['comic']['brief'], + 'metadata' => $remoteComic, + ]); } // Set favourite @@ -125,47 +130,6 @@ class ComicController extends Controller ]); } - // Internal function for Upsert comics to db - protected function comicsUpsert($comics): void - { - // Prep the array for upsert - $comicsUpsertArray = []; - $authorsUpsertArray = []; - - foreach ($comics['list'] as $comic) { - $comicsUpsertArray[] = [ - 'pathword' => $comic['path_word'], - 'uuid' => '', - 'name' => $comic['name'], - 'alias' => '{}', - 'description' => '', - 'cover' => $comic['cover'], - 'upstream_updated_at' => $comic['datetime_updated'] ?? null, - ]; - - foreach ($comic['author'] as $author) { - $authorsUpsertArray[] = [ - 'name' => $author['name'] - ]; - } - } - - // Do an upsert for comics - Comic::upsert($comicsUpsertArray, uniqueBy: 'pathword', update: ['upstream_updated_at']); - Author::upsert($authorsUpsertArray, uniqueBy: 'name'); - - // Had to do a second pass to insert the relationships - foreach ($comics['list'] as $comic) { - // Get the comic id - $comicObj = Comic::where('pathword', $comic['path_word'])->first(); - - foreach ($comic['author'] as $author) { - $authorObj = Author::where('name', $author['name'])->first(); - $comicObj->authors()->sync($authorObj); - } - } - } - /** * Index / Tags for comic listing * @@ -180,12 +144,23 @@ class ComicController extends Controller $params['theme'] = $request->get('tag'); } - $comics = $this->copyManga->comics(30, $request->header('offset', 0), $request->get('top', 'all'), $params); - $this->comicsUpsert($comics); + $offset = $request->header('offset', 0); + $comics = $this->copyManga->comics(30, $offset, $request->get('top', 'all'), $params); + + // Upsert into DB + ComicUpsert::dispatch($comics); + + // Prefetch next page + RemotePrefetch::dispatch('comics', [ + 'limit' => 30, + 'offset' => $offset + 30, + 'top' => $request->get('top', 'all'), + 'params' => $params + ])->delay(now()->addSecond()); return Inertia::render('Comic/Index', [ 'comics' => $this->scToZh($comics), - 'offset' => $request->header('offset', 0) + 'offset' => $offset ]); } @@ -199,15 +174,15 @@ class ComicController extends Controller */ public function author(Request $request, string $author): Response { - $params = []; - $params['author'] = $author; + $offset = $request->header('offset', 0); + $comics = $this->copyManga->comics(30, $offset, $request->get('top', 'all'), ['author' => $author]); - $comics = $this->copyManga->comics(30, $request->header('offset', 0), $request->get('top', 'all'), $params); - $this->comicsUpsert($comics); + // Upsert into DB + ComicUpsert::dispatch($comics); return Inertia::render('Comic/Index', [ 'comics' => $this->scToZh($comics), - 'offset' => $request->header('offset', 0) + 'offset' => $offset ]); } @@ -221,13 +196,14 @@ class ComicController extends Controller */ public function search(Request $request, string $search): Response { - $comics = $this->copyManga->search($search, 30, $request->header('offset', 0)); + $offset = $request->header('offset', 0); + $comics = $this->copyManga->search($search, 30, $offset); // Search API is limited, no upsert return Inertia::render('Comic/Index', [ 'comics' => $this->scToZh($comics), - 'offset' => $request->header('offset', 0) + 'offset' => $offset ]); } @@ -245,8 +221,10 @@ class ComicController extends Controller return to_route('comics.index'); } + $offset = $request->header('offset', 0); + $comic = $this->copyManga->comic($pathword); - $chapters = $this->copyManga->chapters($pathword, 200, $request->header('offset', 0), [], $request->get('group', 'default')); + $chapters = $this->copyManga->chapters($pathword, 200, $offset, [], $request->get('group', 'default')); // Get the comic object and fill other parameters try { @@ -297,15 +275,20 @@ class ComicController extends Controller // Do an upsert Chapter::upsert($arrayForUpsert, uniqueBy: 'chapter_uuid'); - // Get history + // Get user history $histories = $request->user()->readingHistories()->where('reading_histories.comic_id', $comicObject->id) ->distinct()->select('chapter_uuid')->get()->pluck('chapter_uuid'); + // Check if the first chapter is already fetched or not, if not, do a prefetch + if (!$histories->contains($chapters['list'][0]['uuid'])) { + RemotePrefetch::dispatch('chapter', ['pathword' => $pathword, 'uuid' => $chapters['list'][0]['uuid']]); + } + return Inertia::render('Comic/Chapters', [ 'comic' => $this->scToZh($comic), 'chapters' => $this->scToZh($chapters), 'histories' => $histories, - 'offset' => $request->header('offset', 0) + 'offset' => $offset ]); } @@ -351,7 +334,7 @@ class ComicController extends Controller try { Chapter::where('chapter_uuid', $chapter['chapter']['next'])->firstOrFail()->images()->firstOrFail(); } catch (ModelNotFoundException $e) { - RemotePrefetch::dispatch('chapter', ['pathword' => $pathword, 'uuid' => $chapter['chapter']['next']]); + RemotePrefetch::dispatch('chapter', ['pathword' => $pathword, 'uuid' => $chapter['chapter']['next']]); } } @@ -370,7 +353,48 @@ class ComicController extends Controller */ public function histories(Request $request): Response { - // Get history + // Group by comic + if ($request->get('group', '') === 'comic') { + $readingHistory = ReadingHistory::query() + ->join('comics', 'reading_histories.comic_id', '=', 'comics.id') + ->join('chapters', 'reading_histories.chapter_id', '=', 'chapters.id') + ->select( + 'comics.id as comic_id', + 'comics.name as comic_name', + 'comics.pathword as comic_pathword', + 'comics.upstream_updated_at as comic_upstream_updated_at', + 'chapters.name as chapter_name', + 'chapters.chapter_uuid as chapter_uuid', + 'reading_histories.created_at as read_at' + ) + ->where('reading_histories.user_id', $request->user()->id) + ->orderBy('comics.name')->orderByDesc('reading_histories.created_at')->paginate(2000); + + // Transform the paginated data into a 2D array grouped by comic_id, including comic data and histories + $groupedData = $readingHistory->getCollection() + ->groupBy('comic_id')->map(function (Collection $records, $comicId) { + $firstRecord = $records->first(); + return [ + 'comic' => [ + 'comic_id' => $comicId, + 'comic_name' => $firstRecord->comic_name, + 'comic_pathword' => $firstRecord->comic_pathword, + 'comic_upstream_updated_at' => $firstRecord->comic_upstream_updated_at, + ], + 'histories' => $records->map(fn($record) => [ + 'chapter_name' => $record->chapter_name, + 'chapter_uuid' => $record->chapter_uuid, + 'read_at' => $record->read_at, + ])->toArray(), + ]; + })->values()->toArray(); // Use values() to reset numeric keys after grouping + + return Inertia::render('Comic/HistoriesByComic', [ + 'histories' => $this->scToZh($groupedData) + ]); + } + + // Only order by chapter $histories = $request->user()->readingHistories()->with(['comic:id,name,pathword'])->orderByDesc('reading_histories.created_at') ->select(['reading_histories.id as hid', 'chapters.comic_id', 'chapters.name'])->paginate(50)->toArray(); @@ -425,7 +449,7 @@ class ComicController extends Controller */ public function destroyHistories(Request $request): RedirectResponse { - $result = $request->user()->cleanUpReadingHistories(); + $request->user()->cleanUpReadingHistories(); return redirect()->route('comics.histories'); } diff --git a/app/Jobs/ComicUpsert.php b/app/Jobs/ComicUpsert.php new file mode 100644 index 0000000..13f13d1 --- /dev/null +++ b/app/Jobs/ComicUpsert.php @@ -0,0 +1,71 @@ +queueProgress(0); + Log::info("JOB ComicUpsert START"); + + $comicsUpsertArray = []; + $authorsUpsertArray = []; + + foreach ($this->comic['list'] as $comic) { + $comicsUpsertArray[] = [ + 'pathword' => $comic['path_word'], + 'uuid' => '', + 'name' => $comic['name'], + 'alias' => '{}', + 'description' => '', + 'cover' => $comic['cover'], + 'upstream_updated_at' => $comic['datetime_updated'] ?? null, + ]; + + foreach ($comic['author'] as $author) { + $authorsUpsertArray[] = [ + 'name' => $author['name'] + ]; + } + } + + // Do an upsert for comics + Comic::upsert($comicsUpsertArray, uniqueBy: 'pathword', update: ['upstream_updated_at']); + Author::upsert($authorsUpsertArray, uniqueBy: 'name'); + + // Had to do a second pass to insert the relationships + foreach ($this->comic['list'] as $comic) { + // Get the comic id + $comicObj = Comic::where('pathword', $comic['path_word'])->first(); + + foreach ($comic['author'] as $author) { + $authorObj = Author::where('name', $author['name'])->first(); + $comicObj->authors()->sync($authorObj); + } + } + + Log::info('JOB ComicUpsert END'); + $this->queueProgress(100); + } +} diff --git a/app/Jobs/ImageUpsert.php b/app/Jobs/ImageUpsert.php index c708f8f..de29b90 100644 --- a/app/Jobs/ImageUpsert.php +++ b/app/Jobs/ImageUpsert.php @@ -6,10 +6,11 @@ use App\Models\Image; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; +use romanzipp\QueueMonitor\Traits\IsMonitored; class ImageUpsert implements ShouldQueue { - use Queueable; + use IsMonitored, Queueable; /** * Create a new job instance. @@ -25,7 +26,12 @@ class ImageUpsert implements ShouldQueue */ public function handle(): void { + $this->queueProgress(0); Log::info("JOB ImageUpsert START, comicId: {$this->comicId}, chapterId: {$this->chapterId}"); + $this->queueData([ + 'comicId' => $this->comicId, + 'chapterId' => $this->chapterId, + ]); $arrayForUpsert = []; @@ -47,5 +53,6 @@ class ImageUpsert implements ShouldQueue Image::upsert($arrayForUpsert, uniqueBy: 'url'); Log::info('JOB ImageUpsert END'); + $this->queueProgress(100); } } diff --git a/app/Jobs/RemotePrefetch.php b/app/Jobs/RemotePrefetch.php index 70da749..4474150 100644 --- a/app/Jobs/RemotePrefetch.php +++ b/app/Jobs/RemotePrefetch.php @@ -6,10 +6,11 @@ use App\Remote\CopyManga; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; +use romanzipp\QueueMonitor\Traits\IsMonitored; class RemotePrefetch implements ShouldQueue { - use Queueable; + use IsMonitored, Queueable; /** * Create a new job instance. @@ -24,11 +25,17 @@ class RemotePrefetch implements ShouldQueue */ public function handle(): void { + $this->queueProgress(0); $copyManga = new CopyManga(); switch ($this->action) { case 'chapter': Log::info("JOB RemotePrefetch START, action '{$this->action}', Pathword: {$this->parameters['pathword']}, UUID: {$this->parameters['uuid']}"); + $this->queueData([ + 'action' => $this->action, + 'pathword' => $this->parameters['pathword'], + 'uuid' => $this->parameters['uuid'] + ]); $copyManga->chapter($this->parameters['pathword'], $this->parameters['uuid']); @@ -37,15 +44,25 @@ class RemotePrefetch implements ShouldQueue case 'chapters': // TODO: break; - case 'index': - // TODO - break; - case 'tags': - // TODO + case 'comics': + Log::info("JOB RemotePrefetch START, action '{$this->action}', Offset: {$this->parameters['offset']}"); + $this->queueData([ + 'action' => $this->action, + 'offset' => $this->parameters['offset'], + 'limit' => $this->parameters['limit'], + 'top' => $this->parameters['top'], + 'params' => $this->parameters['params'] + ]); + + $copyManga->comics($this->parameters['offset'], $this->parameters['limit'], $this->parameters['top'], $this->parameters['params']); + + Log::info("JOB RemotePrefetch END, action '{$this->action}'"); break; default: Log::info("JOB RemotePrefetch Unknown action '{$this->action}'"); break; } + + $this->queueProgress(100); } } diff --git a/app/Remote/CopyManga.php b/app/Remote/CopyManga.php index 4ee88d6..911dfe2 100644 --- a/app/Remote/CopyManga.php +++ b/app/Remote/CopyManga.php @@ -296,7 +296,7 @@ class CopyManga public function legacyChapter(string $comic, string $chapter): array { $userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"; - $responses = $this->execute($this->legacyBuildUrl("comic/{$comic}/chapter/{$chapter}"), "GET", $userAgent, ttl: 24 * 60 * 60); + $responses = $this->execute($this->legacyBuildUrl("comic/{$comic}/chapter/{$chapter}"), "GET", $userAgent, ttl: 24 * 60 * 60 * 30); // Get Content Key $dom = new DOMDocument(); @@ -346,7 +346,7 @@ class CopyManga */ public function chapter(string $comic, string $chapter, array $parameters = []): array { - $responses = $this->execute($this->buildUrl("comic/{$comic}/chapter2/{$chapter}", $parameters), ttl: 24 * 60 * 60); + $responses = $this->execute($this->buildUrl("comic/{$comic}/chapter2/{$chapter}", $parameters), ttl: 24 * 60 * 60 * 30); if ($this->legacyImagesFetch) { $responses['sorted'] = $this->legacyChapter($comic, $chapter); diff --git a/app/Remote/ImageFetcher.php b/app/Remote/ImageFetcher.php index f6ed4a4..adb9155 100644 --- a/app/Remote/ImageFetcher.php +++ b/app/Remote/ImageFetcher.php @@ -14,7 +14,7 @@ class ImageFetcher 'cache' => [ 'enabled' => true, 'prefix' => 'image_fetcher_', - 'ttl' => 3600, + 'ttl' => 60 * 60 * 24 * 30, ], 'http' => [ 'timeout' => 60, diff --git a/bootstrap/app.php b/bootstrap/app.php index 682ced6..c3fcbff 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ withExceptions(function (Exceptions $exceptions) { + $exceptions->render(function (ServerException $e, Request $request) { + return response()->view('errors', status: 500); + }); + Integration::handles($exceptions); })->create(); diff --git a/bun.lockb b/bun.lockb index d95ba2f..5514e1a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/composer.json b/composer.json index 2f82cfa..1794416 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "laravel/tinker": "^2.9", "plesk/ext-laravel-integration": "^7.0", "predis/predis": "^2.0", + "romanzipp/laravel-queue-monitor": "^5.3", "sentry/sentry-laravel": "^4.10", "tightenco/ziggy": "^2.0" }, diff --git a/composer.lock b/composer.lock index ba3b1e5..c6ee78f 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": "641b7b21cd3231c89ab305cd615eeb6f", + "content-hash": "6f784bba21b3b875b87a164afa37ff36", "packages": [ { "name": "brick/math", @@ -1189,16 +1189,16 @@ }, { "name": "laravel/framework", - "version": "v11.37.0", + "version": "v11.38.2", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "6cb103d2024b087eae207654b3f4b26646119ba5" + "reference": "9d290aa90fcad44048bedca5219d2b872e98772a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6cb103d2024b087eae207654b3f4b26646119ba5", - "reference": "6cb103d2024b087eae207654b3f4b26646119ba5", + "url": "https://api.github.com/repos/laravel/framework/zipball/9d290aa90fcad44048bedca5219d2b872e98772a", + "reference": "9d290aa90fcad44048bedca5219d2b872e98772a", "shasum": "" }, "require": { @@ -1399,20 +1399,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-01-02T20:10:21+00:00" + "time": "2025-01-15T00:06:46+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.2", + "version": "v0.3.3", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "0e0535747c6b8d6d10adca8b68293cf4517abb0f" + "reference": "749395fcd5f8f7530fe1f00dfa84eb22c83d94ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/0e0535747c6b8d6d10adca8b68293cf4517abb0f", - "reference": "0e0535747c6b8d6d10adca8b68293cf4517abb0f", + "url": "https://api.github.com/repos/laravel/prompts/zipball/749395fcd5f8f7530fe1f00dfa84eb22c83d94ea", + "reference": "749395fcd5f8f7530fe1f00dfa84eb22c83d94ea", "shasum": "" }, "require": { @@ -1456,9 +1456,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.2" + "source": "https://github.com/laravel/prompts/tree/v0.3.3" }, - "time": "2024-11-12T14:59:47+00:00" + "time": "2024-12-30T15:53:31+00:00" }, { "name": "laravel/sanctum", @@ -3681,6 +3681,76 @@ ], "time": "2024-04-27T21:32:50+00:00" }, + { + "name": "romanzipp/laravel-queue-monitor", + "version": "5.3.7", + "source": { + "type": "git", + "url": "https://github.com/romanzipp/Laravel-Queue-Monitor.git", + "reference": "7412f5315bbb2fa5ea4a05743d615e56b397a96e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/romanzipp/Laravel-Queue-Monitor/zipball/7412f5315bbb2fa5ea4a05743d615e56b397a96e", + "reference": "7412f5315bbb2fa5ea4a05743d615e56b397a96e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/database": "^5.5|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "illuminate/queue": "^5.5|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "illuminate/support": "^5.5|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.0" + }, + "require-dev": { + "doctrine/dbal": "^3.1", + "friendsofphp/php-cs-fixer": "^3.0", + "laravel/framework": "^5.5|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "mockery/mockery": "^1.3.2", + "orchestra/testbench": ">=3.8", + "phpstan/phpstan": "^0.12.99|^1.0", + "phpunit/phpunit": "^8.5.23|^9.0|^10.5", + "romanzipp/php-cs-fixer-config": "^3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "romanzipp\\QueueMonitor\\Providers\\QueueMonitorProvider" + ] + } + }, + "autoload": { + "psr-4": { + "romanzipp\\QueueMonitor\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "romanzipp", + "email": "ich@ich.wtf", + "homepage": "https://ich.wtf" + } + ], + "description": "Queue Monitoring for Laravel Database Job Queue", + "support": { + "issues": "https://github.com/romanzipp/Laravel-Queue-Monitor/issues", + "source": "https://github.com/romanzipp/Laravel-Queue-Monitor/tree/5.3.7" + }, + "funding": [ + { + "url": "https://github.com/romanzipp", + "type": "github" + } + ], + "time": "2024-12-05T14:46:27+00:00" + }, { "name": "sentry/sentry", "version": "4.10.0", @@ -6966,16 +7036,16 @@ }, { "name": "laravel/breeze", - "version": "v2.3.0", + "version": "v2.3.1", "source": { "type": "git", "url": "https://github.com/laravel/breeze.git", - "reference": "d59702967b9ae21879df905d691a50132966c4ff" + "reference": "60ac80abfa08c3c2dbc61e4b16f02230b843cfd3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/breeze/zipball/d59702967b9ae21879df905d691a50132966c4ff", - "reference": "d59702967b9ae21879df905d691a50132966c4ff", + "url": "https://api.github.com/repos/laravel/breeze/zipball/60ac80abfa08c3c2dbc61e4b16f02230b843cfd3", + "reference": "60ac80abfa08c3c2dbc61e4b16f02230b843cfd3", "shasum": "" }, "require": { @@ -7023,7 +7093,7 @@ "issues": "https://github.com/laravel/breeze/issues", "source": "https://github.com/laravel/breeze" }, - "time": "2024-12-14T21:21:42+00:00" + "time": "2025-01-13T16:52:29+00:00" }, { "name": "laravel/pail", @@ -7105,16 +7175,16 @@ }, { "name": "laravel/pint", - "version": "v1.19.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "8169513746e1bac70c85d6ea1524d9225d4886f0" + "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/8169513746e1bac70c85d6ea1524d9225d4886f0", - "reference": "8169513746e1bac70c85d6ea1524d9225d4886f0", + "url": "https://api.github.com/repos/laravel/pint/zipball/53072e8ea22213a7ed168a8a15b96fbb8b82d44b", + "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b", "shasum": "" }, "require": { @@ -7167,20 +7237,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2024-12-30T16:20:10+00:00" + "time": "2025-01-14T16:20:53+00:00" }, { "name": "laravel/sail", - "version": "v1.39.1", + "version": "v1.40.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "1a3c7291bc88de983b66688919a4d298d68ddec7" + "reference": "237e70656d8eface4839de51d101284bd5d0cf71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/1a3c7291bc88de983b66688919a4d298d68ddec7", - "reference": "1a3c7291bc88de983b66688919a4d298d68ddec7", + "url": "https://api.github.com/repos/laravel/sail/zipball/237e70656d8eface4839de51d101284bd5d0cf71", + "reference": "237e70656d8eface4839de51d101284bd5d0cf71", "shasum": "" }, "require": { @@ -7230,7 +7300,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2024-11-27T15:42:28+00:00" + "time": "2025-01-13T16:57:11+00:00" }, { "name": "mockery/mockery", diff --git a/config/queue-monitor.php b/config/queue-monitor.php new file mode 100644 index 0000000..f7ec055 --- /dev/null +++ b/config/queue-monitor.php @@ -0,0 +1,60 @@ + 'queue_monitor', + 'connection' => null, + + /* + * Set the model used for monitoring. + * If using a custom model, be sure to implement the + * romanzipp\QueueMonitor\Models\Contracts\MonitorContract + * interface or extend the base model. + */ + 'model' => \romanzipp\QueueMonitor\Models\Monitor::class, + + // Determined if the queued jobs should be monitored + 'monitor_queued_jobs' => true, + + // Specify the max character length to use for storing exception backtraces. + 'db_max_length_exception' => 4294967295, + 'db_max_length_exception_message' => 65535, + + // The optional UI settings. + 'ui' => [ + // Enable the UI + 'enabled' => true, + + // Accepts route group configuration + 'route' => [ + 'prefix' => 'queues', + 'middleware' => [], + ], + + // Set the monitored jobs count to be displayed per page. + 'per_page' => 50, + + // Show custom data stored on model + 'show_custom_data' => true, + + // Allow the deletion of single monitor items. + 'allow_deletion' => true, + + // Allow retry for a single failed monitor item. + 'allow_retry' => true, + + // Allow purging all monitor entries. + 'allow_purge' => true, + + 'show_metrics' => true, + + // Time frame used to calculate metrics values (in days). + 'metrics_time_frame' => 14, + + // The interval before refreshing the dashboard (in seconds). + 'refresh_interval' => 5, + + // Order the queued but not started jobs first + 'order_queued_first' => false, + ], +]; diff --git a/database/migrations/2018_02_05_000000_create_queue_monitor_table.php b/database/migrations/2018_02_05_000000_create_queue_monitor_table.php new file mode 100644 index 0000000..d482e3a --- /dev/null +++ b/database/migrations/2018_02_05_000000_create_queue_monitor_table.php @@ -0,0 +1,55 @@ +increments('id'); + $table->uuid('job_uuid')->nullable(); + + $table->string('job_id')->index(); + $table->string('name')->nullable(); + $table->string('queue')->nullable(); + + $table->unsignedInteger('status')->default(MonitorStatus::RUNNING); + + $table->dateTime('queued_at')->nullable(); + $table->timestamp('started_at')->nullable()->index(); + $table->string('started_at_exact')->nullable(); + + $table->timestamp('finished_at')->nullable(); + $table->string('finished_at_exact')->nullable(); + + $table->integer('attempt')->default(0); + $table->boolean('retried')->default(false); + $table->integer('progress')->nullable(); + + $table->longText('exception')->nullable(); + $table->text('exception_message')->nullable(); + $table->text('exception_class')->nullable(); + + $table->longText('data')->nullable(); + }); + } + + public function down() + { + Schema::drop(config('queue-monitor.table')); + } +} diff --git a/package.json b/package.json index 632efc7..5f8bc7c 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,14 @@ }, "devDependencies": { "@headlessui/react": "^2.2.0", - "@inertiajs/react": "^2.0.0", + "@inertiajs/react": "^2.0.2", "@tailwindcss/forms": "^0.5.10", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "axios": "^1.7.9", "concurrently": "^9.1.2", "laravel-vite-plugin": "^1.1.1", - "postcss": "^8.4.49", + "postcss": "^8.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", "tailwindcss": "^3.4.17", @@ -35,7 +35,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.6", - "@sentry/react": "^8.48.0", + "@sentry/react": "^8.50.0", "@sentry/vite-plugin": "^2.23.0", "@tanstack/react-table": "^8.20.6", "class-variance-authority": "^0.7.1", diff --git a/public/vendor/queue-monitor/app.css b/public/vendor/queue-monitor/app.css new file mode 100644 index 0000000..7c238fd --- /dev/null +++ b/public/vendor/queue-monitor/app.css @@ -0,0 +1 @@ +/*! tailwindcss v3.2.7 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.mb-1{margin-bottom:.25rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.mt-1{margin-top:.25rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.h-3{height:.75rem}.h-full{height:100%}.w-1\/4{width:25%}.w-32{width:8rem}.w-64{width:16rem}.w-\[24rem\]{width:24rem}.w-full{width:100%}.flex-1{flex:1 1 0%}.border-separate{border-collapse:initial}.border-spacing-0{--tw-border-spacing-x:0px;--tw-border-spacing-y:0px;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.flex-col{flex-direction:column}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-4{gap:1rem}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.rounded-t-md{border-top-left-radius:.375rem;border-top-right-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.bg-blue-200{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.bg-green-200{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity))}.bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.bg-transparent{background-color:initial}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.p-1{padding:.25rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pb-64{padding-bottom:16rem}.pl-2{padding-left:.5rem}.pl-4{padding-left:1rem}.text-left{text-align:left}.text-center{text-align:center}.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-light{font-weight:300}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-5{line-height:1.25rem}.leading-relaxed{line-height:1.625}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity))}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-colors{transition-duration:.15s}.hover\:bg-blue-100:hover{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.hover\:bg-red-100:hover{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}@media (prefers-color-scheme:dark){.dark\:border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity))}.dark\:border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity))}.dark\:bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity))}.dark\:bg-blue-200{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity))}.dark\:bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity))}.dark\:bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.dark\:bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.dark\:bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.dark\:bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity))}.dark\:bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity))}.dark\:bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.dark\:bg-transparent{background-color:initial}.dark\:text-blue-50{--tw-text-opacity:1;color:rgb(239 246 255/var(--tw-text-opacity))}.dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.dark\:text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.dark\:text-green-50{--tw-text-opacity:1;color:rgb(240 253 244/var(--tw-text-opacity))}.dark\:text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.dark\:text-red-50{--tw-text-opacity:1;color:rgb(254 242 242/var(--tw-text-opacity))}.dark\:text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.dark\:text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.dark\:hover\:bg-blue-300:hover{--tw-bg-opacity:1;background-color:rgb(147 197 253/var(--tw-bg-opacity))}.dark\:hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.dark\:hover\:bg-red-800:hover{--tw-bg-opacity:1;background-color:rgb(153 27 27/var(--tw-bg-opacity))}.hover\:dark\:bg-red-300:hover{--tw-bg-opacity:1;background-color:rgb(252 165 165/var(--tw-bg-opacity))}.dark\:hover\:text-red-200:hover{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity))}} \ No newline at end of file diff --git a/resources/js/Layouts/AppLayout.jsx b/resources/js/Layouts/AppLayout.jsx index e6184ce..610bccd 100644 --- a/resources/js/Layouts/AppLayout.jsx +++ b/resources/js/Layouts/AppLayout.jsx @@ -1,43 +1,13 @@ -import { useEffect, useState } from 'react'; import { Link } from '@inertiajs/react'; -import { Moon, Sun } from 'lucide-react'; import { Separator } from '@radix-ui/react-separator'; -import { Tooltip, TooltipProvider, TooltipTrigger } from '@radix-ui/react-tooltip'; import { AppSidebar } from '@/components/ui/app-sidebar'; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb'; -import { Button } from '@/components/ui/button'; import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; -import { TooltipContent } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/toaster'; export default function AppLayout({ auth, header, children, toolbar }) { - - const getTheme = () => { - if (localStorage.getItem('theme')) { - return localStorage.getItem('theme'); - } - - return 'light'; - } - - const themeButtonOnclickHandler = (theme) => { - // Set local storage - localStorage.setItem('theme', theme); - setTheme(theme); - - const root = window.document.documentElement; - root.classList.remove("light", "dark"); - root.classList.add(theme); - } - - const [theme, setTheme] = useState(getTheme()); - - useEffect(() => { - setTheme(getTheme()); - }, []); - return ( @@ -56,20 +26,6 @@ export default function AppLayout({ auth, header, children, toolbar }) { - - - - { theme === 'dark' ? () : () } - - -

Toggle day / night mode

-
-
-
{ toolbar }
diff --git a/resources/js/Pages/Comic/Histories.jsx b/resources/js/Pages/Comic/Histories.jsx index 9e7e7f6..6014864 100644 --- a/resources/js/Pages/Comic/Histories.jsx +++ b/resources/js/Pages/Comic/Histories.jsx @@ -79,6 +79,12 @@ export default function Histories({ auth, histories }) { + + @@ -135,5 +141,5 @@ export default function Histories({ auth, histories }) { -); + ); } diff --git a/resources/js/Pages/Comic/HistoriesByComic.jsx b/resources/js/Pages/Comic/HistoriesByComic.jsx new file mode 100644 index 0000000..a783854 --- /dev/null +++ b/resources/js/Pages/Comic/HistoriesByComic.jsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import { Head, Link, router } from '@inertiajs/react'; +import AppLayout from '@/Layouts/AppLayout.jsx'; + +import { DateTime } from "luxon"; + +import { BreadcrumbItem, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button.jsx"; + +export default function Histories({ auth, histories }) { + + const deleteButtonOnClickHandler = () => { + router.visit(route('comics.patchHistories'), { + data: (ids.length > 0) ? { ids: ids } : { ids: 'all' }, + method: "PATCH", + only: ['histories'], + onSuccess: data => { + toast({ + title: "All set", + description: `The histories has been deleted.`, + }); + } + }); + } + + const removeDuplicatedButtonOnClickHandler = () => { + router.visit(route('comics.destroyHistories'), { + data: (ids.length > 0) ? { ids: ids } : { ids: 'all' }, + method: "DELETE", + only: ['histories'], + onSuccess: data => { + toast({ + title: "All set", + description: `The duplicated records has been deleted.`, + }); + } + }); + } + + return ( + + + + Histories + + + }> + + Histories + +
+
+ +
+
+ + + Comic + Chapter + Read at + + + + { histories.map((comic, i) => + comic.histories.map((record, j) => ( + + { (j === 0) ? + + { comic.comic.comic_name } + + : null } + + + { record.chapter_name } + + + { record.read_at } + + ) ) + ) } + +
+ + + ); +} diff --git a/resources/js/Pages/Comic/Index.jsx b/resources/js/Pages/Comic/Index.jsx index 0528571..6c37d82 100644 --- a/resources/js/Pages/Comic/Index.jsx +++ b/resources/js/Pages/Comic/Index.jsx @@ -12,11 +12,17 @@ import { useToast } from '@/hooks/use-toast.js'; export default function Index({ comics, offset, auth }) { - const url = new URL(window.location); //searchParams + const url = new URL(window.location); // searchParams const [favourites, setFavourites] = useState(auth.user?.favourites ?? []); const { toast } = useToast(); + /** + * On click handler for the star + * Do posting and make a toast + * + * @param pathword + */ const favouriteOnClickHandler = (pathword) => { axios.post(route('comics.postFavourite'), { pathword: pathword }).then(res => { setFavourites(res.data); @@ -28,6 +34,12 @@ export default function Index({ comics, offset, auth }) { }); } + /** + * Generate info card for comics + * @param props + * @returns {JSX.Element} + * @constructor + */ const ComicCard = (props) => ( @@ -41,7 +53,9 @@ export default function Index({ comics, offset, auth }) { - { props.name } + + { props.name } + { props.author && props.author.map(a => ( @@ -53,6 +67,12 @@ export default function Index({ comics, offset, auth }) { ); + /** + * Loop and return all info cards + * @param comics + * @returns {*} + * @constructor + */ const ComicCards = (comics) => { return comics.list.map((comic, i) => ); } diff --git a/resources/js/Pages/Comic/Read.jsx b/resources/js/Pages/Comic/Read.jsx index 46d9bde..b382778 100644 --- a/resources/js/Pages/Comic/Read.jsx +++ b/resources/js/Pages/Comic/Read.jsx @@ -1,6 +1,6 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Head, Link, router } from '@inertiajs/react'; import AppLayout from '@/Layouts/AppLayout.jsx'; -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { ChevronFirst, ChevronLast, Rows3 } from 'lucide-react'; import { Tooltip, TooltipProvider, TooltipTrigger } from '@radix-ui/react-tooltip'; @@ -19,6 +19,7 @@ export default function Read({ auth, comic, chapter, chapters }) { const validReadingModes = ['rtl', 'utd']; const [readingMode, setReadingMode] = useState('rtl'); // rtl, utd + const [isInvertClickingZone, setIsInvertClickingZone] = useState(false); const [isTwoPagesPerScreen, setIsTwoPagePerScreen] = useState(false); // TODO const [currentImage, setCurrentImage] = useState(1); @@ -35,9 +36,17 @@ export default function Read({ auth, comic, chapter, chapters }) { return "rtl"; } + const getLocalStorageIsInvertClickingZone = () => { + if (window.localStorage.getItem('invertClickingZone') !== null && window.localStorage.getItem('invertClickingZone')) { + return window.localStorage.getItem('invertClickingZone') === 'true'; + } + + return false; + } + const getLocalStorageIsTwoPagePerScreen = () => { - if (window.localStorage.getItem('twoPagesPerScreen') !== null && validReadingModes.includes(window.localStorage.getItem('twoPagesPerScreen'))) { - return window.localStorage.getItem('twoPagesPerScreen'); + if (window.localStorage.getItem('twoPagesPerScreen') !== null && window.localStorage.getItem('twoPagesPerScreen')) { + return window.localStorage.getItem('twoPagesPerScreen') === 'true'; } return false; @@ -68,6 +77,16 @@ export default function Read({ auth, comic, chapter, chapters }) { } } + const toggleInvertClickingZone = (e) => { + if (e) { + window.localStorage.setItem('invertClickingZone', true); + setIsInvertClickingZone(true); + } else { + window.localStorage.setItem('invertClickingZone', false); + setIsInvertClickingZone(false); + } + } + const toggleTwoPagesPerScreen = (e) => { if (e) { window.localStorage.setItem('twoPagesPerScreen', true); @@ -114,24 +133,32 @@ export default function Read({ auth, comic, chapter, chapters }) { const bounds = imgRef.current.getBoundingClientRect(); const percentage = (e.pageX - bounds.left) / imgRef.current.offsetWidth; - if (percentage < 0.45) { + const prevHandler = () => { if (img.innerKey === 0 && chapter.chapter.prev) { router.visit(route('comics.read', [comic.comic.path_word, chapter.chapter.prev])); } else { document.getElementById(`image-${img.innerKey - 1}`)?.scrollIntoView(); } - } else if (percentage > 0.55) { + } + + const nextHandler = () => { if (img.innerKey >= chapter.sorted.length - 1 && chapter.chapter.next) { router.visit(route('comics.read', [comic.comic.path_word, chapter.chapter.next])); } else { document.getElementById(`image-${img.innerKey + 1}`)?.scrollIntoView(); } } + + // InvertClickingZone + if (percentage < 0.45 || percentage > 0.55) { + (percentage < 0.45) ^ isInvertClickingZone ? prevHandler() : nextHandler(); + } }; return (
- {
); @@ -166,7 +193,17 @@ export default function Read({ auth, comic, chapter, chapters }) {
- + +

Turn on for clicking image on right side to previous one

+
+ toggleInvertClickingZone(!isInvertClickingZone) } /> +
+
+
+
+
+

Only applicable to RTL mode

{ setReadingMode(getLocalStorageReadingMode()); + setIsInvertClickingZone(getLocalStorageIsInvertClickingZone()); setIsTwoPagePerScreen(getLocalStorageIsTwoPagePerScreen()); if (!ref.current) return; @@ -333,7 +371,7 @@ export default function Read({ auth, comic, chapter, chapters }) { { chapter.chapter.name.concat(" - ", comic.comic.name) }
+ style={{ overflowAnchor: "none", height: "calc(100dvh - 90px)", overflowY: "scroll" }}> { chapter.sorted.map((img, j) => ) }
diff --git a/resources/js/Pages/Pages/Installation.jsx b/resources/js/Pages/Pages/Installation.jsx index b6a4fe6..4e6335f 100644 --- a/resources/js/Pages/Pages/Installation.jsx +++ b/resources/js/Pages/Pages/Installation.jsx @@ -38,6 +38,7 @@ export default function Installation({ auth }) {
  • Generate Database tables php artisan migrate
  • Install JS dependencies bun install
  • Build frontend JS files bun run build
  • +
  • Run background queue process php artisan queue:listen
  • Visit http://[url]/tags to fetch initial dataset
  • It should be running?
  • diff --git a/resources/js/Pages/Pages/Updates.jsx b/resources/js/Pages/Pages/Updates.jsx index 67cb0dd..93f40f2 100644 --- a/resources/js/Pages/Pages/Updates.jsx +++ b/resources/js/Pages/Pages/Updates.jsx @@ -11,6 +11,22 @@ export default function Updates({ auth }) { Updates
    + + + 0.1.3 + Release: 18 Jan 2025 + + +
      +
    • Fixed: Theme initialization
    • +
    • Toggle clicking spot invert in reading page
    • +
    • Beta: Histories group by comics
    • +
    • Beta: Prefetch first chapter if unread comic clicked
    • +
    • Updated cache TTL
    • +
    • Moved theme toggle to sidebar
    • +
    +
    +
    0.1.2 diff --git a/resources/js/components/ui/app-sidebar.jsx b/resources/js/components/ui/app-sidebar.jsx index 5012e30..347f6e6 100644 --- a/resources/js/components/ui/app-sidebar.jsx +++ b/resources/js/components/ui/app-sidebar.jsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, router, usePage } from '@inertiajs/react'; -import { BadgeCheck, ChevronsUpDown, Star, History, ChevronDown, LogOut, Search, Book, TableOfContents, House, HardDriveDownload } from 'lucide-react'; +import { BadgeCheck, ChevronsUpDown, Star, History, ChevronDown, LogOut, Search, Book, TableOfContents, House, HardDriveDownload, Moon, Sun, Layers, SquarePower } from 'lucide-react'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -9,16 +9,15 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; export function AppSidebar({ auth }) { - const { tags } = usePage().props; + const { tags } = usePage().props; const [search, setSearch] = useState(''); const searchOnSubmitHandler = (e) => { e.preventDefault(); - // Visit the search page - router.get(route('comics.search', [search]), {}, { - }); + // Visit the search page + router.get(route('comics.search', [search])); } const SidebarItem = (props) => { @@ -44,6 +43,36 @@ export function AppSidebar({ auth }) { ); }; + const getTheme = () => { + if (localStorage.getItem('theme')) { + return localStorage.getItem('theme'); + } + + return 'light'; + } + + const themeButtonOnclickHandler = () => { + const t = (theme === 'light') ? 'dark' : 'light'; + + // Set local storage + localStorage.setItem('theme', t); + setTheme(t); + setThemeInHtml(t); + } + + const setThemeInHtml = (theme) => { + const root = window.document.documentElement; + root.classList.remove("light", "dark"); + root.classList.add(theme); + } + + const [theme, setTheme] = useState(getTheme()); + + useEffect(() => { + setTheme(getTheme()); + setThemeInHtml(getTheme()); + }, []); + return ( @@ -57,7 +86,7 @@ export function AppSidebar({ auth }) {
    Comic - 0.1.2 + 0.1.3
    @@ -147,8 +176,7 @@ export function AppSidebar({ auth }) { - @@ -186,6 +214,13 @@ export function AppSidebar({ auth }) { + + History On + + themeButtonOnclickHandler() }> + <>{ theme === 'dark' ? () : () } Toggle theme + + Queues Profile Favourites History diff --git a/resources/views/errors.blade.php b/resources/views/errors.blade.php new file mode 100644 index 0000000..e69de29