This commit is contained in:
Flatlogic Bot 2026-02-05 04:02:20 +00:00
parent ef0c922762
commit d5087fc4e7
7 changed files with 543 additions and 21 deletions

View File

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

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('Browse Sources')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiSourceBranch} title="Manga Sources" main>
<BaseButton
icon={mdiRefresh}
color="whiteDark"
onClick={() => dispatch(fetch({}))}
disabled={loading}
/>
</SectionTitleLineWithButton>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6">
{sources.map((source: any) => (
<CardBox key={source.id} className="flex flex-col">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-bold">{source.name}</h2>
<span className={`px-2 py-1 text-xs rounded ${source.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{source.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<p className="text-gray-500 text-sm mb-4 flex-grow">
{source.base_url}
</p>
<div className="flex justify-between items-center">
<span className="text-xs text-gray-400 uppercase tracking-wider">
{source.source_type} {source.default_language}
</span>
<Link href={`/sources/browse/${source.id}`} passHref legacyBehavior>
<BaseButton
color="info"
label="Browse"
icon={mdiOpenInApp}
small
/>
</Link>
</div>
</CardBox>
))}
</div>
{sources.length === 0 && !loading && (
<CardBox>
<div className="text-center py-10">
<p className="text-gray-500">No sources found. Add a source in the admin panel to get started.</p>
</div>
</CardBox>
)}
</SectionMain>
</>
)
}
BrowseSourcesPage.getLayout = function getLayout(page: React.ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default BrowseSourcesPage

View File

@ -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 <div className="text-center py-20">Loading...</div>
}
return (
<>
<Head>
<title>{getPageTitle(mangaDetails?.title || 'Manga Details')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiInformation}
title={mangaDetails?.title || 'Manga Details'}
main
>
<Link href={`/sources/browse/${sourceId}`} passHref legacyBehavior>
<BaseButton icon={mdiArrowLeft} label="Back to Source" color="whiteDark" />
</Link>
</SectionTitleLineWithButton>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div className="md:col-span-1">
<CardBox className="overflow-hidden p-0">
<img
src={mangaDetails?.coverUrl || 'https://via.placeholder.com/512x720'}
alt={mangaDetails?.title}
className="w-full h-auto"
/>
</CardBox>
</div>
<div className="md:col-span-3">
<CardBox className="h-full">
<h1 className="text-3xl font-bold mb-4 dark:text-white">{mangaDetails?.title}</h1>
<div className="flex flex-wrap gap-2 mb-4">
{mangaDetails?.author && (
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
Author: {mangaDetails.author}
</span>
)}
{mangaDetails?.artist && (
<span className="px-3 py-1 bg-purple-100 text-purple-800 rounded-full text-sm">
Artist: {mangaDetails.artist}
</span>
)}
<span className="px-3 py-1 bg-gray-100 text-gray-800 rounded-full text-sm">
Source: {mangaDetails?.source}
</span>
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
Status: {mangaDetails?.status}
</span>
</div>
<div className="prose dark:prose-invert max-w-none">
<p className="text-gray-600 dark:text-slate-300">
{mangaDetails?.description || 'No description available.'}
</p>
</div>
</CardBox>
</div>
</div>
<CardBox title="Chapters">
<div className="divide-y dark:divide-slate-700 max-h-[600px] overflow-y-auto">
{chapters.map((chapter: any) => (
<div key={chapter.id} className="py-3 flex justify-between items-center group">
<div>
<span className="font-semibold dark:text-white">Chapter {chapter.chapter}</span>
{chapter.title && <span className="ml-2 text-gray-500 text-sm">- {chapter.title}</span>}
</div>
<Link href={`/reader/${sourceId}/${chapter.id}`} passHref legacyBehavior>
<BaseButton
color="info"
label="Read"
icon={mdiBookOpenVariant}
small
outline
/>
</Link>
</div>
))}
{chapters.length === 0 && (
<div className="py-10 text-center text-gray-500">
No chapters found for this manga.
</div>
)}
</div>
</CardBox>
</SectionMain>
</>
)
}
MangaDetailsPage.getLayout = function getLayout(page: React.ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default MangaDetailsPage

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('Reading Chapter')}</title>
</Head>
<div className="bg-black min-h-screen text-white">
<div className="sticky top-0 z-50 bg-slate-900 bg-opacity-90 px-4 py-2 flex justify-between items-center">
<BaseButton
icon={mdiArrowLeft}
color="white"
onClick={() => router.back()}
small
label="Exit"
/>
<div className="text-sm font-semibold truncate max-w-xs">
Chapter {chapterId}
</div>
<BaseButton
icon={mdiFormatVerticalAlignTop}
color="white"
onClick={scrollToTop}
small
/>
</div>
<div className="max-w-3xl mx-auto py-4">
{loading ? (
<div className="flex flex-col items-center justify-center py-40">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-info-500"></div>
<p className="mt-4 text-gray-400">Loading pages...</p>
</div>
) : (
<div className="space-y-1">
{pages.map((url: string, index: number) => (
<div key={index} className="relative w-full flex justify-center bg-slate-950">
<img
src={url}
alt={`Page ${index + 1}`}
className="max-w-full h-auto"
loading="lazy"
/>
</div>
))}
</div>
)}
{!loading && pages.length === 0 && (
<div className="text-center py-40">
<p className="text-gray-500">No pages found for this chapter.</p>
<BaseButton
label="Go Back"
color="info"
onClick={() => router.back()}
className="mt-4"
/>
</div>
)}
</div>
<div className="fixed bottom-4 right-4 space-x-2">
<BaseButton
icon={mdiFormatVerticalAlignTop}
color="info"
onClick={scrollToTop}
rounded-full
/>
</div>
</div>
</>
)
}
// 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

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('Browse Source')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiViewList}
title={`Browse Source`}
main
>
<Link href="/browse-sources" passHref legacyBehavior>
<BaseButton icon={mdiArrowLeft} label="Back to Sources" color="whiteDark" />
</Link>
</SectionTitleLineWithButton>
<CardBox className="mb-6">
<form onSubmit={handleSearch} className="flex gap-4">
<div className="flex-grow">
<FormField>
<input
type="text"
placeholder="Search manga in this source..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full px-4 py-2 border rounded dark:bg-slate-800 dark:border-slate-700"
/>
</FormField>
</div>
<BaseButton
type="submit"
color="info"
label="Search"
icon={mdiMagnify}
disabled={loading}
/>
</form>
</CardBox>
{loading ? (
<div className="text-center py-20">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-info-500"></div>
<p className="mt-2 text-gray-500">Fetching manga...</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{searchResults.map((manga: any) => (
<Link key={manga.id} href={`/manga/view/${id}/${manga.id}`} passHref legacyBehavior>
<div className="cursor-pointer group">
<div className="aspect-[3/4] overflow-hidden rounded-lg bg-gray-200 dark:bg-slate-800 relative">
{manga.coverUrl ? (
<img
src={manga.coverUrl}
alt={manga.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
No Cover
</div>
)}
</div>
<h3 className="mt-2 text-sm font-semibold line-clamp-2 dark:text-white">
{manga.title}
</h3>
</div>
</Link>
))}
</div>
)}
{!loading && searchResults.length === 0 && (
<div className="text-center py-20 text-gray-500">
No results found. Try a different search query.
</div>
)}
</SectionMain>
</>
)
}
BrowseSourceIdPage.getLayout = function getLayout(page: React.ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default BrowseSourceIdPage

View File

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

View File

@ -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<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch
export type AppDispatch = typeof store.dispatch