Compare commits

..

13 Commits

Author SHA1 Message Date
User
d0c7713d8d 0.1.6B bugfix 2025-08-05 13:41:07 -04:00
User
24737ca896 0.1.6B bugfix 2025-06-28 10:53:15 -04:00
User
a887399300 0.1.6B bugfix 2025-06-17 20:00:12 -04:00
User
d6110dd6db 0.1.6B bugfix 2025-06-17 19:40:41 -04:00
User
2c730be26f 0.1.6A bugfix 2025-05-28 15:15:19 -04:00
User
7837e7db0c 0.1.6A 2025-05-03 14:06:32 -04:00
User
0d2b67ab57 0.1.6A 2025-05-01 22:40:17 -04:00
User
fb23c3b001 0.1.6 2025-04-22 19:45:02 -04:00
User
b7f9b0aff7 0.1.5 2025-02-07 18:24:52 -05:00
User
75d4a616dd 0.1.4 2025-01-21 20:06:29 -05:00
User
d9f57e4e86 0.1.4 2025-01-21 19:57:07 -05:00
User
befc90dd02 0.1.3A 2025-01-19 10:20:44 -05:00
User
14938f63df 0.1.3 2025-01-18 19:05:23 -05:00
35 changed files with 2009 additions and 5677 deletions

View File

