diff --git a/app/Console/Commands/CleanupReadingHistories.php b/app/Console/Commands/CleanupReadingHistories.php new file mode 100644 index 0000000..f7f0113 --- /dev/null +++ b/app/Console/Commands/CleanupReadingHistories.php @@ -0,0 +1,77 @@ +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."); + } +} diff --git a/app/Helper/Timezones.php b/app/Helper/Timezones.php new file mode 100644 index 0000000..4e5a769 --- /dev/null +++ b/app/Helper/Timezones.php @@ -0,0 +1,41 @@ +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', [ 'histories' => $this->scToZh($histories) @@ -374,7 +374,7 @@ class ComicController extends Controller * @param Request $request * @return RedirectResponse */ - public function destroyHistories(Request $request): RedirectResponse + public function patchHistories(Request $request): RedirectResponse { if (!is_array($request->get('ids')) && $request->get('ids') === 'all') { $histories = $request->user()->readingHistories()->delete(); @@ -385,6 +385,27 @@ class ComicController extends Controller 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 * diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 873b4f7..6f98c98 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Helper\Timezones; use App\Http\Requests\ProfileUpdateRequest; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\RedirectResponse; @@ -18,8 +19,11 @@ class ProfileController extends Controller */ public function edit(Request $request): Response { + $tz = new Timezones(); + return Inertia::render('Profile/Edit', [ 'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail, + 'timezones' => $tz->toArray(), 'status' => session('status'), ]); } @@ -35,6 +39,12 @@ class ProfileController extends Controller $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(); return Redirect::route('profile.edit'); diff --git a/app/Http/Resources/UserCollection.php b/app/Http/Resources/UserCollection.php index 10917dd..a6f2be2 100644 --- a/app/Http/Resources/UserCollection.php +++ b/app/Http/Resources/UserCollection.php @@ -18,6 +18,7 @@ class UserCollection extends ResourceCollection 'id' => $request->user()->id, 'name' => $request->user()->name, 'email' => $request->user()->email, + 'settings' => $request->user()->settings, 'favourites' => $request->user()->favourites()->get(['pathword'])->pluck('pathword') ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 2bc51e9..537bbaf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,16 +3,16 @@ namespace App\Models; 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\Relations\BelongsToMany; +use Illuminate\Database\Query\JoinClause; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\DB; class User extends Authenticatable implements MustVerifyEmail { - /** @use HasFactory */ + use HasFactory, Notifiable; /** @@ -24,6 +24,7 @@ class User extends Authenticatable implements MustVerifyEmail 'name', 'email', 'password', + 'settings', ]; /** @@ -36,6 +37,10 @@ class User extends Authenticatable implements MustVerifyEmail 'remember_token', ]; + protected $attributes = [ + 'settings' => "{timezone:'UTC'}", + ]; + /** * Get the attributes that should be cast. * @@ -46,6 +51,7 @@ class User extends Authenticatable implements MustVerifyEmail return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'settings' => 'array', ]; } @@ -59,4 +65,41 @@ class User extends Authenticatable implements MustVerifyEmail 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, + ]; + } + } diff --git a/bun.lockb b/bun.lockb index 50c49bb..4c59ecf 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/composer.json b/composer.json index d7b1b49..0bafdf0 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "keywords": ["laravel", "framework"], "license": "MIT", "require": { - "php": "^8.2", + "php": "^8.3", "ext-dom": "*", "ext-openssl": "*", "inertiajs/inertia-laravel": "^2.0", diff --git a/composer.lock b/composer.lock index d07d00d..a6a3151 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cf7d41fc8c70877b1ded788c249463de", + "content-hash": "b4f2ee211728714c2fa05fbad3914d2e", "packages": [ { "name": "brick/math", @@ -1189,16 +1189,16 @@ }, { "name": "laravel/framework", - "version": "v11.36.1", + "version": "v11.37.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "df06f5163f4550641fdf349ebc04916a61135a64" + "reference": "6cb103d2024b087eae207654b3f4b26646119ba5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/df06f5163f4550641fdf349ebc04916a61135a64", - "reference": "df06f5163f4550641fdf349ebc04916a61135a64", + "url": "https://api.github.com/repos/laravel/framework/zipball/6cb103d2024b087eae207654b3f4b26646119ba5", + "reference": "6cb103d2024b087eae207654b3f4b26646119ba5", "shasum": "" }, "require": { @@ -1248,7 +1248,6 @@ "voku/portable-ascii": "^2.0.2" }, "conflict": { - "mockery/mockery": "1.6.8", "tightenco/collect": "<5.5.33" }, "provide": { @@ -1400,7 +1399,7 @@ "issues": "https://github.com/laravel/framework/issues", "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", @@ -6193,16 +6192,16 @@ }, { "name": "tightenco/ziggy", - "version": "v2.4.1", + "version": "v2.4.2", "source": { "type": "git", "url": "https://github.com/tighten/ziggy.git", - "reference": "8e002298678fd4d61155bb1d6e3837048235bff7" + "reference": "6612c8c9b2d5b3e74fd67c58c11465df1273f384" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tighten/ziggy/zipball/8e002298678fd4d61155bb1d6e3837048235bff7", - "reference": "8e002298678fd4d61155bb1d6e3837048235bff7", + "url": "https://api.github.com/repos/tighten/ziggy/zipball/6612c8c9b2d5b3e74fd67c58c11465df1273f384", + "reference": "6612c8c9b2d5b3e74fd67c58c11465df1273f384", "shasum": "" }, "require": { @@ -6257,9 +6256,9 @@ ], "support": { "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", @@ -9751,7 +9750,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2", + "php": "^8.3", "ext-dom": "*", "ext-openssl": "*" }, diff --git a/database/migrations/2025_01_04_161757_update_users_table.php b/database/migrations/2025_01_04_161757_update_users_table.php new file mode 100644 index 0000000..4f43cb9 --- /dev/null +++ b/database/migrations/2025_01_04_161757_update_users_table.php @@ -0,0 +1,21 @@ +json('settings')->after('remember_token')->nullable(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('settings'); + }); + } +}; diff --git a/package.json b/package.json index 97b3399..b0fad8b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tailwindcss": "^3.4.17", - "vite": "^6.0.6" + "vite": "^6.0.7" }, "dependencies": { "@hookform/resolvers": "^3.9.1", @@ -42,6 +42,7 @@ "clsx": "^2.1.1", "lodash": "^4.17.21", "lucide-react": "^0.468.0", + "luxon": "^3.5.0", "react-hook-form": "^7.54.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", diff --git a/public/manifest.json b/public/manifest.json index 322ac39..67dade0 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -13,7 +13,7 @@ "type": "image/png" } ], - "start_url": "http://127.0.0.1:8000", + "start_url": "https://c.yumj.in/", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" diff --git a/resources/js/Pages/Comic/Chapters.jsx b/resources/js/Pages/Comic/Chapters.jsx index 0861b62..11830b3 100644 --- a/resources/js/Pages/Comic/Chapters.jsx +++ b/resources/js/Pages/Comic/Chapters.jsx @@ -1,6 +1,6 @@ import { useState } from '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'; @@ -23,11 +23,27 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) { const favouriteOnClickHandler = (pathword) => { axios.post(route('comics.postFavourite'), { pathword: pathword }).then(res => { setFavourites(res.data); + toast({ + title: "All set", + description: `${comic.comic.name} is now in / remove your favorite list.`, + }); }); + } - toast({ - title: "All set", - 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.`, + }); + } }); } @@ -101,6 +117,12 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) { Alias { comic.comic.alias } + + Status + + { comic.comic.region.display } / { comic.comic.status.display } + + Category @@ -115,9 +137,9 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) { Authors { comic.comic.author.map(a => ( - - { a.name } - + + { a.name } + ) ) } @@ -149,10 +171,32 @@ export default function Chapters({ auth, comic, chapters, histories, offset }) { )) } -
- +
+ + + + + + +

Set order as { ascending ? 'Descending' : 'Ascending' }

+
+
+
+ + + + + + + +

Remove all histories for this comic

+
+
+
diff --git a/resources/js/Pages/Comic/Histories.jsx b/resources/js/Pages/Comic/Histories.jsx index 67fba0f..de4a9d8 100644 --- a/resources/js/Pages/Comic/Histories.jsx +++ b/resources/js/Pages/Comic/Histories.jsx @@ -2,6 +2,8 @@ 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 { Button } from '@/components/ui/button'; import Checkbox from "@/components/Checkbox"; @@ -25,19 +27,11 @@ export default function Histories({ auth, histories }) { } const datetimeConversion = (iso8601) => { - const date = new Date(iso8601); - - 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}`; + return DateTime.fromISO(iso8601).setZone(auth.user.settings.timezone).toFormat('dd-MM-yyyy HH:mm'); } const deleteButtonOnClickHandler = () => { - router.visit(route('comics.destroyHistories'), { + router.visit(route('comics.patchHistories'), { data: (ids.length > 0) ? { ids: ids } : { ids: 'all' }, method: "PATCH", 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 ( @@ -67,6 +75,10 @@ export default function Histories({ auth, histories }) { + + @@ -74,7 +86,7 @@ export default function Histories({ auth, histories }) { Select Chapter Comic - Read at + Read at @@ -97,7 +109,7 @@ export default function Histories({ auth, histories }) { { h.comic.name } - { datetimeConversion(h.read_at) } + { datetimeConversion(h.pivot.created_at) } )) } diff --git a/resources/js/Pages/Comic/Index.jsx b/resources/js/Pages/Comic/Index.jsx index bd01a9b..6a013bf 100644 --- a/resources/js/Pages/Comic/Index.jsx +++ b/resources/js/Pages/Comic/Index.jsx @@ -14,7 +14,7 @@ export default function Index({ comics, offset, auth }) { 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 favouriteOnClickHandler = (pathword) => { diff --git a/resources/js/Pages/Pages/Updates.jsx b/resources/js/Pages/Pages/Updates.jsx index 3834770..c504b4a 100644 --- a/resources/js/Pages/Pages/Updates.jsx +++ b/resources/js/Pages/Pages/Updates.jsx @@ -11,6 +11,20 @@ export default function Updates({ auth }) { Updates
+ + + 0.1.0 + Release: 04 Jan 2025 + + +
    +
  • iOS fixed for search auto fours
  • +
  • Beta: Timezones, only available for histories now
  • +
  • Beta: Remove histories for selected comic
  • +
  • Beta: Remove duplicated reading histories
  • +
+
+
0.0.2 FR1 diff --git a/resources/js/Pages/Profile/Edit.jsx b/resources/js/Pages/Profile/Edit.jsx index 8c00443..05e70de 100644 --- a/resources/js/Pages/Profile/Edit.jsx +++ b/resources/js/Pages/Profile/Edit.jsx @@ -6,7 +6,7 @@ import AppLayout from "@/Layouts/AppLayout.jsx"; import { BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator } from "@/components/ui/breadcrumb.jsx"; 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 ( @@ -27,7 +27,7 @@ export default function Edit({ auth, mustVerifyEmail, status }) { diff --git a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx index 233d527..8bfe8da 100644 --- a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx +++ b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx @@ -11,24 +11,29 @@ import { CardFooter, CardHeader, 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({ - mustVerifyEmail, - status, - className = '', -}) { +export default function UpdateProfileInformation({ mustVerifyEmail, status, timezones }) { const user = usePage().props.auth.user; const { data, setData, patch, errors, processing, recentlySuccessful } = useForm({ name: user.name, email: user.email, + timezone: user.settings.timezone }); const submit = (e) => { e.preventDefault(); - patch(route('profile.update')); }; @@ -51,6 +56,7 @@ export default function UpdateProfileInformation({
+
@@ -88,6 +94,28 @@ export default function UpdateProfileInformation({ ) }
) } + +
+
+ + +
+ +
Save diff --git a/resources/js/components/ui/app-sidebar.jsx b/resources/js/components/ui/app-sidebar.jsx index e0d942b..c3d565a 100644 --- a/resources/js/components/ui/app-sidebar.jsx +++ b/resources/js/components/ui/app-sidebar.jsx @@ -57,18 +57,20 @@ export function AppSidebar({ auth }) {
Comic - 0.0.2 FR1 + 0.1.0
- -
searchOnSubmitHandler(e)} > + + searchOnSubmitHandler(e) }> - setSearch(e.target.value) } id="search" placeholder="Search" className="pl-8" /> - + setSearch(e.target.value) } id="search" + placeholder="Search" className="pl-8" /> +
diff --git a/routes/web.php b/routes/web.php index b6830f1..4151f6f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,7 +27,9 @@ Route::controller(ComicController::class)->middleware('auth')->name('comics.')-> // 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 () {