0.1.0
This commit is contained in:
77
app/Console/Commands/CleanupReadingHistories.php
Normal file
77
app/Console/Commands/CleanupReadingHistories.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class CleanupReadingHistories extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'cleanup:histories
|
||||||
|
{userIds?* : The user ID(s) to clean up (use "all" to clean up for all users)}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Clean up duplicate reading histories for specified or all users';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$userIds = $this->argument('userIds');
|
||||||
|
|
||||||
|
if (empty($userIds)) {
|
||||||
|
$this->error('You must specify user IDs or "all".');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "all" case
|
||||||
|
if ($userIds === ['all']) {
|
||||||
|
$users = User::all();
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$this->cleanUpForUser($user);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure user IDs are unique
|
||||||
|
$uniqueUserIds = array_unique($userIds);
|
||||||
|
|
||||||
|
foreach ($uniqueUserIds as $userId) {
|
||||||
|
$user = User::find($userId);
|
||||||
|
if ($user) {
|
||||||
|
$this->cleanUpForUser($user);
|
||||||
|
} else {
|
||||||
|
$this->error("User with ID {$userId} not found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Cleanup completed successfully.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up duplicate reading histories for a single user.
|
||||||
|
*
|
||||||
|
* @param \App\Models\User $user
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function cleanUpForUser(User $user): void
|
||||||
|
{
|
||||||
|
$result = $user->cleanUpReadingHistories();
|
||||||
|
|
||||||
|
$keptIds = $result['kept_ids']->toArray(); // Convert Collection to array
|
||||||
|
|
||||||
|
$this->info("User ID {$result['user_id']} - Kept IDs: " . implode(', ', $keptIds) . " - Deleted: {$result['deleted_count']} records.");
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Helper/Timezones.php
Normal file
41
app/Helper/Timezones.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Helper;
|
||||||
|
|
||||||
|
use DateTimeZone;
|
||||||
|
|
||||||
|
class Timezones
|
||||||
|
{
|
||||||
|
|
||||||
|
public function groups(): array
|
||||||
|
{
|
||||||
|
return ['Africa', 'America', 'Antarctica', 'Arctic', 'Asia', 'Australia', 'Europe', 'Indian', 'Pacific', 'UTC'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function locations(string $group): array
|
||||||
|
{
|
||||||
|
return DateTimeZone::listIdentifiers(DateTimeZone::{strtoupper($group)});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
$tzForSelect = [];
|
||||||
|
|
||||||
|
foreach (DateTimeZone::listIdentifiers() as $timezone) {
|
||||||
|
$tz = explode('/', $timezone);
|
||||||
|
if (!isset($tz[1])) {
|
||||||
|
$tzForSelect['UTC']['UTC'] = 'UTC';
|
||||||
|
} else {
|
||||||
|
if (isset($tz[2])) {
|
||||||
|
$tzForSelect[$tz[0]][$tz[1]] = $tz[1]."/".$tz[2];
|
||||||
|
} else {
|
||||||
|
$tzForSelect[$tz[0]][$timezone] = $tz[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tzForSelect;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -361,7 +361,7 @@ class ComicController extends Controller
|
|||||||
{
|
{
|
||||||
// Get history
|
// Get history
|
||||||
$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', 'reading_histories.created_at as read_at', 'chapters.comic_id', 'chapters.name'])->paginate(50)->toArray();
|
->select(['reading_histories.id as hid', 'chapters.comic_id', 'chapters.name'])->paginate(50)->toArray();
|
||||||
|
|
||||||
return Inertia::render('Comic/Histories', [
|
return Inertia::render('Comic/Histories', [
|
||||||
'histories' => $this->scToZh($histories)
|
'histories' => $this->scToZh($histories)
|
||||||
@@ -374,7 +374,7 @@ class ComicController extends Controller
|
|||||||
* @param Request $request
|
* @param Request $request
|
||||||
* @return RedirectResponse
|
* @return RedirectResponse
|
||||||
*/
|
*/
|
||||||
public function destroyHistories(Request $request): RedirectResponse
|
public function patchHistories(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
if (!is_array($request->get('ids')) && $request->get('ids') === 'all') {
|
if (!is_array($request->get('ids')) && $request->get('ids') === 'all') {
|
||||||
$histories = $request->user()->readingHistories()->delete();
|
$histories = $request->user()->readingHistories()->delete();
|
||||||
@@ -385,6 +385,27 @@ class ComicController extends Controller
|
|||||||
return redirect()->route('comics.histories');
|
return redirect()->route('comics.histories');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroyHistory(Request $request, string $pathword): Response
|
||||||
|
{
|
||||||
|
$comicId = Comic::where('pathword', $pathword)->firstOrFail(['id'])->id;
|
||||||
|
$request->user()->readingHistories()->where('reading_histories.comic_id', $comicId)->delete();
|
||||||
|
|
||||||
|
// Get history
|
||||||
|
$histories = $request->user()->readingHistories()->where('reading_histories.comic_id', $comicId)
|
||||||
|
->distinct()->select('chapter_uuid')->get()->pluck('chapter_uuid');
|
||||||
|
|
||||||
|
return Inertia::render('Comic/Chapters', [
|
||||||
|
'histories' => $histories
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyHistories(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$result = $request->user()->cleanUpReadingHistories();
|
||||||
|
|
||||||
|
return redirect()->route('comics.histories');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch tags
|
* Fetch tags
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Helper\Timezones;
|
||||||
use App\Http\Requests\ProfileUpdateRequest;
|
use App\Http\Requests\ProfileUpdateRequest;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -18,8 +19,11 @@ class ProfileController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function edit(Request $request): Response
|
public function edit(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$tz = new Timezones();
|
||||||
|
|
||||||
return Inertia::render('Profile/Edit', [
|
return Inertia::render('Profile/Edit', [
|
||||||
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||||
|
'timezones' => $tz->toArray(),
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -35,6 +39,12 @@ class ProfileController extends Controller
|
|||||||
$request->user()->email_verified_at = null;
|
$request->user()->email_verified_at = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
$settings = $request->user()->settings;
|
||||||
|
$settings['timezone'] = $request->get('timezone');
|
||||||
|
$request->user()->settings = $settings;
|
||||||
|
$request->user()->save();
|
||||||
|
|
||||||
$request->user()->save();
|
$request->user()->save();
|
||||||
|
|
||||||
return Redirect::route('profile.edit');
|
return Redirect::route('profile.edit');
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class UserCollection extends ResourceCollection
|
|||||||
'id' => $request->user()->id,
|
'id' => $request->user()->id,
|
||||||
'name' => $request->user()->name,
|
'name' => $request->user()->name,
|
||||||
'email' => $request->user()->email,
|
'email' => $request->user()->email,
|
||||||
|
'settings' => $request->user()->settings,
|
||||||
'favourites' => $request->user()->favourites()->get(['pathword'])->pluck('pathword')
|
'favourites' => $request->user()->favourites()->get(['pathword'])->pluck('pathword')
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Database\Factories\UserFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Query\JoinClause;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class User extends Authenticatable implements MustVerifyEmail
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +24,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'settings',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'remember_token',
|
'remember_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'settings' => "{timezone:'UTC'}",
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
@@ -46,6 +51,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'settings' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,4 +65,41 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return $this->belongsToMany(Chapter::class, 'reading_histories')->withTimestamps();
|
return $this->belongsToMany(Chapter::class, 'reading_histories')->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function cleanUpReadingHistories(): array
|
||||||
|
{
|
||||||
|
// Get the user's ID
|
||||||
|
$userId = $this->id;
|
||||||
|
|
||||||
|
// Step 1: Identify records to keep
|
||||||
|
$idsToKeep = ReadingHistory::query()
|
||||||
|
->select('reading_histories.id') // Specify table name explicitly
|
||||||
|
->joinSub(
|
||||||
|
ReadingHistory::query()
|
||||||
|
->select('comic_id', 'user_id', 'chapter_id', DB::raw('MIN(created_at) as earliest_created_at'))
|
||||||
|
->where('user_id', $userId) // Specify user_id with table name
|
||||||
|
->groupBy('comic_id', 'user_id', 'chapter_id'),
|
||||||
|
'b',
|
||||||
|
function (JoinClause $join) {
|
||||||
|
$join->on('reading_histories.comic_id', '=', 'b.comic_id')
|
||||||
|
->on('reading_histories.user_id', '=', 'b.user_id') // Disambiguate user_id
|
||||||
|
->on('reading_histories.chapter_id', '=', 'b.chapter_id')
|
||||||
|
->on('reading_histories.created_at', '=', 'b.earliest_created_at');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->where('reading_histories.user_id', $userId) // Specify table name explicitly
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
// Step 2: Delete duplicates for the user
|
||||||
|
$deletedCount = ReadingHistory::where('user_id', $userId)
|
||||||
|
->whereNotIn('id', $idsToKeep)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
// Return the result as an array
|
||||||
|
return [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'kept_ids' => $idsToKeep,
|
||||||
|
'deleted_count' => $deletedCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.3",
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
"ext-openssl": "*",
|
"ext-openssl": "*",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
|
|||||||
27
composer.lock
generated
27
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "cf7d41fc8c70877b1ded788c249463de",
|
"content-hash": "b4f2ee211728714c2fa05fbad3914d2e",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -1189,16 +1189,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v11.36.1",
|
"version": "v11.37.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/framework.git",
|
"url": "https://github.com/laravel/framework.git",
|
||||||
"reference": "df06f5163f4550641fdf349ebc04916a61135a64"
|
"reference": "6cb103d2024b087eae207654b3f4b26646119ba5"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/framework/zipball/df06f5163f4550641fdf349ebc04916a61135a64",
|
"url": "https://api.github.com/repos/laravel/framework/zipball/6cb103d2024b087eae207654b3f4b26646119ba5",
|
||||||
"reference": "df06f5163f4550641fdf349ebc04916a61135a64",
|
"reference": "6cb103d2024b087eae207654b3f4b26646119ba5",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1248,7 +1248,6 @@
|
|||||||
"voku/portable-ascii": "^2.0.2"
|
"voku/portable-ascii": "^2.0.2"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"mockery/mockery": "1.6.8",
|
|
||||||
"tightenco/collect": "<5.5.33"
|
"tightenco/collect": "<5.5.33"
|
||||||
},
|
},
|
||||||
"provide": {
|
"provide": {
|
||||||
@@ -1400,7 +1399,7 @@
|
|||||||
"issues": "https://github.com/laravel/framework/issues",
|
"issues": "https://github.com/laravel/framework/issues",
|
||||||
"source": "https://github.com/laravel/framework"
|
"source": "https://github.com/laravel/framework"
|
||||||
},
|
},
|
||||||
"time": "2024-12-17T22:32:08+00:00"
|
"time": "2025-01-02T20:10:21+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/prompts",
|
"name": "laravel/prompts",
|
||||||
@@ -6193,16 +6192,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tightenco/ziggy",
|
"name": "tightenco/ziggy",
|
||||||
"version": "v2.4.1",
|
"version": "v2.4.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/tighten/ziggy.git",
|
"url": "https://github.com/tighten/ziggy.git",
|
||||||
"reference": "8e002298678fd4d61155bb1d6e3837048235bff7"
|
"reference": "6612c8c9b2d5b3e74fd67c58c11465df1273f384"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/tighten/ziggy/zipball/8e002298678fd4d61155bb1d6e3837048235bff7",
|
"url": "https://api.github.com/repos/tighten/ziggy/zipball/6612c8c9b2d5b3e74fd67c58c11465df1273f384",
|
||||||
"reference": "8e002298678fd4d61155bb1d6e3837048235bff7",
|
"reference": "6612c8c9b2d5b3e74fd67c58c11465df1273f384",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -6257,9 +6256,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/tighten/ziggy/issues",
|
"issues": "https://github.com/tighten/ziggy/issues",
|
||||||
"source": "https://github.com/tighten/ziggy/tree/v2.4.1"
|
"source": "https://github.com/tighten/ziggy/tree/v2.4.2"
|
||||||
},
|
},
|
||||||
"time": "2024-11-21T15:51:20+00:00"
|
"time": "2025-01-02T20:06:52+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
@@ -9751,7 +9750,7 @@
|
|||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2",
|
"php": "^8.3",
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
"ext-openssl": "*"
|
"ext-openssl": "*"
|
||||||
},
|
},
|
||||||
|
|||||||
21
database/migrations/2025_01_04_161757_update_users_table.php
Normal file
21
database/migrations/2025_01_04_161757_update_users_table.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->json('settings')->after('remember_token')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('settings');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"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.6"
|
"vite": "^6.0.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"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",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "http://127.0.0.1:8000",
|
"start_url": "https://c.yumj.in/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"background_color": "#ffffff"
|
"background_color": "#ffffff"
|
||||||
|
|||||||
@@ -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 } from 'lucide-react';
|
import { Plus, Star, ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronsLeft, ChevronsRight, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import AppLayout from '@/Layouts/AppLayout.jsx';
|
import AppLayout from '@/Layouts/AppLayout.jsx';
|
||||||
|
|
||||||
@@ -23,12 +23,28 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
|
|||||||
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);
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "All set",
|
title: "All set",
|
||||||
description: `${comic.comic.name} is now in / remove your favorite list.`,
|
description: `${comic.comic.name} is now in / remove your favorite list.`,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAllHistoriesOnClickHandler = (pathword) => {
|
||||||
|
router.visit(route('comics.destroyHistory', { pathword: pathword }), {
|
||||||
|
method: 'DELETE',
|
||||||
|
replace: false,
|
||||||
|
preserveScroll: true,
|
||||||
|
preserveState: true,
|
||||||
|
showProgress: false,
|
||||||
|
only: ['histories'],
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "All set",
|
||||||
|
description: `All histories have been removed.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupOnClickHandler = (pathword) => {
|
const groupOnClickHandler = (pathword) => {
|
||||||
@@ -101,6 +117,12 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
|
|||||||
<td className="text-right w-24 pr-3">Alias</td>
|
<td className="text-right w-24 pr-3">Alias</td>
|
||||||
<td>{ comic.comic.alias }</td>
|
<td>{ comic.comic.alias }</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="text-right pr-3">Status</td>
|
||||||
|
<td>
|
||||||
|
{ comic.comic.region.display } / { comic.comic.status.display }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="text-right pr-3">Category</td>
|
<td className="text-right pr-3">Category</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -149,10 +171,32 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)) }
|
)) }
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div>
|
<div className="flex justify-end">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button variant="link" size="icon" onClick={ () => toggleAscending() }>
|
<Button variant="link" size="icon" onClick={ () => toggleAscending() }>
|
||||||
{ ascending ? <ArrowDownNarrowWide /> : <ArrowUpNarrowWide /> }
|
{ ascending ? <ArrowDownNarrowWide /> : <ArrowUpNarrowWide /> }
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Set order as { ascending ? 'Descending' : 'Ascending' }</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="link" size="icon" onClick={ () => removeAllHistoriesOnClickHandler(comic.comic.path_word) }>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Remove all histories for this comic</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TabsContent value={ group }>
|
<TabsContent value={ group }>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { 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 { DateTime } from "luxon";
|
||||||
|
|
||||||
import { BreadcrumbItem, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
import { BreadcrumbItem, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Checkbox from "@/components/Checkbox";
|
import Checkbox from "@/components/Checkbox";
|
||||||
@@ -25,19 +27,11 @@ export default function Histories({ auth, histories }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const datetimeConversion = (iso8601) => {
|
const datetimeConversion = (iso8601) => {
|
||||||
const date = new Date(iso8601);
|
return DateTime.fromISO(iso8601).setZone(auth.user.settings.timezone).toFormat('dd-MM-yyyy HH:mm');
|
||||||
|
|
||||||
const year = date.getUTCFullYear();
|
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0'); // Months are 0-based
|
|
||||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
|
||||||
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteButtonOnClickHandler = () => {
|
const deleteButtonOnClickHandler = () => {
|
||||||
router.visit(route('comics.destroyHistories'), {
|
router.visit(route('comics.patchHistories'), {
|
||||||
data: (ids.length > 0) ? { ids: ids } : { ids: 'all' },
|
data: (ids.length > 0) ? { ids: ids } : { ids: 'all' },
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
only: ['histories'],
|
only: ['histories'],
|
||||||
@@ -50,6 +44,20 @@ export default function Histories({ auth, histories }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<AppLayout auth={ auth } header={
|
<AppLayout auth={ auth } header={
|
||||||
<>
|
<>
|
||||||
@@ -67,6 +75,10 @@ export default function Histories({ auth, histories }) {
|
|||||||
<Button size="sm" variant="destructive" onClick={ () => deleteButtonOnClickHandler() }>
|
<Button size="sm" variant="destructive" onClick={ () => deleteButtonOnClickHandler() }>
|
||||||
{ ids.length > 0 ? `Delete selected (${ids.length})` : "Delete All" }
|
{ ids.length > 0 ? `Delete selected (${ids.length})` : "Delete All" }
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button size="sm" variant="destructive" onClick={ () => removeDuplicatedButtonOnClickHandler() }>
|
||||||
|
Remove duplicates
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -74,7 +86,7 @@ export default function Histories({ auth, histories }) {
|
|||||||
<TableHead>Select</TableHead>
|
<TableHead>Select</TableHead>
|
||||||
<TableHead>Chapter</TableHead>
|
<TableHead>Chapter</TableHead>
|
||||||
<TableHead>Comic</TableHead>
|
<TableHead>Comic</TableHead>
|
||||||
<TableHead className="hidden lg:block">Read at</TableHead>
|
<TableHead className="invisible lg:visible">Read at</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -97,7 +109,7 @@ export default function Histories({ auth, histories }) {
|
|||||||
{ h.comic.name }
|
{ h.comic.name }
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden lg:block">{ datetimeConversion(h.read_at) }</TableCell>
|
<TableCell className="invisible lg:visible">{ datetimeConversion(h.pivot.created_at) }</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)) }
|
)) }
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function Index({ comics, offset, auth }) {
|
|||||||
|
|
||||||
const url = new URL(window.location); //searchParams
|
const url = new URL(window.location); //searchParams
|
||||||
|
|
||||||
const [favourites, setFavourites] = useState(auth.user.favourites);
|
const [favourites, setFavourites] = useState(auth.user?.favourites);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const favouriteOnClickHandler = (pathword) => {
|
const favouriteOnClickHandler = (pathword) => {
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ 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.0</CardTitle>
|
||||||
|
<CardDescription>Release: 04 Jan 2025</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul>
|
||||||
|
<li>iOS fixed for search auto fours</li>
|
||||||
|
<li>Beta: Timezones, only available for histories now</li>
|
||||||
|
<li>Beta: Remove histories for selected comic</li>
|
||||||
|
<li>Beta: Remove duplicated reading histories</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<Card className="w-[90%] m-3 mx-auto">
|
<Card className="w-[90%] m-3 mx-auto">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>0.0.2 FR1</CardTitle>
|
<CardTitle>0.0.2 FR1</CardTitle>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import AppLayout from "@/Layouts/AppLayout.jsx";
|
|||||||
import { BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator } from "@/components/ui/breadcrumb.jsx";
|
import { BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator } from "@/components/ui/breadcrumb.jsx";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
export default function Edit({ auth, mustVerifyEmail, status }) {
|
export default function Edit({ auth, mustVerifyEmail, status, timezones }) {
|
||||||
return (
|
return (
|
||||||
<AppLayout auth={ auth } header={
|
<AppLayout auth={ auth } header={
|
||||||
<>
|
<>
|
||||||
@@ -27,7 +27,7 @@ export default function Edit({ auth, mustVerifyEmail, status }) {
|
|||||||
<TabsContent value="profile">
|
<TabsContent value="profile">
|
||||||
<UpdateProfileInformationForm
|
<UpdateProfileInformationForm
|
||||||
mustVerifyEmail={ mustVerifyEmail }
|
mustVerifyEmail={ mustVerifyEmail }
|
||||||
status={ status }
|
status={ status } timezones={ timezones }
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="password">
|
<TabsContent value="password">
|
||||||
|
|||||||
@@ -11,24 +11,29 @@ import {
|
|||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
export default function UpdateProfileInformation({
|
export default function UpdateProfileInformation({ mustVerifyEmail, status, timezones }) {
|
||||||
mustVerifyEmail,
|
|
||||||
status,
|
|
||||||
className = '',
|
|
||||||
}) {
|
|
||||||
const user = usePage().props.auth.user;
|
const user = usePage().props.auth.user;
|
||||||
|
|
||||||
const { data, setData, patch, errors, processing, recentlySuccessful } =
|
const { data, setData, patch, errors, processing, recentlySuccessful } =
|
||||||
useForm({
|
useForm({
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
timezone: user.settings.timezone
|
||||||
});
|
});
|
||||||
|
|
||||||
const submit = (e) => {
|
const submit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
patch(route('profile.update'));
|
patch(route('profile.update'));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,6 +56,7 @@ export default function UpdateProfileInformation({
|
|||||||
</div>
|
</div>
|
||||||
<InputError className="mt-2" message={ errors.name } />
|
<InputError className="mt-2" message={ errors.name } />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid w-full items-center gap-4 pt-3">
|
<div className="grid w-full items-center gap-4 pt-3">
|
||||||
<div className="flex flex-col space-y-1.5">
|
<div className="flex flex-col space-y-1.5">
|
||||||
<InputLabel htmlFor="email" value="Email" />
|
<InputLabel htmlFor="email" value="Email" />
|
||||||
@@ -88,6 +94,28 @@ export default function UpdateProfileInformation({
|
|||||||
) }
|
) }
|
||||||
</div>
|
</div>
|
||||||
) }
|
) }
|
||||||
|
|
||||||
|
<div className="grid w-full items-center gap-4 pt-3">
|
||||||
|
<div className="flex flex-col space-y-1.5">
|
||||||
|
<InputLabel htmlFor="timezone" value="Timezone" />
|
||||||
|
<Select defaultValue={ data.timezone } onValueChange={ (e) => setData('timezone', e) } className="w-full">
|
||||||
|
<SelectTrigger className="border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600">
|
||||||
|
<SelectValue placeholder="Select a timezone" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ Object.keys(timezones).map((area, i) => (
|
||||||
|
<SelectGroup key={ i }>
|
||||||
|
<SelectLabel>{ area }</SelectLabel>
|
||||||
|
{ Object.keys(timezones[area]).map((tz, j) => (
|
||||||
|
<SelectItem className="pl-5" key={ j } value={ tz }>{ timezones[area][tz] }</SelectItem>
|
||||||
|
) ) }
|
||||||
|
</SelectGroup>
|
||||||
|
) ) }
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<InputError className="mt-2" message={ errors.timezone } />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<PrimaryButton disabled={ processing }>Save</PrimaryButton>
|
<PrimaryButton disabled={ processing }>Save</PrimaryButton>
|
||||||
|
|||||||
@@ -57,18 +57,20 @@ 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.0.2 FR1</span>
|
<span>0.1.0</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
<SidebarGroup className="py-0">
|
<SidebarGroup className="py-0">
|
||||||
<SidebarGroupContent className="relative">
|
<SidebarGroupContent className="relative" tabIndex="0">
|
||||||
<form autoCorrect="false" autoComplete="false" autoFocus="false" onSubmit={ (e) => searchOnSubmitHandler(e)} >
|
<form autoCorrect="false" onSubmit={ (e) => searchOnSubmitHandler(e) }>
|
||||||
<label htmlFor="search" className="sr-only">Search</label>
|
<label htmlFor="search" className="sr-only">Search</label>
|
||||||
<SidebarInput onChange={ (e) => setSearch(e.target.value) } id="search" placeholder="Search" className="pl-8" />
|
<SidebarInput onChange={ (e) => setSearch(e.target.value) } id="search"
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-50" />
|
placeholder="Search" className="pl-8" />
|
||||||
|
<Search
|
||||||
|
className="pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-50" />
|
||||||
</form>
|
</form>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ Route::controller(ComicController::class)->middleware('auth')->name('comics.')->
|
|||||||
|
|
||||||
// Histories
|
// Histories
|
||||||
Route::get('/histories', 'histories')->name('histories');
|
Route::get('/histories', 'histories')->name('histories');
|
||||||
Route::patch('/histories', 'destroyHistories')->name('destroyHistories'); // Only patch accept params
|
Route::patch('/histories', 'patchHistories')->name('patchHistories');
|
||||||
|
Route::delete('/history/{pathword}', 'destroyHistory')->name('destroyHistory');
|
||||||
|
Route::delete('/histories', 'destroyHistories')->name('destroyHistories');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::controller(PagesController::class)->middleware('auth')->name('pages.')->group(function () {
|
Route::controller(PagesController::class)->middleware('auth')->name('pages.')->group(function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user