@@ -1,66 +0,0 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com/)**
- **[Tighten Co.](https://tighten.co)**
- **[WebReinvent](https://webreinvent.com/)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
- **[Cyber-Duck](https://cyber-duck.co.uk)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Jump24](https://jump24.co.uk)**
- **[Redberry](https://redberry.international/laravel/)**
- **[Active Logic](https://activelogic.com)**
- **[byte5](https://byte5.de)**
- **[OP.GG](https://op.gg)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -63,7 +63,7 @@ class CleanupReadingHistories extends Command
/**
* Clean up duplicate reading histories for a single user.
*
* @param \App\Models\User $user
* @param User $user
* @return void
*/
protected function cleanUpForUser(User $user): void

View File

@@ -3,11 +3,13 @@
namespace App\Http\Controllers;
use App\Helper\ZhConversion;
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 +21,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 +33,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
@@ -53,6 +59,21 @@ class ComicController extends Controller
return str_replace(array_keys($this->zhConversion::ZH_TO_HANT), array_values($this->zhConversion::ZH_TO_HANT), $string);
}
public function toggleHistory(Request $request): JsonResponse
{
if ($request->session()->has('historyDisabled')) {
$newValue = !$request->session()->get('historyDisabled');
} else {
$newValue = true;
}
// Put Session
$request->session()->put('historyDisabled', $newValue);
// Return to frontend
return response()->json(['historyDisabled' => $newValue]);
}
/**
* Show user favourites
*
@@ -61,10 +82,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 +103,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 +144,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 +158,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 +188,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 +210,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 +235,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'), $request->get('reload', false));
// Get the comic object and fill other parameters
try {
@@ -297,15 +289,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
]);
}
@@ -341,7 +338,9 @@ class ComicController extends Controller
ImageUpsert::dispatch($comicObj->id, $chapterObj->id, $chapter);
// Update history
$request->user()->readingHistories()->attach($chapterObj->id, ['comic_id' => $comicObj->id]);
if (!$request->session()->get('historyDisabled', false)) {
$request->user()->readingHistories()->attach($chapterObj->id, ['comic_id' => $comicObj->id]);
}
// Get chapters from DB
$chapters = $comicObj->chapters()->where('metadata->group_path_word', $chapter['chapter']['group_path_word'])->orderBy('order')->get(['name', 'chapter_uuid']);
@@ -351,7 +350,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 +369,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 +465,7 @@ class ComicController extends Controller
*/
public function destroyHistories(Request $request): RedirectResponse
{
$result = $request->user()->cleanUpReadingHistories();
$request->user()->cleanUpReadingHistories();
return redirect()->route('comics.histories');
}

View File

@@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Inertia\Inertia;
use Inertia\Response;
@@ -13,13 +12,12 @@ class PagesController
{
/**
* @param Request $request
* @param string $path
* @return ResponseFactory|Application|\Illuminate\Http\Response|Response
*/
public function show(Request $request, string $path = '')
public function show(string $path = '')
{
if (!File::exists(resource_path("js/Pages/Pages/$path.jsx"))) {
if (!File::exists(resource_path("js/Pages/Pages/{$path}.jsx"))) {
return response('Page not found', 404);
}

View File

@@ -19,7 +19,8 @@ class UserCollection extends ResourceCollection
'name' => $request->user()->name,
'email' => $request->user()->email,
'settings' => $request->user()->settings,
'favourites' => $request->user()->favourites()->get(['pathword'])->pluck('pathword')
'favourites' => $request->user()->favourites()->get(['pathword'])->pluck('pathword'),
'historyDisabled' => $request->session()->get('historyDisabled', false),
];
}
}

67
app/Jobs/ComicUpsert.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
namespace App\Jobs;
use App\Models\Author;
use App\Models\Comic;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class ComicUpsert implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct(
public array $comic,
) {}
/**
* Execute the job.
*/
public function handle(): void
{
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');
}
}

View File

@@ -37,11 +37,12 @@ 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']}");
$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}'");

View File

@@ -55,16 +55,25 @@ class User extends Authenticatable implements MustVerifyEmail
];
}
/**
* @return BelongsToMany
*/
public function favourites(): BelongsToMany
{
return $this->belongsToMany(Comic::class, 'user_favourite')->withTimestamps();
}
/**
* @return BelongsToMany
*/
public function readingHistories(): BelongsToMany
{
return $this->belongsToMany(Chapter::class, 'reading_histories')->withTimestamps();
}
/**
* @return array
*/
public function cleanUpReadingHistories(): array
{
// Get the user's ID

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user) {
return in_array($user->email, [
'turkey@yumj.in'
]);
});
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Remote;
use DOMDocument;
use Dom\HTMLDocument;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
@@ -16,7 +16,7 @@ class CopyManga
{
/**
* @var array Caching options
* @var array{caching: bool, cachingTimeout: int} Caching options
* @deprecated
*/
protected array $options = [
@@ -32,12 +32,14 @@ class CopyManga
/**
* @var string
*/
protected string $url = "https://api.mangacopy.com/api/";
protected string $url = "https://mapi.copy20.com/api/";
/**
* @var string Encryption for legacy image fetch
*
* Since 17/Jun/2025, the key is updated dynamically, added function to fetch the key now
*/
protected string $encryptionKey = "xxxmanga.woo.key";
protected string $encryptionKey = "";
/**
* @var bool Use old method to fetch images list
@@ -123,19 +125,24 @@ class CopyManga
* @param string $method
* @param string $userAgent
* @param int $ttl
* @param bool $force
* @return mixed|string
* @throws GuzzleException
*/
protected function execute(string $url, string $method = 'GET', string $userAgent = "", int $ttl = 0): mixed
protected function execute(string $url, string $method = 'GET', string $userAgent = "", int $ttl = 0, bool $force = false): mixed
{
if ($this->options['caching']) {
// Check cache exist
if (Cache::has("URL_{$url}")) {
$cache = Cache::get("URL_{$url}");
if (isset($cache['type']) && $cache['type'] == 'HTML') {
return $cache['response'];
} else {
return $cache;
if ($force) {
$this->forget($url);
} else {
if ($this->options['caching']) {
// Check cache exist
if (Cache::has("URL_{$url}")) {
$cache = Cache::get("URL_{$url}");
if (isset($cache['type']) && $cache['type'] == 'HTML') {
return $cache['response'];
} else {
return $cache;
}
}
}
}
@@ -146,7 +153,7 @@ class CopyManga
if ($method === 'OPTIONS') {
$options = ['headers' => ['Access-Control-Request-Method' => 'GET']];
} else {
$options = ['headers' => ['platform' => '1', 'version' => '2022.10.28', 'webp' => '0', 'region' => '0', 'Accept' => 'application/json']];
$options = ['headers' => ['platform' => '1', 'version' => '2025.05.09', 'webp' => '0', 'region' => '0', 'Accept' => 'application/json']];
}
$response = $client->request($method, $url, $options);
@@ -178,6 +185,17 @@ class CopyManga
}
}
/**
* Remove a cache with key
*
* @param string $url
* @return void
*/
public function forget(string $url): void
{
Cache::forget("URL_{$url}");
}
/**
* Get tags available
*
@@ -211,10 +229,20 @@ class CopyManga
$parameters['limit'] = $limit;
$parameters['offset'] = $offset;
$parameters['top'] = $top;
$parameters['free_type'] = 1;
$parameters['ordering'] = "-datetime_updated";
$parameters['_update'] = true;
$a = $this->network21();
return $this->execute($this->buildUrl("comics", $parameters), ttl: 15 * 60);
}
public function network21()
{
return $this->execute($this->buildUrl("system/network21", ['platform' => 1]));
}
/**
* Search comic by name
*
@@ -256,16 +284,17 @@ class CopyManga
* @param int $offset
* @param array $parameters
* @param string $group
* @param bool $force
* @return mixed|string
* @throws GuzzleException
*/
public function chapters(string $comic, int $limit = 200, int $offset = 0, array $parameters = [], string $group = "default"): mixed
public function chapters(string $comic, int $limit = 200, int $offset = 0, array $parameters = [], string $group = "default", bool $force = false): mixed
{
$parameters['limit'] = $limit;
$parameters['offset'] = $offset;
$options = $this->execute($this->buildUrl("comic/{$comic}/group/{$group}/chapters", $parameters, false), 'OPTIONS');
return $this->execute($this->buildUrl("comic/{$comic}/group/{$group}/chapters", $parameters), ttl: 24 * 60 * 60);
return $this->execute($this->buildUrl("comic/{$comic}/group/{$group}/chapters", $parameters), ttl: 24 * 60 * 60, force: $force);
}
/**
@@ -296,16 +325,26 @@ 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();
$dom->loadHTML($responses);
$dataNode = $dom->getElementsByTagName("div");
$dom = HTMLDocument::createFromString($responses, LIBXML_NOERROR);
$scriptNodes = $dom->getElementsByTagName('script');
foreach ($scriptNodes as $node) {
if (strpos($node->textContent, 'var jojo') !== false) {
if (preg_match("/var\s+jojo\s*=\s*'([^']+)'/", trim($node->textContent), $matches)) {
$this->encryptionKey = $matches[1];
}
}
}
$dataNodes = $dom->getElementsByTagName("div");
$encryptedData = "";
foreach ($dataNode as $node) {
foreach ($dataNodes as $node) {
if ($node->getAttribute("class") === 'imageData') {
$encryptedData = $node->attributes->item(1)->value;
break;
@@ -346,7 +385,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);

View File

@@ -14,7 +14,7 @@ class ImageFetcher
'cache' => [
'enabled' => true,
'prefix' => 'image_fetcher_',
'ttl' => 3600,
'ttl' => 60 * 60 * 24 * 30,
],
'http' => [
'timeout' => 60,

View File

@@ -1,10 +1,12 @@
<?php
use App\Http\Middleware\HandleInertiaRequests;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Middleware\TrustProxies;
use Sentry\Laravel\Integration;
return Application::configure(basePath: dirname(__DIR__))
@@ -18,10 +20,15 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(append: [
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
TrustProxies::class,
]);
$middleware->statefulApi();
//
$middleware->trustProxies(at: '*');
})
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ServerException $e, Request $request) {
return response()->view('errors', status: 500);
});
Integration::handles($exceptions);
})->create();

View File

@@ -2,4 +2,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
];

BIN
bun.lockb

Binary file not shown.

View File

@@ -6,11 +6,12 @@
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"php": "^8.4",
"ext-dom": "*",
"ext-openssl": "*",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^11.31",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.30",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.9",
"plesk/ext-laravel-integration": "^7.0",
@@ -59,7 +60,7 @@
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
"bun concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"bun run dev\" --names=server,queue,logs,vite"
]
},
"extra": {

1526
composer.lock generated

File diff suppressed because it is too large Load Diff

213
config/horizon.php Normal file
View File

@@ -0,0 +1,213 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 1024,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
'supervisor-1' => [
'maxProcesses' => 3,
],
],
],
];

View File

@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use romanzipp\QueueMonitor\Enums\MonitorStatus;
class CreateQueueMonitorTable extends Migration
{
/**
* Get the customized connection name.
*
* @return string|null
*/
public function getConnection()
{
return config('queue-monitor.connection');
}
public function up()
{
Schema::create(config('queue-monitor.table'), function (Blueprint $table) {
$table->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'));
}
}

178
nixpacks.toml Normal file
View File

@@ -0,0 +1,178 @@
[phases.setup]
nixPkgs = ["...", "python311Packages.supervisor"]
[phases.build]
cmds = [
"mkdir -p /etc/supervisor/conf.d/",
"cp /assets/worker-*.conf /etc/supervisor/conf.d/",
"cp /assets/supervisord.conf /etc/supervisord.conf",
"chmod +x /assets/start.sh",
"..."
]
[start]
cmd = '/assets/start.sh'
[staticAssets]
"start.sh" = '''
#!/bin/bash
# Transform the nginx configuration
node /assets/scripts/prestart.mjs /assets/nginx.template.conf /etc/nginx.conf
# Start supervisor
supervisord -c /etc/supervisord.conf -n
'''
"supervisord.conf" = '''
[unix_http_server]
file=/assets/supervisor.sock
[supervisord]
logfile=/var/log/supervisord.log
logfile_maxbytes=50MB
logfile_backups=10
loglevel=info
pidfile=/assets/supervisord.pid
nodaemon=false
silent=false
minfds=1024
minprocs=200
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///assets/supervisor.sock
[include]
files = /etc/supervisor/conf.d/*.conf
'''
"worker-nginx.conf" = '''
[program:worker-nginx]
process_name=%(program_name)s_%(process_num)02d
command=nginx -c /etc/nginx.conf
autostart=true
autorestart=true
stdout_logfile=/var/log/worker-nginx.log
stderr_logfile=/var/log/worker-nginx.log
'''
"worker-phpfpm.conf" = '''
[program:worker-phpfpm]
process_name=%(program_name)s_%(process_num)02d
command=php-fpm -y /assets/php-fpm.conf -F
autostart=true
autorestart=true
stdout_logfile=/var/log/worker-phpfpm.log
stderr_logfile=/var/log/worker-phpfpm.log
'''
"worker-laravel.conf" = '''
[program:worker-laravel]
process_name=%(program_name)s_%(process_num)02d
command=bash -c 'exec php /app/artisan queue:work --sleep=3 --tries=3 --max-time=3600'
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
numprocs=16 # To reduce memory/CPU usage, change to 2.
startsecs=0
stopwaitsecs=3600
stdout_logfile=/var/log/worker-laravel.log
stderr_logfile=/var/log/worker-laravel.log
'''
"php-fpm.conf" = '''
[www]
listen = 127.0.0.1:9000
user = www-data
group = www-data
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 50
pm.min_spare_servers = 4
pm.max_spare_servers = 32
pm.start_servers = 18
clear_env = no
php_admin_value[post_max_size] = 35M
php_admin_value[upload_max_filesize] = 30M
'''
"nginx.template.conf" = '''
user www-data www-data;
worker_processes 5;
daemon off;
worker_rlimit_nofile 8192;
events {
worker_connections 4096; # Default: 1024
}
http {
include $!{nginx}/conf/mime.types;
index index.html index.htm index.php;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] $status '
'"$request" $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx-access.log;
error_log /var/log/nginx-error.log;
sendfile on;
tcp_nopush on;
server_names_hash_bucket_size 128; # this seems to be required for some vhosts
server {
listen ${PORT};
listen [::]:${PORT};
server_name localhost;
$if(NIXPACKS_PHP_ROOT_DIR) (
root ${NIXPACKS_PHP_ROOT_DIR};
) else (
root /app;
)
add_header X-Content-Type-Options "nosniff";
client_max_body_size 35M;
index index.php;
charset utf-8;
$if(NIXPACKS_PHP_FALLBACK_PATH) (
location / {
try_files $uri $uri/ ${NIXPACKS_PHP_FALLBACK_PATH}?$query_string;
}
) else (
location / {
try_files $uri $uri/ /index.php?$query_string;
}
)
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
$if(IS_LARAVEL) (
error_page 404 /index.php;
) else ()
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_buffer_size 8k;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include $!{nginx}/conf/fastcgi_params;
include $!{nginx}/conf/fastcgi.conf;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
}
'''

4651
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,46 +6,48 @@
"dev": "vite"
},
"devDependencies": {
"@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.0.0",
"@headlessui/react": "^2.2.7",
"@inertiajs/react": "^2.0.17",
"@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",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.21",
"axios": "^1.11.0",
"concurrently": "^9.2.0",
"laravel-vite-plugin": "^1.3.0",
"postcss": "^8.5.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
"vite": "^6.3.5"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@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/vite-plugin": "^2.23.0",
"@tanstack/react-table": "^8.20.6",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@sentry/react": "^8.55.0",
"@sentry/vite-plugin": "^2.23.1",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lodash": "^4.17.21",
"lucide-react": "^0.468.0",
"luxon": "^3.5.0",
"react-hook-form": "^7.54.2",
"luxon": "^3.7.1",
"react-hook-form": "^7.62.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"
"use-double-tap": "^1.3.7",
"use-long-press": "^3.3.0",
"zod": "^3.25.76"
}
}

1
public/vendor/queue-monitor/app.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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 (
<SidebarProvider>
<AppSidebar auth={ auth } />
@@ -56,20 +26,6 @@ export default function AppLayout({ auth, header, children, toolbar }) {
</BreadcrumbList>
</Breadcrumb>
<span className="flex gap-1 ml-auto justify-center content-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{ theme === 'dark' ? (<Button variant="link" size="icon" onClick={ () => themeButtonOnclickHandler('light') }>
<Sun />
</Button>) : (<Button variant="link" size="icon" onClick={ () => themeButtonOnclickHandler('dark') }>
<Moon />
</Button>) }
</TooltipTrigger>
<TooltipContent>
<p>Toggle day / night mode</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{ toolbar }
</span>
</header>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Head, Link, router } from '@inertiajs/react';
import { Plus, Star, ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronsLeft, ChevronsRight, Trash2 } from 'lucide-react';
import { Plus, Star, ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronsLeft, ChevronsRight, Trash2, RefreshCw } from 'lucide-react';
import AppLayout from '@/Layouts/AppLayout.jsx';
@@ -47,6 +47,13 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
});
}
const forceReload = (pathword) => {
router.get(`/comic/${ comic.comic.path_word }?reload=true`, {}, {
only: ['chapters'],
preserveState: true,
});
}
const groupOnClickHandler = (pathword) => {
router.get(`/comic/${ comic.comic.path_word }?group=${ pathword }`, {}, {
only: ['chapters'],
@@ -59,7 +66,7 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
}
const ComicChapterLink = (props) => {
const isNew = Date.now() - Date.parse(props.datetime_created) < 6.048e+8;
const isNew = Date.now() - Date.parse(props.datetime_created) < 6.048e+8; // 1 week
const isRead = histories.includes(props.uuid);
return (
@@ -97,7 +104,8 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
<Head>
<title>{ comic.comic.name }</title>
</Head>
<div className="p-3 pt-1">
<div className="p-3 pt-1 pb-1 flex flex-wrap justify-center" scroll-region="true"
style={{ overflowAnchor: "none", height: "calc(100dvh - 90px)", overflowY: "scroll" }}>
<Card>
<CardHeader>
<CardTitle className="flex flex-row content-end items-center">
@@ -188,6 +196,19 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="link" size="icon" onClick={ () => forceReload(comic.comic.path_word) }>
<RefreshCw />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Force reload</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -215,7 +236,7 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
{ chapters.list.sort((a, b) => ascending ? (a.index - b.index) : (b.index - a.index)).map(c => (
<ComicChapterLink key={ c.uuid } { ...c } />
) ) }
{ (chapters.total > chapters.limit && chapters.total > chapters.offset) && (
{ (chapters.total > (chapters.limit + chapters.offset)) && (
<Button size="sm" variant="outline" asChild>
<Link href="?" only={['chapters', 'offset']} headers={{ offset: parseInt(chapters.offset) + chapters.limit }}>
Next <ChevronsRight />

View File

@@ -73,14 +73,20 @@ export default function Histories({ auth, histories }) {
<div className="p-3 pt-1 w-[90%] mx-auto">
<div className="flex justify-end gap-2">
<Button size="sm" variant="destructive" onClick={ () => deleteButtonOnClickHandler() }>
{ ids.length > 0 ? `Delete selected (${ids.length})` : "Delete All" }
{ ids.length > 0 ? `Delete selected (${ ids.length })` : "Delete All" }
</Button>
<Button size="sm" variant="destructive" onClick={ () => removeDuplicatedButtonOnClickHandler() }>
Remove duplicates
</Button>
<Button size="sm" asChild>
<Link href="?group=comic">
Group by Comics
</Link>
</Button>
</div>
<Table>
<Table className="mt-2">
<TableHeader>
<TableRow>
<TableHead>Select</TableHead>
@@ -114,26 +120,20 @@ export default function Histories({ auth, histories }) {
)) }
</TableBody>
</Table>
<div>
<div className="pt-2">
<Pagination className="justify-end">
<PaginationContent>
{ histories.current_page > 1 && (
<PaginationItem>
<PaginationPrevious href={ histories.prev_page_url } only={['histories']} />
{ histories.links.map((h, i) => (
<PaginationItem key={ i }>
{ h.label.includes('Previous') && <PaginationPrevious href={ h.url !== null ? h.url : "#" } only={['histories']} isActive={ h.active } /> }
{ !h.label.includes('Previous') && !h.label.includes('Next') && <PaginationLink href={ h.url !== null ? h.url : "#" } only={['histories']} isActive={ h.active }>{ h.label }</PaginationLink> }
{ h.label.includes('Next') && <PaginationNext href={ h.url !== null ? h.url : "#" } only={['histories']} isActive={ h.active } /> }
</PaginationItem>
) }
<PaginationItem>
<PaginationLink href="#">{ histories.current_page }</PaginationLink>
</PaginationItem>
{ histories.current_page < histories.last_page && (
<PaginationItem>
<PaginationNext href={ histories.next_page_url } only={['histories']} />
</PaginationItem>
) }
) ) }
</PaginationContent>
</Pagination>
</div>
</div>
</AppLayout>
);
);
}

View File

@@ -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 (
<AppLayout auth={ auth } header={
<>
<BreadcrumbSeparator className="hidden lg:block" />
<BreadcrumbItem>
<BreadcrumbPage>Histories</BreadcrumbPage>
</BreadcrumbItem>
</>
}>
<Head>
<title>Histories</title>
</Head>
<div className="p-3 pt-1 w-[90%] mx-auto">
<div className="flex justify-end gap-2">
<Button size="sm" asChild>
<Link href="?group=">
Group by Chapter
</Link>
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Comic</TableHead>
<TableHead>Chapter</TableHead>
<TableHead>Read at</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ histories.map((comic, i) => comic.histories.map((record, j) => (
<TableRow key={ j }>
{ (j === 0) && <TableCell className="w-[40%]" rowSpan={ comic.histories.length }>
<Link href={ route('comics.chapters', comic.comic.comic_pathword) }>
{ comic.comic.comic_name }
</Link>
</TableCell> }
<TableCell>
<Link href={ route('comics.read', [comic.comic.comic_pathword, record.chapter_uuid]) }>
{ record.chapter_name }
</Link>
</TableCell>
<TableCell>
{ record.read_at }
</TableCell>
</TableRow>
) ) ) }
</TableBody>
</Table>
</div>
</AppLayout>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { Head, Link } from '@inertiajs/react';
import { Star } from 'lucide-react';
@@ -10,13 +10,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from '@/components/ui/pagination';
import { useToast } from '@/hooks/use-toast.js';
export default function Index({ comics, offset, auth }) {
export default function Index({ comics = [], offset = 0, auth = {} }) {
const url = new URL(window.location); //searchParams
const [favourites, setFavourites] = useState(auth.user?.favourites ?? []);
const url = new URL(window.location); // searchParams
const [favourites, setFavourites] = useState((auth?.user?.favourites !== null) ? auth.user.favourites : []);
const { toast } = useToast();
const itemsPerPage = 30;
/**
* 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 +35,12 @@ export default function Index({ comics, offset, auth }) {
});
}
/**
* Generate info card for comics
* @param props
* @returns {JSX.Element}
* @constructor
*/
const ComicCard = (props) => (
<Card>
<CardHeader>
@@ -41,7 +54,9 @@ export default function Index({ comics, offset, auth }) {
</div>
</CardHeader>
<CardContent>
<CardTitle><Link href={ `/comic/${ props.path_word }` }>{ props.name }</Link></CardTitle>
<CardTitle>
<Link href={ `/comic/${ props.path_word }` }>{ props.name }</Link>
</CardTitle>
<CardDescription className="pt-2">
{ props.author && props.author.map(a => (
<Badge className="m-1" key={ a.path_word } variant="outline">
@@ -53,6 +68,12 @@ export default function Index({ comics, offset, auth }) {
</Card>
);
/**
* Loop and return all info cards
* @param comics
* @returns {*}
* @constructor
*/
const ComicCards = (comics) => {
return comics.list.map((comic, i) => <ComicCard key={ i } { ...comic } />);
}
@@ -62,21 +83,26 @@ export default function Index({ comics, offset, auth }) {
<Head>
<title>Home</title>
</Head>
<div className="p-3 pt-1 grid 2xl:grid-cols-6 xl:grid-cols-4 sm:grid-cols-2 gap-2">
<ComicCards { ...comics } />
<div className="p-3 pt-1 pb-1 flex flex-wrap justify-center" scroll-region="true"
style={{ overflowAnchor: "none", height: "calc(100dvh - 90px)", overflowY: "scroll" }}>
<div className="grid 2xl:grid-cols-6 xl:grid-cols-4 grid-cols-2 gap-2">
<ComicCards { ...comics } />
</div>
<Pagination className="justify-end pt-2">
<PaginationContent>
{ parseInt(offset) !== 0 &&
<PaginationItem>
<PaginationPrevious href={ `${ url.pathname }?${ url.searchParams }` } only={['comics', 'offset']} headers={{ offset: parseInt(offset) - itemsPerPage }} />
</PaginationItem>
}
{ parseInt(comics.total) > parseInt(offset) + itemsPerPage &&
<PaginationItem>
<PaginationNext href={ `${ url.pathname }?${ url.searchParams }` } only={['comics', 'offset']} headers={{ offset: parseInt(offset) + itemsPerPage }} />
</PaginationItem>
}
</PaginationContent>
</Pagination>
</div>
<Pagination className="justify-end pb-2">
<PaginationContent>
{ parseInt(offset) !== 0 &&
<PaginationItem>
<PaginationPrevious href={ `${ url.pathname }?${ url.searchParams }` } only={['comics', 'offset']} headers={{ offset: parseInt(offset) - 30 }} />
</PaginationItem>
}
<PaginationItem>
<PaginationNext href={ `${ url.pathname }?${ url.searchParams }` } only={['comics', 'offset']} headers={{ offset: parseInt(offset) + 30 }} />
</PaginationItem>
</PaginationContent>
</Pagination>
</AppLayout>
);
}

View File

@@ -1,8 +1,9 @@
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';
import { useLongPress } from "use-long-press";
import { throttle } from 'lodash';
@@ -19,34 +20,18 @@ 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);
const [divDimensions, setDivDimensions] = useState([0, 0]);
const ref = useRef();
const windowSize = useWindowSize();
const getLocalStorageReadingMode = () => {
if (window.localStorage.getItem('readingMode') !== null && validReadingModes.includes(window.localStorage.getItem('readingMode'))) {
return window.localStorage.getItem('readingMode');
}
return "rtl";
}
const getLocalStorageIsTwoPagePerScreen = () => {
if (window.localStorage.getItem('twoPagesPerScreen') !== null && validReadingModes.includes(window.localStorage.getItem('twoPagesPerScreen'))) {
return window.localStorage.getItem('twoPagesPerScreen');
}
return false;
}
function useWindowSize() {
const useWindowSize = () => {
const [size, setSize] = useState([0, 0]);
useLayoutEffect(() => {
function updateSize() {
const updateSize = () => {
setSize([window.innerWidth, window.innerHeight]);
}
@@ -58,6 +43,32 @@ export default function Read({ auth, comic, chapter, chapters }) {
return size;
}
const windowSize = useWindowSize();
const getLocalStorageReadingMode = () => {
if (window.localStorage.getItem('readingMode') !== null && validReadingModes.includes(window.localStorage.getItem('readingMode'))) {
return window.localStorage.getItem('readingMode');
}
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 && window.localStorage.getItem('twoPagesPerScreen')) {
return window.localStorage.getItem('twoPagesPerScreen') === 'true';
}
return false;
}
const toggleReadingMode = (e) => {
if (e) {
window.localStorage.setItem('readingMode', 'utd');
@@ -68,6 +79,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);
@@ -95,6 +116,13 @@ export default function Read({ auth, comic, chapter, chapters }) {
//console.log(e.target.naturalHeight);
}
const longPress = useLongPress((e) => {
const percentage = e.pageX / windowSize[0];
if (percentage < 0.45 || percentage > 0.55) {
(percentage < 0.45) ^ isInvertClickingZone ? router.get(route('comics.read', [comic.comic.path_word, chapter.chapter.prev])) : router.get(route('comics.read', [comic.comic.path_word, chapter.chapter.next]));
}
});
const ImageForComic = (img) => {
const imgRef = useRef();
@@ -114,25 +142,33 @@ 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();
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();
document.getElementById(`image-${ img.innerKey + 1 }`)?.scrollIntoView();
}
}
// InvertClickingZone
if (percentage < 0.45 || percentage > 0.55) {
(percentage < 0.45) ^ isInvertClickingZone ? prevHandler() : nextHandler();
}
};
return (
<div className="basis-full">
<img alt={ comic.comic.name } className={` m-auto comic-img `} id={ `image-${ img.innerKey }` } ref={ imgRef }
onClick={ handleImageClick } src={ `/image/${ btoa(img.url) }` } style={ imgStyles } />
<img alt={ comic.comic.name } className={` m-auto comic-img `} id={ `image-${ img.innerKey }` }
onClick={ handleImageClick } ref={ imgRef }
src={ `/image/${ btoa(img.url) }` } style={ imgStyles } />
</div>
);
}
@@ -166,7 +202,17 @@ export default function Read({ auth, comic, chapter, chapters }) {
<div className="space-y-4">
<div className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<label>Two images per screen</label>
<label>Flip clicking zone</label>
<p>Turn on for clicking image on right side to previous one</p>
</div>
<Switch defaultChecked={ (isInvertClickingZone) }
onCheckedChange={ (e) => toggleInvertClickingZone(!isInvertClickingZone) } />
</div>
</div>
<div className="space-y-4">
<div className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<label>Two images per screen TODO</label>
<p>Only applicable to RTL mode</p>
</div>
<Switch defaultChecked={ (isTwoPagesPerScreen) }
@@ -277,6 +323,7 @@ export default function Read({ auth, comic, chapter, chapters }) {
useEffect(() => {
setReadingMode(getLocalStorageReadingMode());
setIsInvertClickingZone(getLocalStorageIsInvertClickingZone());
setIsTwoPagePerScreen(getLocalStorageIsTwoPagePerScreen());
if (!ref.current) return;
@@ -332,8 +379,8 @@ export default function Read({ auth, comic, chapter, chapters }) {
<Head>
<title>{ chapter.chapter.name.concat(" - ", comic.comic.name) }</title>
</Head>
<div className="p-3 pt-1 pb-1 flex flex-wrap justify-center" id="mvp" ref={ ref }
style={ { overflowAnchor: "none", height: "calc(100dvh - 90px)", overflowY: "scroll" } }>
<div className="p-3 pt-1 pb-1 flex flex-wrap justify-center" id="mvp" ref={ ref } scroll-region="true" { ...longPress() }
style={{ overflowAnchor: "none", height: "calc(100dvh - 90px)", overflowY: "scroll" }}>
{ chapter.sorted.map((img, j) => <ImageForComic key={ j } innerKey={ j } { ...img } />) }
</div>
</AppLayout>

View File

@@ -38,6 +38,7 @@ export default function Installation({ auth }) {
<li>Generate Database tables <span className="rounded-md border m-1 p-1 font-mono text-sm shadow-sm">php artisan migrate</span></li>
<li>Install JS dependencies <span className="rounded-md border m-1 p-1 font-mono text-sm shadow-sm">bun install</span></li>
<li>Build frontend JS files <span className="rounded-md border m-1 p-1 font-mono text-sm shadow-sm">bun run build</span></li>
<li>Run background queue process <span className="rounded-md border m-1 p-1 font-mono text-sm shadow-sm">php artisan queue:listen</span></li>
<li>Visit <span className="rounded-md border m-1 p-1 font-mono text-sm shadow-sm">http://[url]/tags</span> to fetch initial dataset</li>
<li>It should be running?</li>
</ol>

View File

@@ -11,6 +11,68 @@ export default function Updates({ auth }) {
<title>Updates</title>
</Head>
<div className="p-3 pt-1">
<Card className="w-[90%] m-3 mx-auto">
<CardHeader>
<CardTitle>0.1.6A</CardTitle>
<CardDescription>Release: 01 May 2025</CardDescription>
</CardHeader>
<CardContent>
<ul>
<li>Bugfix for histories</li>
</ul>
</CardContent>
<CardHeader>
<CardTitle>0.1.6</CardTitle>
<CardDescription>Release: 22 Apr 2025</CardDescription>
</CardHeader>
<CardContent>
<ul>
<li>Beta: long press for next/prev chapter</li>
</ul>
</CardContent>
</Card>
<Card className="w-[90%] m-3 mx-auto">
<CardHeader>
<CardTitle>0.1.5</CardTitle>
<CardDescription>Release: 07 Feb 2025</CardDescription>
</CardHeader>
<CardContent>
<ul>
<li>General bug fixes and system improvements</li>
<li>Toggle reading history</li>
<li>Queue driver, Horizon is now default driver</li>
</ul>
</CardContent>
</Card>
<Card className="w-[90%] m-3 mx-auto">
<CardHeader>
<CardTitle>0.1.4</CardTitle>
<CardDescription>Release: 21 Jan 2025</CardDescription>
</CardHeader>
<CardContent>
<ul>
<li>Ability for force refetch on chapters</li>
<li>Fixed top toolbar on comic pages</li>
</ul>
</CardContent>
</Card>
<Card className="w-[90%] m-3 mx-auto">
<CardHeader>
<CardTitle>0.1.3 / 0.1.3A</CardTitle>
<CardDescription>Release: 19 Jan 2025 / 18 Jan 2025</CardDescription>
</CardHeader>
<CardContent>
<ul>
<li>0.1.3A: Remove lazy loading</li>
<li>Fixed: Theme initialization</li>
<li>Toggle clicking spot invert in reading page</li>
<li>Beta: Histories group by comics</li>
<li>Beta: Prefetch first chapter if unread comic clicked</li>
<li>Updated cache TTL</li>
<li>Moved theme toggle to sidebar</li>
</ul>
</CardContent>
</Card>
<Card className="w-[90%] m-3 mx-auto">
<CardHeader>
<CardTitle>0.1.2</CardTitle>

View File

@@ -17,7 +17,7 @@ Sentry.init({
// Tracing
tracesSampleRate: 0.1, // Capture 10% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: ["localhost", /^https:\/\/c\.yumj\.in/],
tracePropagationTargets: [/^https:\/\/c\.yumj\.in/],
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.

View File

@@ -1,24 +1,28 @@
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';
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
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;
import { useToast } from '@/hooks/use-toast.js';
export function AppSidebar({ auth }) {
const { tags } = usePage().props;
const [search, setSearch] = useState('');
const [historyDisabled, setHistoryDisabled] = useState(false);
const { toast } = useToast();
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 +48,49 @@ 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());
const toggleHistoryButtonHandler = () => {
axios.post(route('comics.toggleHistory')).then(res => {
setHistoryDisabled(res.data.historyDisabled);
toast({
title: "All set",
description: `Histories are now ${ res.data.historyDisabled ? 'Disabled' : 'Enabled' }`,
});
});
}
useEffect(() => {
setTheme(getTheme());
setThemeInHtml(getTheme());
// Set historyDisabled
setHistoryDisabled(auth.user.historyDisabled);
}, []);
return (
<Sidebar>
<SidebarContent>
@@ -57,7 +104,7 @@ export function AppSidebar({ auth }) {
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">Comic</span>
<span>0.1.2</span>
<span>0.1.6A</span>
</div>
</Link>
</SidebarMenuButton>
@@ -147,8 +194,7 @@ export function AppSidebar({ auth }) {
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
<SidebarMenuButton size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
@@ -186,6 +232,13 @@ export function AppSidebar({ auth }) {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={ () => toggleHistoryButtonHandler() }>
<SquarePower /> { (historyDisabled) ? "History Off" : "History On" }
</DropdownMenuItem>
<DropdownMenuItem onClick={ () => themeButtonOnclickHandler() }>
<>{ theme === 'dark' ? (<Sun />) : (<Moon />) } Toggle theme</>
</DropdownMenuItem>
<DropdownMenuItem asChild><a href="/horizon" target="_blank"><Layers /> Queues</a></DropdownMenuItem>
<DropdownMenuItem asChild><Link href={ route('profile.edit') }><BadgeCheck /> Profile</Link></DropdownMenuItem>
<DropdownMenuItem asChild><Link href={ route('comics.favourites') }><Star /> Favourites</Link></DropdownMenuItem>
<DropdownMenuItem asChild><Link href={ route('comics.histories') }><History /> History</Link></DropdownMenuItem>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0, minimal-ui">
<link rel="manifest" href="manifest.json">
<link rel="manifest" href="/manifest.json">
<title inertia>{{ config('app.name', 'Laravel') }}</title>
@routes
@viteReactRefresh

View File

View File

@@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
// Auth protected routes
Route::controller(ComicController::class)->middleware('auth')->name('comics.')->group(function () {
Route::controller(ComicController::class)->middleware(['auth', 'verified'])->name('comics.')->group(function () {
Route::get('/', 'index')->name('index');
Route::get('/author/{author}', 'author')->name('author');
Route::get('/search/{search}', 'search')->name('search');
@@ -30,13 +30,15 @@ Route::controller(ComicController::class)->middleware('auth')->name('comics.')->
Route::patch('/histories', 'patchHistories')->name('patchHistories');
Route::delete('/history/{pathword}', 'destroyHistory')->name('destroyHistory');
Route::delete('/histories', 'destroyHistories')->name('destroyHistories');
// History Toggle
Route::post('/toggleHistory', 'toggleHistory')->name('toggleHistory');
});
Route::controller(PagesController::class)->middleware('auth')->name('pages.')->group(function () {
Route::get('/pages/{path?}', 'show')->name('show');
});
Route::get('/dashboard', function () {
return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');