From d5087fc4e7bf779953b98490aae5a3652eeb7272 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 5 Feb 2026 04:02:20 +0000 Subject: [PATCH] dave --- frontend/src/menuAside.ts | 44 ++++--- frontend/src/pages/browse-sources.tsx | 83 ++++++++++++ .../pages/manga/view/[sourceId]/[mangaId].tsx | 124 ++++++++++++++++++ .../pages/reader/[sourceId]/[chapterId].tsx | 111 ++++++++++++++++ frontend/src/pages/sources/browse/[id].tsx | 122 +++++++++++++++++ frontend/src/stores/mangaSlice.ts | 76 +++++++++++ frontend/src/stores/store.ts | 4 +- 7 files changed, 543 insertions(+), 21 deletions(-) create mode 100644 frontend/src/pages/browse-sources.tsx create mode 100644 frontend/src/pages/manga/view/[sourceId]/[mangaId].tsx create mode 100644 frontend/src/pages/reader/[sourceId]/[chapterId].tsx create mode 100644 frontend/src/pages/sources/browse/[id].tsx create mode 100644 frontend/src/stores/mangaSlice.ts diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 085093e..6fa19c6 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,7 +7,27 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, - + { + href: '/browse-sources', + icon: icon.mdiSourceBranch, + label: 'Browse Extensions', + }, + { + href: '/library_entries/library_entries-list', + label: 'My Library', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiLibraryShelves' in icon ? icon['mdiLibraryShelves' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_LIBRARY_ENTRIES' + }, + { + href: '/series/series-list', + label: 'Local Series', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiBookOpenPageVariant' in icon ? icon['mdiBookOpenPageVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_SERIES' + }, { href: '/users/users-list', label: 'Users', @@ -34,7 +54,7 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/sources/sources-list', - label: 'Sources', + label: 'Sources Admin', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiSourceRepository' in icon ? icon['mdiSourceRepository' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, @@ -42,20 +62,12 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/extensions/extensions-list', - label: 'Extensions', + label: 'Extensions Admin', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiPuzzle' in icon ? icon['mdiPuzzle' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_EXTENSIONS' }, - { - href: '/series/series-list', - label: 'Series', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiBookOpenPageVariant' in icon ? icon['mdiBookOpenPageVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_SERIES' - }, { href: '/chapters/chapters-list', label: 'Chapters', @@ -72,14 +84,6 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_CHAPTER_PAGES' }, - { - href: '/library_entries/library_entries-list', - label: 'Library entries', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiLibraryShelves' in icon ? icon['mdiLibraryShelves' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_LIBRARY_ENTRIES' - }, { href: '/categories/categories-list', label: 'Categories', @@ -208,4 +212,4 @@ const menuAside: MenuAsideItem[] = [ }, ] -export default menuAside +export default menuAside \ No newline at end of file diff --git a/frontend/src/pages/browse-sources.tsx b/frontend/src/pages/browse-sources.tsx new file mode 100644 index 0000000..68b0363 --- /dev/null +++ b/frontend/src/pages/browse-sources.tsx @@ -0,0 +1,83 @@ +import React, { useEffect } from 'react' +import Head from 'next/head' +import { mdiOpenInApp, mdiSourceBranch, mdiRefresh } from '@mdi/js' +import LayoutAuthenticated from '../layouts/Authenticated' +import SectionMain from '../components/SectionMain' +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' +import CardBox from '../components/CardBox' +import { useAppDispatch, useAppSelector } from '../stores/hooks' +import { fetch } from '../stores/sources/sourcesSlice' +import BaseButton from '../components/BaseButton' +import { getPageTitle } from '../config' +import Link from 'next/link' + +const BrowseSourcesPage = () => { + const dispatch = useAppDispatch() + const { sources, loading } = useAppSelector((state) => state.sources) + + useEffect(() => { + dispatch(fetch({})) + }, [dispatch]) + + return ( + <> + + {getPageTitle('Browse Sources')} + + + + + dispatch(fetch({}))} + disabled={loading} + /> + + +
+ {sources.map((source: any) => ( + +
+

{source.name}

+ + {source.enabled ? 'Enabled' : 'Disabled'} + +
+

+ {source.base_url} +

+
+ + {source.source_type} • {source.default_language} + + + + +
+
+ ))} +
+ + {sources.length === 0 && !loading && ( + +
+

No sources found. Add a source in the admin panel to get started.

+
+
+ )} +
+ + ) +} + +BrowseSourcesPage.getLayout = function getLayout(page: React.ReactElement) { + return {page} +} + +export default BrowseSourcesPage diff --git a/frontend/src/pages/manga/view/[sourceId]/[mangaId].tsx b/frontend/src/pages/manga/view/[sourceId]/[mangaId].tsx new file mode 100644 index 0000000..7337e72 --- /dev/null +++ b/frontend/src/pages/manga/view/[sourceId]/[mangaId].tsx @@ -0,0 +1,124 @@ +import React, { useEffect } from 'react' +import Head from 'next/head' +import { useRouter } from 'next/router' +import { mdiBookOpenVariant, mdiArrowLeft, mdiInformation } from '@mdi/js' +import LayoutAuthenticated from '../../../layouts/Authenticated' +import SectionMain from '../../../components/SectionMain' +import SectionTitleLineWithButton from '../../../components/SectionTitleLineWithButton' +import CardBox from '../../../components/CardBox' +import { useAppDispatch, useAppSelector } from '../../../stores/hooks' +import { fetchMangaDetails, fetchChapters } from '../../../stores/mangaSlice' +import BaseButton from '../../../components/BaseButton' +import { getPageTitle } from '../../../config' +import Link from 'next/link' + +const MangaDetailsPage = () => { + const router = useRouter() + const { sourceId, mangaId } = router.query + const dispatch = useAppDispatch() + const { mangaDetails, chapters, loading } = useAppSelector((state) => state.manga) + + useEffect(() => { + if (sourceId && mangaId && typeof sourceId === 'string' && typeof mangaId === 'string') { + dispatch(fetchMangaDetails({ sourceId, mangaId })) + dispatch(fetchChapters({ sourceId, mangaId })) + } + }, [sourceId, mangaId, dispatch]) + + if (!mangaDetails && loading) { + return
Loading...
+ } + + return ( + <> + + {getPageTitle(mangaDetails?.title || 'Manga Details')} + + + + + + + + + +
+
+ + {mangaDetails?.title} + +
+
+ +

{mangaDetails?.title}

+
+ {mangaDetails?.author && ( + + Author: {mangaDetails.author} + + )} + {mangaDetails?.artist && ( + + Artist: {mangaDetails.artist} + + )} + + Source: {mangaDetails?.source} + + + Status: {mangaDetails?.status} + +
+
+

+ {mangaDetails?.description || 'No description available.'} +

+
+
+
+
+ + +
+ {chapters.map((chapter: any) => ( +
+
+ Chapter {chapter.chapter} + {chapter.title && - {chapter.title}} +
+ + + +
+ ))} + {chapters.length === 0 && ( +
+ No chapters found for this manga. +
+ )} +
+
+
+ + ) +} + +MangaDetailsPage.getLayout = function getLayout(page: React.ReactElement) { + return {page} +} + +export default MangaDetailsPage diff --git a/frontend/src/pages/reader/[sourceId]/[chapterId].tsx b/frontend/src/pages/reader/[sourceId]/[chapterId].tsx new file mode 100644 index 0000000..774c923 --- /dev/null +++ b/frontend/src/pages/reader/[sourceId]/[chapterId].tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState } from 'react' +import Head from 'next/head' +import { useRouter } from 'next/router' +import { mdiArrowLeft, mdiFormatVerticalAlignTop, mdiChevronLeft, mdiChevronRight } from '@mdi/js' +import LayoutAuthenticated from '../../../layouts/Authenticated' +import SectionMain from '../../../components/SectionMain' +import { useAppDispatch, useAppSelector } from '../../../stores/hooks' +import { fetchPages, clearReader } from '../../../stores/mangaSlice' +import BaseButton from '../../../components/BaseButton' +import { getPageTitle } from '../../../config' +import Link from 'next/link' + +const ReaderPage = () => { + const router = useRouter() + const { sourceId, chapterId } = router.query + const dispatch = useAppDispatch() + const { pages, loading } = useAppSelector((state) => state.manga) + const [currentPage, setCurrentPage] = useState(0) + + useEffect(() => { + if (sourceId && chapterId && typeof sourceId === 'string' && typeof chapterId === 'string') { + dispatch(fetchPages({ sourceId, chapterId })) + } + return () => { + dispatch(clearReader()) + } + }, [sourceId, chapterId, dispatch]) + + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + return ( + <> + + {getPageTitle('Reading Chapter')} + + +
+
+ router.back()} + small + label="Exit" + /> +
+ Chapter {chapterId} +
+ +
+ +
+ {loading ? ( +
+
+

Loading pages...

+
+ ) : ( +
+ {pages.map((url: string, index: number) => ( +
+ {`Page +
+ ))} +
+ )} + + {!loading && pages.length === 0 && ( +
+

No pages found for this chapter.

+ router.back()} + className="mt-4" + /> +
+ )} +
+ +
+ +
+
+ + ) +} + +// We use a blank layout for the reader or Guest layout if we want it fullscreen +ReaderPage.getLayout = function getLayout(page: React.ReactElement) { + return page // Custom layout included in component +} + +export default ReaderPage diff --git a/frontend/src/pages/sources/browse/[id].tsx b/frontend/src/pages/sources/browse/[id].tsx new file mode 100644 index 0000000..ba4aa76 --- /dev/null +++ b/frontend/src/pages/sources/browse/[id].tsx @@ -0,0 +1,122 @@ +import React, { useState, useEffect } from 'react' +import Head from 'next/head' +import { useRouter } from 'next/router' +import { mdiMagnify, mdiArrowLeft, mdiViewList } from '@mdi/js' +import LayoutAuthenticated from '../../../layouts/Authenticated' +import SectionMain from '../../../components/SectionMain' +import SectionTitleLineWithButton from '../../../components/SectionTitleLineWithButton' +import CardBox from '../../../components/CardBox' +import { useAppDispatch, useAppSelector } from '../../../stores/hooks' +import { searchManga } from '../../../stores/mangaSlice' +import BaseButton from '../../../components/BaseButton' +import FormField from '../../../components/FormField' +import { getPageTitle } from '../../../config' +import Link from 'next/link' + +const BrowseSourceIdPage = () => { + const router = useRouter() + const { id } = router.query + const dispatch = useAppDispatch() + const { searchResults, loading } = useAppSelector((state) => state.manga) + const [query, setQuery] = useState('') + + const handleSearch = (e?: React.FormEvent) => { + if (e) e.preventDefault() + if (id && typeof id === 'string') { + dispatch(searchManga({ query, sourceId: id })) + } + } + + useEffect(() => { + if (id && typeof id === 'string' && searchResults.length === 0) { + dispatch(searchManga({ query: '', sourceId: id })) + } + }, [id, dispatch]) + + return ( + <> + + {getPageTitle('Browse Source')} + + + + + + + + + + +
+
+ + setQuery(e.target.value)} + className="w-full px-4 py-2 border rounded dark:bg-slate-800 dark:border-slate-700" + /> + +
+ + +
+ + {loading ? ( +
+
+

Fetching manga...

+
+ ) : ( +
+ {searchResults.map((manga: any) => ( + +
+
+ {manga.coverUrl ? ( + {manga.title} + ) : ( +
+ No Cover +
+ )} +
+

+ {manga.title} +

+
+ + ))} +
+ )} + + {!loading && searchResults.length === 0 && ( +
+ No results found. Try a different search query. +
+ )} +
+ + ) +} + +BrowseSourceIdPage.getLayout = function getLayout(page: React.ReactElement) { + return {page} +} + +export default BrowseSourceIdPage \ No newline at end of file diff --git a/frontend/src/stores/mangaSlice.ts b/frontend/src/stores/mangaSlice.ts new file mode 100644 index 0000000..63b3f23 --- /dev/null +++ b/frontend/src/stores/mangaSlice.ts @@ -0,0 +1,76 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import axios from 'axios' + +interface MangaState { + searchResults: any[] + mangaDetails: any | null + chapters: any[] + pages: any[] + loading: boolean + error: string | null +} + +const initialState: MangaState = { + searchResults: [], + mangaDetails: null, + chapters: [], + pages: [], + loading: false, + error: null, +} + +export const searchManga = createAsyncThunk('manga/search', async (data: { query: string, sourceId: string }) => { + const response = await axios.get('/manga/search', { params: data }); + return response.data; +}) + +export const fetchMangaDetails = createAsyncThunk('manga/fetchDetails', async (data: { mangaId: string, sourceId: string }) => { + const response = await axios.get(`/manga/details/${data.sourceId}/${data.mangaId}`); + return response.data; +}) + +export const fetchChapters = createAsyncThunk('manga/fetchChapters', async (data: { mangaId: string, sourceId: string }) => { + const response = await axios.get(`/manga/chapters/${data.sourceId}/${data.mangaId}`); + return response.data; +}) + +export const fetchPages = createAsyncThunk('manga/fetchPages', async (data: { chapterId: string, sourceId: string }) => { + const response = await axios.get(`/manga/pages/${data.sourceId}/${data.chapterId}`); + return response.data; +}) + +export const mangaSlice = createSlice({ + name: 'manga', + initialState, + reducers: { + clearReader: (state) => { + state.pages = []; + } + }, + extraReducers: (builder) => { + builder + .addCase(searchManga.pending, (state) => { state.loading = true; }) + .addCase(searchManga.fulfilled, (state, action) => { + state.loading = false; + state.searchResults = action.payload; + }) + .addCase(searchManga.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Search failed'; + }) + .addCase(fetchMangaDetails.fulfilled, (state, action) => { + state.mangaDetails = action.payload; + }) + .addCase(fetchChapters.fulfilled, (state, action) => { + state.chapters = action.payload; + }) + .addCase(fetchPages.pending, (state) => { state.loading = true; }) + .addCase(fetchPages.fulfilled, (state, action) => { + state.loading = false; + state.pages = action.payload; + }) + }, +}) + +export const { clearReader } = mangaSlice.actions +export default mangaSlice.reducer diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index 635d0a0..9b69498 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -3,6 +3,7 @@ import styleReducer from './styleSlice'; import mainReducer from './mainSlice'; import authSlice from './authSlice'; import openAiSlice from './openAiSlice'; +import mangaReducer from './mangaSlice'; import usersSlice from "./users/usersSlice"; import rolesSlice from "./roles/rolesSlice"; @@ -34,6 +35,7 @@ export const store = configureStore({ main: mainReducer, auth: authSlice, openAi: openAiSlice, + manga: mangaReducer, users: usersSlice, roles: rolesSlice, @@ -64,4 +66,4 @@ reading_progress: reading_progressSlice, // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} -export type AppDispatch = typeof store.dispatch +export type AppDispatch = typeof store.dispatch \ No newline at end of file