Compare commits
10 Commits
3f282d6ecd
...
d6110dd6db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6110dd6db | ||
|
|
2c730be26f | ||
|
|
7837e7db0c | ||
|
|
0d2b67ab57 | ||
|
|
fb23c3b001 | ||
|
|
b7f9b0aff7 | ||
|
|
75d4a616dd | ||
|
|
d9f57e4e86 | ||
|
|
befc90dd02 | ||
|
|
14938f63df |
66
README.md
66
README.md
@@ -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).
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class CleanupReadingHistories extends Command
|
|||||||
/**
|
/**
|
||||||
* Clean up duplicate reading histories for a single user.
|
* Clean up duplicate reading histories for a single user.
|
||||||
*
|
*
|
||||||
* @param \App\Models\User $user
|
* @param User $user
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
protected function cleanUpForUser(User $user): void
|
protected function cleanUpForUser(User $user): void
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Helper\ZhConversion;
|
use App\Helper\ZhConversion;
|
||||||
|
use App\Jobs\ComicUpsert;
|
||||||
use App\Jobs\ImageUpsert;
|
use App\Jobs\ImageUpsert;
|
||||||
use App\Jobs\RemotePrefetch;
|
use App\Jobs\RemotePrefetch;
|
||||||
use App\Models\Author;
|
use App\Models\Author;
|
||||||
use App\Models\Chapter;
|
use App\Models\Chapter;
|
||||||
use App\Models\Comic;
|
use App\Models\Comic;
|
||||||
|
use App\Models\ReadingHistory;
|
||||||
use App\Remote\CopyManga;
|
use App\Remote\CopyManga;
|
||||||
use App\Remote\ImageFetcher;
|
use App\Remote\ImageFetcher;
|
||||||
use GuzzleHttp\Exception\GuzzleException;
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
@@ -19,6 +21,7 @@ use Illuminate\Http\JsonResponse;
|
|||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response as IlluminateHttpResponse;
|
use Illuminate\Http\Response as IlluminateHttpResponse;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -30,12 +33,15 @@ class ComicController extends Controller
|
|||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Autoload classes
|
||||||
|
*
|
||||||
* @param CopyManga $copyManga
|
* @param CopyManga $copyManga
|
||||||
* @param ZhConversion $zhConversion
|
* @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
|
* 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);
|
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
|
* Show user favourites
|
||||||
*
|
*
|
||||||
@@ -61,10 +82,8 @@ class ComicController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function favourites(Request $request): Response
|
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', [
|
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
|
// Fetch from remote
|
||||||
$remoteComic = $this->copyManga->comic($request->pathword);
|
$remoteComic = $this->copyManga->comic($request->pathword);
|
||||||
|
|
||||||
$comic = new Comic;
|
$comic = Comic::create([
|
||||||
$comic->pathword = $remoteComic['comic']['path_word'];
|
'pathword' => $remoteComic['comic']['path_word'],
|
||||||
$comic->name = $remoteComic['comic']['name'];
|
'name' => $remoteComic['comic']['name'],
|
||||||
$comic->cover = $remoteComic['comic']['cover'];
|
'cover' => $remoteComic['comic']['cover'],
|
||||||
$comic->upstream_updated_at = $remoteComic['comic']['datetime_updated'];
|
'upstream_updated_at' => $remoteComic['comic']['datetime_updated'],
|
||||||
$comic->uuid = $remoteComic['comic']['uuid'];
|
'uuid' => $remoteComic['comic']['uuid'],
|
||||||
$comic->alias = explode(',', $remoteComic['comic']['alias']);
|
'alias' => explode(',', $remoteComic['comic']['alias']),
|
||||||
$comic->description = $remoteComic['comic']['brief'];
|
'description' => $remoteComic['comic']['brief'],
|
||||||
$comic->metadata = $remoteComic;
|
'metadata' => $remoteComic,
|
||||||
$comic->save();
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set favourite
|
// 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
|
* Index / Tags for comic listing
|
||||||
*
|
*
|
||||||
@@ -180,12 +158,23 @@ class ComicController extends Controller
|
|||||||
$params['theme'] = $request->get('tag');
|
$params['theme'] = $request->get('tag');
|
||||||
}
|
}
|
||||||
|
|
||||||
$comics = $this->copyManga->comics(30, $request->header('offset', 0), $request->get('top', 'all'), $params);
|
$offset = $request->header('offset', 0);
|
||||||
$this->comicsUpsert($comics);
|
$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', [
|
return Inertia::render('Comic/Index', [
|
||||||
'comics' => $this->scToZh($comics),
|
'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
|
public function author(Request $request, string $author): Response
|
||||||
{
|
{
|
||||||
$params = [];
|
$offset = $request->header('offset', 0);
|
||||||
$params['author'] = $author;
|
$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);
|
// Upsert into DB
|
||||||
$this->comicsUpsert($comics);
|
ComicUpsert::dispatch($comics);
|
||||||
|
|
||||||
return Inertia::render('Comic/Index', [
|
return Inertia::render('Comic/Index', [
|
||||||
'comics' => $this->scToZh($comics),
|
'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
|
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
|
// Search API is limited, no upsert
|
||||||
|
|
||||||
return Inertia::render('Comic/Index', [
|
return Inertia::render('Comic/Index', [
|
||||||
'comics' => $this->scToZh($comics),
|
'comics' => $this->scToZh($comics),
|
||||||
'offset' => $request->header('offset', 0)
|
'offset' => $offset
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,8 +235,10 @@ class ComicController extends Controller
|
|||||||
return to_route('comics.index');
|
return to_route('comics.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$offset = $request->header('offset', 0);
|
||||||
|
|
||||||
$comic = $this->copyManga->comic($pathword);
|
$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
|
// Get the comic object and fill other parameters
|
||||||
try {
|
try {
|
||||||
@@ -297,15 +289,20 @@ class ComicController extends Controller
|
|||||||
// Do an upsert
|
// Do an upsert
|
||||||
Chapter::upsert($arrayForUpsert, uniqueBy: 'chapter_uuid');
|
Chapter::upsert($arrayForUpsert, uniqueBy: 'chapter_uuid');
|
||||||
|
|
||||||
// Get history
|
// Get user history
|
||||||
$histories = $request->user()->readingHistories()->where('reading_histories.comic_id', $comicObject->id)
|
$histories = $request->user()->readingHistories()->where('reading_histories.comic_id', $comicObject->id)
|
||||||
->distinct()->select('chapter_uuid')->get()->pluck('chapter_uuid');
|
->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', [
|
return Inertia::render('Comic/Chapters', [
|
||||||
'comic' => $this->scToZh($comic),
|
'comic' => $this->scToZh($comic),
|
||||||
'chapters' => $this->scToZh($chapters),
|
'chapters' => $this->scToZh($chapters),
|
||||||
'histories' => $histories,
|
'histories' => $histories,
|
||||||
'offset' => $request->header('offset', 0)
|
'offset' => $offset
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +338,9 @@ class ComicController extends Controller
|
|||||||
ImageUpsert::dispatch($comicObj->id, $chapterObj->id, $chapter);
|
ImageUpsert::dispatch($comicObj->id, $chapterObj->id, $chapter);
|
||||||
|
|
||||||
// Update history
|
// Update history
|
||||||
|
if (!$request->session()->get('historyDisabled', false)) {
|
||||||
$request->user()->readingHistories()->attach($chapterObj->id, ['comic_id' => $comicObj->id]);
|
$request->user()->readingHistories()->attach($chapterObj->id, ['comic_id' => $comicObj->id]);
|
||||||
|
}
|
||||||
|
|
||||||
// Get chapters from DB
|
// Get chapters from DB
|
||||||
$chapters = $comicObj->chapters()->where('metadata->group_path_word', $chapter['chapter']['group_path_word'])->orderBy('order')->get(['name', 'chapter_uuid']);
|
$chapters = $comicObj->chapters()->where('metadata->group_path_word', $chapter['chapter']['group_path_word'])->orderBy('order')->get(['name', 'chapter_uuid']);
|
||||||
@@ -370,7 +369,48 @@ class ComicController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function histories(Request $request): Response
|
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')
|
$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();
|
->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
|
public function destroyHistories(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$result = $request->user()->cleanUpReadingHistories();
|
$request->user()->cleanUpReadingHistories();
|
||||||
|
|
||||||
return redirect()->route('comics.histories');
|
return redirect()->route('comics.histories');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use Illuminate\Contracts\Routing\ResponseFactory;
|
use Illuminate\Contracts\Routing\ResponseFactory;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -13,13 +12,12 @@ class PagesController
|
|||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Request $request
|
|
||||||
* @param string $path
|
* @param string $path
|
||||||
* @return ResponseFactory|Application|\Illuminate\Http\Response|Response
|
* @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);
|
return response('Page not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ class UserCollection extends ResourceCollection
|
|||||||
'name' => $request->user()->name,
|
'name' => $request->user()->name,
|
||||||
'email' => $request->user()->email,
|
'email' => $request->user()->email,
|
||||||
'settings' => $request->user()->settings,
|
'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
67
app/Jobs/ComicUpsert.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,11 +37,12 @@ class RemotePrefetch implements ShouldQueue
|
|||||||
case 'chapters':
|
case 'chapters':
|
||||||
// TODO:
|
// TODO:
|
||||||
break;
|
break;
|
||||||
case 'index':
|
case 'comics':
|
||||||
// TODO
|
Log::info("JOB RemotePrefetch START, action '{$this->action}', Offset: {$this->parameters['offset']}");
|
||||||
break;
|
|
||||||
case 'tags':
|
$copyManga->comics($this->parameters['offset'], $this->parameters['limit'], $this->parameters['top'], $this->parameters['params']);
|
||||||
// TODO
|
|
||||||
|
Log::info("JOB RemotePrefetch END, action '{$this->action}'");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
Log::info("JOB RemotePrefetch Unknown action '{$this->action}'");
|
Log::info("JOB RemotePrefetch Unknown action '{$this->action}'");
|
||||||
|
|||||||
@@ -55,16 +55,25 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany
|
||||||
|
*/
|
||||||
public function favourites(): BelongsToMany
|
public function favourites(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Comic::class, 'user_favourite')->withTimestamps();
|
return $this->belongsToMany(Comic::class, 'user_favourite')->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany
|
||||||
|
*/
|
||||||
public function readingHistories(): BelongsToMany
|
public function readingHistories(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Chapter::class, 'reading_histories')->withTimestamps();
|
return $this->belongsToMany(Chapter::class, 'reading_histories')->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
public function cleanUpReadingHistories(): array
|
public function cleanUpReadingHistories(): array
|
||||||
{
|
{
|
||||||
// Get the user's ID
|
// Get the user's ID
|
||||||
|
|||||||
36
app/Providers/HorizonServiceProvider.php
Normal file
36
app/Providers/HorizonServiceProvider.php
Normal 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'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Remote;
|
namespace App\Remote;
|
||||||
|
|
||||||
use DOMDocument;
|
use Dom\HTMLDocument;
|
||||||
use Exception;
|
use Exception;
|
||||||
use GuzzleHttp\Client;
|
use GuzzleHttp\Client;
|
||||||
use GuzzleHttp\Exception\GuzzleException;
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
@@ -16,7 +16,7 @@ class CopyManga
|
|||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array Caching options
|
* @var array{caching: bool, cachingTimeout: int} Caching options
|
||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
protected array $options = [
|
protected array $options = [
|
||||||
@@ -32,12 +32,14 @@ class CopyManga
|
|||||||
/**
|
/**
|
||||||
* @var string
|
* @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
|
* @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
|
* @var bool Use old method to fetch images list
|
||||||
@@ -123,11 +125,15 @@ class CopyManga
|
|||||||
* @param string $method
|
* @param string $method
|
||||||
* @param string $userAgent
|
* @param string $userAgent
|
||||||
* @param int $ttl
|
* @param int $ttl
|
||||||
|
* @param bool $force
|
||||||
* @return mixed|string
|
* @return mixed|string
|
||||||
* @throws GuzzleException
|
* @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 ($force) {
|
||||||
|
$this->forget($url);
|
||||||
|
} else {
|
||||||
if ($this->options['caching']) {
|
if ($this->options['caching']) {
|
||||||
// Check cache exist
|
// Check cache exist
|
||||||
if (Cache::has("URL_{$url}")) {
|
if (Cache::has("URL_{$url}")) {
|
||||||
@@ -139,6 +145,7 @@ class CopyManga
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$client = new Client(['headers' => ['User-Agent' => ($userAgent === "") ? $this->userAgent : $userAgent]]);
|
$client = new Client(['headers' => ['User-Agent' => ($userAgent === "") ? $this->userAgent : $userAgent]]);
|
||||||
|
|
||||||
@@ -146,7 +153,7 @@ class CopyManga
|
|||||||
if ($method === 'OPTIONS') {
|
if ($method === 'OPTIONS') {
|
||||||
$options = ['headers' => ['Access-Control-Request-Method' => 'GET']];
|
$options = ['headers' => ['Access-Control-Request-Method' => 'GET']];
|
||||||
} else {
|
} 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);
|
$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
|
* Get tags available
|
||||||
*
|
*
|
||||||
@@ -211,10 +229,20 @@ class CopyManga
|
|||||||
$parameters['limit'] = $limit;
|
$parameters['limit'] = $limit;
|
||||||
$parameters['offset'] = $offset;
|
$parameters['offset'] = $offset;
|
||||||
$parameters['top'] = $top;
|
$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);
|
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
|
* Search comic by name
|
||||||
*
|
*
|
||||||
@@ -256,16 +284,17 @@ class CopyManga
|
|||||||
* @param int $offset
|
* @param int $offset
|
||||||
* @param array $parameters
|
* @param array $parameters
|
||||||
* @param string $group
|
* @param string $group
|
||||||
|
* @param bool $force
|
||||||
* @return mixed|string
|
* @return mixed|string
|
||||||
* @throws GuzzleException
|
* @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['limit'] = $limit;
|
||||||
$parameters['offset'] = $offset;
|
$parameters['offset'] = $offset;
|
||||||
$options = $this->execute($this->buildUrl("comic/{$comic}/group/{$group}/chapters", $parameters, false), 'OPTIONS');
|
$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
|
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";
|
$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
|
// Get Content Key
|
||||||
$dom = new DOMDocument();
|
$dom = HTMLDocument::createFromString($responses, LIBXML_NOERROR);
|
||||||
$dom->loadHTML($responses);
|
|
||||||
$dataNode = $dom->getElementsByTagName("div");
|
$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 = "";
|
$encryptedData = "";
|
||||||
|
|
||||||
foreach ($dataNode as $node) {
|
foreach ($dataNodes as $node) {
|
||||||
if ($node->getAttribute("class") === 'imageData') {
|
if ($node->getAttribute("class") === 'imageData') {
|
||||||
$encryptedData = $node->attributes->item(1)->value;
|
$encryptedData = $node->attributes->item(1)->value;
|
||||||
break;
|
break;
|
||||||
@@ -346,7 +385,7 @@ class CopyManga
|
|||||||
*/
|
*/
|
||||||
public function chapter(string $comic, string $chapter, array $parameters = []): array
|
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) {
|
if ($this->legacyImagesFetch) {
|
||||||
$responses['sorted'] = $this->legacyChapter($comic, $chapter);
|
$responses['sorted'] = $this->legacyChapter($comic, $chapter);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class ImageFetcher
|
|||||||
'cache' => [
|
'cache' => [
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
'prefix' => 'image_fetcher_',
|
'prefix' => 'image_fetcher_',
|
||||||
'ttl' => 3600,
|
'ttl' => 60 * 60 * 24 * 30,
|
||||||
],
|
],
|
||||||
'http' => [
|
'http' => [
|
||||||
'timeout' => 60,
|
'timeout' => 60,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\HandleInertiaRequests;
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
|
use GuzzleHttp\Exception\ServerException;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||||
|
use Illuminate\Http\Middleware\TrustProxies;
|
||||||
use Sentry\Laravel\Integration;
|
use Sentry\Laravel\Integration;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
@@ -18,10 +20,15 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
|
TrustProxies::class,
|
||||||
]);
|
]);
|
||||||
$middleware->statefulApi();
|
$middleware->statefulApi();
|
||||||
//
|
$middleware->trustProxies(at: '*');
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
$exceptions->render(function (ServerException $e, Request $request) {
|
||||||
|
return response()->view('errors', status: 500);
|
||||||
|
});
|
||||||
|
|
||||||
Integration::handles($exceptions);
|
Integration::handles($exceptions);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\HorizonServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,17 +6,19 @@
|
|||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.4",
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
"ext-openssl": "*",
|
"ext-openssl": "*",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/framework": "^11.31",
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/horizon": "^5.30",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/tinker": "^2.9",
|
"laravel/tinker": "^2.9",
|
||||||
"plesk/ext-laravel-integration": "^7.0",
|
"plesk/ext-laravel-integration": "^7.0",
|
||||||
"predis/predis": "^2.0",
|
"predis/predis": "^2.0",
|
||||||
"sentry/sentry-laravel": "^4.10",
|
"sentry/sentry-laravel": "^4.10",
|
||||||
"tightenco/ziggy": "^2.0"
|
"tightenco/ziggy": "^2.0",
|
||||||
|
"ext-libxml": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
@@ -59,7 +61,7 @@
|
|||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"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": {
|
"extra": {
|
||||||
|
|||||||
1417
composer.lock
generated
1417
composer.lock
generated
File diff suppressed because it is too large
Load Diff
213
config/horizon.php
Normal file
213
config/horizon.php
Normal 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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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
178
nixpacks.toml
Normal 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=2 # 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
4651
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
54
package.json
54
package.json
@@ -6,46 +6,48 @@
|
|||||||
"dev": "vite"
|
"dev": "vite"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.4",
|
||||||
"@inertiajs/react": "^2.0.0",
|
"@inertiajs/react": "^2.0.12",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.10.0",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"laravel-vite-plugin": "^1.1.1",
|
"laravel-vite-plugin": "^1.3.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.5.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.1.3",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.2",
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.14",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@sentry/react": "^8.48.0",
|
"@sentry/react": "^8.55.0",
|
||||||
"@sentry/vite-plugin": "^2.23.0",
|
"@sentry/vite-plugin": "^2.23.0",
|
||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.6.1",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.58.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.24.1"
|
"use-double-tap": "^1.3.7",
|
||||||
|
"use-long-press": "^3.3.0",
|
||||||
|
"zod": "^3.25.67"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
public/vendor/queue-monitor/app.css
vendored
Normal file
1
public/vendor/queue-monitor/app.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,43 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { Moon, Sun } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Separator } from '@radix-ui/react-separator';
|
import { Separator } from '@radix-ui/react-separator';
|
||||||
import { Tooltip, TooltipProvider, TooltipTrigger } from '@radix-ui/react-tooltip';
|
|
||||||
|
|
||||||
import { AppSidebar } from '@/components/ui/app-sidebar';
|
import { AppSidebar } from '@/components/ui/app-sidebar';
|
||||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb';
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import { TooltipContent } from '@/components/ui/tooltip';
|
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
|
||||||
export default function AppLayout({ auth, header, children, toolbar }) {
|
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 (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar auth={ auth } />
|
<AppSidebar auth={ auth } />
|
||||||
@@ -56,20 +26,6 @@ export default function AppLayout({ auth, header, children, toolbar }) {
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<span className="flex gap-1 ml-auto justify-center content-center">
|
<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 }
|
{ toolbar }
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Head, Link, router } from '@inertiajs/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';
|
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) => {
|
const groupOnClickHandler = (pathword) => {
|
||||||
router.get(`/comic/${ comic.comic.path_word }?group=${ pathword }`, {}, {
|
router.get(`/comic/${ comic.comic.path_word }?group=${ pathword }`, {}, {
|
||||||
only: ['chapters'],
|
only: ['chapters'],
|
||||||
@@ -59,7 +66,7 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ComicChapterLink = (props) => {
|
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);
|
const isRead = histories.includes(props.uuid);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,7 +104,8 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
|
|||||||
<Head>
|
<Head>
|
||||||
<title>{ comic.comic.name }</title>
|
<title>{ comic.comic.name }</title>
|
||||||
</Head>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex flex-row content-end items-center">
|
<CardTitle className="flex flex-row content-end items-center">
|
||||||
@@ -188,6 +196,19 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</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>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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 => (
|
{ chapters.list.sort((a, b) => ascending ? (a.index - b.index) : (b.index - a.index)).map(c => (
|
||||||
<ComicChapterLink key={ c.uuid } { ...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>
|
<Button size="sm" variant="outline" asChild>
|
||||||
<Link href="?" only={['chapters', 'offset']} headers={{ offset: parseInt(chapters.offset) + chapters.limit }}>
|
<Link href="?" only={['chapters', 'offset']} headers={{ offset: parseInt(chapters.offset) + chapters.limit }}>
|
||||||
Next <ChevronsRight />
|
Next <ChevronsRight />
|
||||||
|
|||||||
@@ -79,8 +79,14 @@ export default function Histories({ auth, histories }) {
|
|||||||
<Button size="sm" variant="destructive" onClick={ () => removeDuplicatedButtonOnClickHandler() }>
|
<Button size="sm" variant="destructive" onClick={ () => removeDuplicatedButtonOnClickHandler() }>
|
||||||
Remove duplicates
|
Remove duplicates
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link href="?group=comic">
|
||||||
|
Group by Comics
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Table>
|
<Table className="mt-2">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Select</TableHead>
|
<TableHead>Select</TableHead>
|
||||||
@@ -114,22 +120,16 @@ export default function Histories({ auth, histories }) {
|
|||||||
)) }
|
)) }
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
<div>
|
<div className="pt-2">
|
||||||
<Pagination className="justify-end">
|
<Pagination className="justify-end">
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
{ histories.current_page > 1 && (
|
{ histories.links.map((h, i) => (
|
||||||
<PaginationItem>
|
<PaginationItem key={ i }>
|
||||||
<PaginationPrevious href={ histories.prev_page_url } only={['histories']} />
|
{ 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>
|
||||||
) }
|
) ) }
|
||||||
<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>
|
</PaginationContent>
|
||||||
</Pagination>
|
</Pagination>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
92
resources/js/Pages/Comic/HistoriesByComic.jsx
Normal file
92
resources/js/Pages/Comic/HistoriesByComic.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link } from '@inertiajs/react';
|
||||||
import { Star } from 'lucide-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 { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from '@/components/ui/pagination';
|
||||||
import { useToast } from '@/hooks/use-toast.js';
|
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 url = new URL(window.location); // searchParams
|
||||||
|
const [favourites, setFavourites] = useState((auth?.user?.favourites !== null) ? auth.user.favourites : []);
|
||||||
const [favourites, setFavourites] = useState(auth.user?.favourites ?? []);
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const itemsPerPage = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On click handler for the star
|
||||||
|
* Do posting and make a toast
|
||||||
|
*
|
||||||
|
* @param pathword
|
||||||
|
*/
|
||||||
const favouriteOnClickHandler = (pathword) => {
|
const favouriteOnClickHandler = (pathword) => {
|
||||||
axios.post(route('comics.postFavourite'), { pathword: pathword }).then(res => {
|
axios.post(route('comics.postFavourite'), { pathword: pathword }).then(res => {
|
||||||
setFavourites(res.data);
|
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) => (
|
const ComicCard = (props) => (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -41,7 +54,9 @@ export default function Index({ comics, offset, auth }) {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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">
|
<CardDescription className="pt-2">
|
||||||
{ props.author && props.author.map(a => (
|
{ props.author && props.author.map(a => (
|
||||||
<Badge className="m-1" key={ a.path_word } variant="outline">
|
<Badge className="m-1" key={ a.path_word } variant="outline">
|
||||||
@@ -53,6 +68,12 @@ export default function Index({ comics, offset, auth }) {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loop and return all info cards
|
||||||
|
* @param comics
|
||||||
|
* @returns {*}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
const ComicCards = (comics) => {
|
const ComicCards = (comics) => {
|
||||||
return comics.list.map((comic, i) => <ComicCard key={ i } { ...comic } />);
|
return comics.list.map((comic, i) => <ComicCard key={ i } { ...comic } />);
|
||||||
}
|
}
|
||||||
@@ -62,21 +83,26 @@ export default function Index({ comics, offset, auth }) {
|
|||||||
<Head>
|
<Head>
|
||||||
<title>Home</title>
|
<title>Home</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="p-3 pt-1 grid 2xl:grid-cols-6 xl:grid-cols-4 sm:grid-cols-2 gap-2">
|
<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 } />
|
<ComicCards { ...comics } />
|
||||||
</div>
|
</div>
|
||||||
<Pagination className="justify-end pb-2">
|
<Pagination className="justify-end pt-2">
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
{ parseInt(offset) !== 0 &&
|
{ parseInt(offset) !== 0 &&
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationPrevious href={ `${ url.pathname }?${ url.searchParams }` } only={['comics', 'offset']} headers={{ offset: parseInt(offset) - 30 }} />
|
<PaginationPrevious href={ `${ url.pathname }?${ url.searchParams }` } only={['comics', 'offset']} headers={{ offset: parseInt(offset) - itemsPerPage }} />
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
}
|
}
|
||||||
|
{ parseInt(comics.total) > parseInt(offset) + itemsPerPage &&
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationNext href={ `${ url.pathname }?${ url.searchParams }` } only={['comics', 'offset']} headers={{ offset: parseInt(offset) + 30 }} />
|
<PaginationNext href={ `${ url.pathname }?${ url.searchParams }` } only={['comics', 'offset']} headers={{ offset: parseInt(offset) + itemsPerPage }} />
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
}
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
</Pagination>
|
</Pagination>
|
||||||
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Head, Link, router } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
import AppLayout from '@/Layouts/AppLayout.jsx';
|
import AppLayout from '@/Layouts/AppLayout.jsx';
|
||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
||||||
import { ChevronFirst, ChevronLast, Rows3 } from 'lucide-react';
|
import { ChevronFirst, ChevronLast, Rows3 } from 'lucide-react';
|
||||||
import { Tooltip, TooltipProvider, TooltipTrigger } from '@radix-ui/react-tooltip';
|
import { Tooltip, TooltipProvider, TooltipTrigger } from '@radix-ui/react-tooltip';
|
||||||
|
import { useLongPress } from "use-long-press";
|
||||||
|
|
||||||
import { throttle } from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
@@ -19,34 +20,18 @@ export default function Read({ auth, comic, chapter, chapters }) {
|
|||||||
const validReadingModes = ['rtl', 'utd'];
|
const validReadingModes = ['rtl', 'utd'];
|
||||||
|
|
||||||
const [readingMode, setReadingMode] = useState('rtl'); // rtl, utd
|
const [readingMode, setReadingMode] = useState('rtl'); // rtl, utd
|
||||||
|
const [isInvertClickingZone, setIsInvertClickingZone] = useState(false);
|
||||||
const [isTwoPagesPerScreen, setIsTwoPagePerScreen] = useState(false); // TODO
|
const [isTwoPagesPerScreen, setIsTwoPagePerScreen] = useState(false); // TODO
|
||||||
|
|
||||||
const [currentImage, setCurrentImage] = useState(1);
|
const [currentImage, setCurrentImage] = useState(1);
|
||||||
const [divDimensions, setDivDimensions] = useState([0, 0]);
|
const [divDimensions, setDivDimensions] = useState([0, 0]);
|
||||||
|
|
||||||
const ref = useRef();
|
const ref = useRef();
|
||||||
const windowSize = useWindowSize();
|
|
||||||
|
|
||||||
const getLocalStorageReadingMode = () => {
|
const useWindowSize = () => {
|
||||||
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 [size, setSize] = useState([0, 0]);
|
const [size, setSize] = useState([0, 0]);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
function updateSize() {
|
const updateSize = () => {
|
||||||
setSize([window.innerWidth, window.innerHeight]);
|
setSize([window.innerWidth, window.innerHeight]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +43,32 @@ export default function Read({ auth, comic, chapter, chapters }) {
|
|||||||
return size;
|
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) => {
|
const toggleReadingMode = (e) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
window.localStorage.setItem('readingMode', 'utd');
|
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) => {
|
const toggleTwoPagesPerScreen = (e) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
window.localStorage.setItem('twoPagesPerScreen', true);
|
window.localStorage.setItem('twoPagesPerScreen', true);
|
||||||
@@ -95,6 +116,13 @@ export default function Read({ auth, comic, chapter, chapters }) {
|
|||||||
//console.log(e.target.naturalHeight);
|
//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 ImageForComic = (img) => {
|
||||||
const imgRef = useRef();
|
const imgRef = useRef();
|
||||||
|
|
||||||
@@ -114,25 +142,33 @@ export default function Read({ auth, comic, chapter, chapters }) {
|
|||||||
const bounds = imgRef.current.getBoundingClientRect();
|
const bounds = imgRef.current.getBoundingClientRect();
|
||||||
const percentage = (e.pageX - bounds.left) / imgRef.current.offsetWidth;
|
const percentage = (e.pageX - bounds.left) / imgRef.current.offsetWidth;
|
||||||
|
|
||||||
if (percentage < 0.45) {
|
const prevHandler = () => {
|
||||||
if (img.innerKey === 0 && chapter.chapter.prev) {
|
if (img.innerKey === 0 && chapter.chapter.prev) {
|
||||||
router.visit(route('comics.read', [comic.comic.path_word, chapter.chapter.prev]));
|
router.visit(route('comics.read', [comic.comic.path_word, chapter.chapter.prev]));
|
||||||
} else {
|
} 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) {
|
if (img.innerKey >= chapter.sorted.length - 1 && chapter.chapter.next) {
|
||||||
router.visit(route('comics.read', [comic.comic.path_word, chapter.chapter.next]));
|
router.visit(route('comics.read', [comic.comic.path_word, chapter.chapter.next]));
|
||||||
} else {
|
} 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 (
|
return (
|
||||||
<div className="basis-full">
|
<div className="basis-full">
|
||||||
<img alt={ comic.comic.name } className={` m-auto comic-img `} id={ `image-${ img.innerKey }` } ref={ imgRef }
|
<img alt={ comic.comic.name } className={` m-auto comic-img `} id={ `image-${ img.innerKey }` }
|
||||||
onClick={ handleImageClick } src={ `/image/${ btoa(img.url) }` } style={ imgStyles } />
|
onClick={ handleImageClick } ref={ imgRef }
|
||||||
|
src={ `/image/${ btoa(img.url) }` } style={ imgStyles } />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,7 +202,17 @@ export default function Read({ auth, comic, chapter, chapters }) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
<div className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<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>
|
<p>Only applicable to RTL mode</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch defaultChecked={ (isTwoPagesPerScreen) }
|
<Switch defaultChecked={ (isTwoPagesPerScreen) }
|
||||||
@@ -277,6 +323,7 @@ export default function Read({ auth, comic, chapter, chapters }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReadingMode(getLocalStorageReadingMode());
|
setReadingMode(getLocalStorageReadingMode());
|
||||||
|
setIsInvertClickingZone(getLocalStorageIsInvertClickingZone());
|
||||||
setIsTwoPagePerScreen(getLocalStorageIsTwoPagePerScreen());
|
setIsTwoPagePerScreen(getLocalStorageIsTwoPagePerScreen());
|
||||||
|
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
@@ -332,7 +379,7 @@ export default function Read({ auth, comic, chapter, chapters }) {
|
|||||||
<Head>
|
<Head>
|
||||||
<title>{ chapter.chapter.name.concat(" - ", comic.comic.name) }</title>
|
<title>{ chapter.chapter.name.concat(" - ", comic.comic.name) }</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="p-3 pt-1 pb-1 flex flex-wrap justify-center" id="mvp" ref={ ref }
|
<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" }}>
|
style={{ overflowAnchor: "none", height: "calc(100dvh - 90px)", overflowY: "scroll" }}>
|
||||||
{ chapter.sorted.map((img, j) => <ImageForComic key={ j } innerKey={ j } { ...img } />) }
|
{ chapter.sorted.map((img, j) => <ImageForComic key={ j } innerKey={ j } { ...img } />) }
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>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>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>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>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>
|
<li>It should be running?</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -11,6 +11,68 @@ export default function Updates({ auth }) {
|
|||||||
<title>Updates</title>
|
<title>Updates</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="p-3 pt-1">
|
<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">
|
<Card className="w-[90%] m-3 mx-auto">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>0.1.2</CardTitle>
|
<CardTitle>0.1.2</CardTitle>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Sentry.init({
|
|||||||
// Tracing
|
// Tracing
|
||||||
tracesSampleRate: 0.1, // Capture 10% of the transactions
|
tracesSampleRate: 0.1, // Capture 10% of the transactions
|
||||||
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
|
// 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
|
// 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.
|
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.
|
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, router, usePage } from '@inertiajs/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 { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
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';
|
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
||||||
|
|
||||||
export function AppSidebar({ auth }) {
|
import { useToast } from '@/hooks/use-toast.js';
|
||||||
const { tags } = usePage().props;
|
|
||||||
|
|
||||||
|
export function AppSidebar({ auth }) {
|
||||||
|
|
||||||
|
const { tags } = usePage().props;
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [historyDisabled, setHistoryDisabled] = useState(false);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const searchOnSubmitHandler = (e) => {
|
const searchOnSubmitHandler = (e) => {
|
||||||
e.preventDefault();
|
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) => {
|
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 (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
@@ -57,7 +104,7 @@ export function AppSidebar({ auth }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span className="font-semibold">Comic</span>
|
<span className="font-semibold">Comic</span>
|
||||||
<span>0.1.2</span>
|
<span>0.1.6A</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -147,8 +194,7 @@ export function AppSidebar({ auth }) {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton size="lg"
|
||||||
size="lg"
|
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
@@ -186,6 +232,13 @@ export function AppSidebar({ auth }) {
|
|||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<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('profile.edit') }><BadgeCheck /> Profile</Link></DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild><Link href={ route('comics.favourites') }><Star /> Favourites</Link></DropdownMenuItem>
|
<DropdownMenuItem asChild><Link href={ route('comics.favourites') }><Star /> Favourites</Link></DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild><Link href={ route('comics.histories') }><History /> History</Link></DropdownMenuItem>
|
<DropdownMenuItem asChild><Link href={ route('comics.histories') }><History /> History</Link></DropdownMenuItem>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0, minimal-ui">
|
<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>
|
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||||
@routes
|
@routes
|
||||||
@viteReactRefresh
|
@viteReactRefresh
|
||||||
|
|||||||
0
resources/views/errors.blade.php
Normal file
0
resources/views/errors.blade.php
Normal file
@@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Route;
|
|||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
// Auth protected routes
|
// 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('/', 'index')->name('index');
|
||||||
Route::get('/author/{author}', 'author')->name('author');
|
Route::get('/author/{author}', 'author')->name('author');
|
||||||
Route::get('/search/{search}', 'search')->name('search');
|
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::patch('/histories', 'patchHistories')->name('patchHistories');
|
||||||
Route::delete('/history/{pathword}', 'destroyHistory')->name('destroyHistory');
|
Route::delete('/history/{pathword}', 'destroyHistory')->name('destroyHistory');
|
||||||
Route::delete('/histories', 'destroyHistories')->name('destroyHistories');
|
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::controller(PagesController::class)->middleware('auth')->name('pages.')->group(function () {
|
||||||
Route::get('/pages/{path?}', 'show')->name('show');
|
Route::get('/pages/{path?}', 'show')->name('show');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
Route::get('/dashboard', function () {
|
Route::get('/dashboard', function () {
|
||||||
return Inertia::render('Dashboard');
|
return Inertia::render('Dashboard');
|
||||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||||
|
|||||||
Reference in New Issue
Block a user