This commit is contained in:
User
2025-01-04 13:02:50 -05:00
parent da807296f3
commit 489e054614
20 changed files with 377 additions and 61 deletions

View 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
View 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;
}
}

View File

@@ -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
* *

View File

@@ -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');

View File

@@ -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')
]; ];
} }

View File

@@ -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,
];
}
} }

BIN
bun.lockb

Binary file not shown.

View File

@@ -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
View File

@@ -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": "*"
}, },

View 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');
});
}
};

View File

@@ -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",

View File

@@ -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"

View File

@@ -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,11 +23,27 @@ 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({
title: "All set",
description: `${comic.comic.name} is now in / remove your favorite list.`,
});
}); });
}
toast({ const removeAllHistoriesOnClickHandler = (pathword) => {
title: "All set", router.visit(route('comics.destroyHistory', { pathword: pathword }), {
description: `${comic.comic.name} is now in / remove your favorite list.`, method: 'DELETE',
replace: false,
preserveScroll: true,
preserveState: true,
showProgress: false,
only: ['histories'],
onSuccess: () => {
toast({
title: "All set",
description: `All histories have been removed.`,
});
}
}); });
} }
@@ -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>
@@ -115,9 +137,9 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
<td className="text-right pr-3">Authors</td> <td className="text-right pr-3">Authors</td>
<td> <td>
{ comic.comic.author.map(a => ( { comic.comic.author.map(a => (
<Badge key={ a.path_word } className="m-2" variant="outline"> <Badge key={ a.path_word } className="m-2" variant="outline">
<Link href={ route('comics.author', [a.path_word]) }>{ a.name }</Link> <Link href={ route('comics.author', [a.path_word]) }>{ a.name }</Link>
</Badge> </Badge>
) ) } ) ) }
</td> </td>
</tr> </tr>
@@ -149,10 +171,32 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) {
</TabsTrigger> </TabsTrigger>
)) } )) }
</TabsList> </TabsList>
<div> <div className="flex justify-end">
<Button variant="link" size="icon" onClick={ () => toggleAscending() }> <TooltipProvider>
{ ascending ? <ArrowDownNarrowWide /> : <ArrowUpNarrowWide /> } <Tooltip>
</Button> <TooltipTrigger asChild>
<Button variant="link" size="icon" onClick={ () => toggleAscending() }>
{ ascending ? <ArrowDownNarrowWide /> : <ArrowUpNarrowWide /> }
</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 }>

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 () {