dave
This commit is contained in:
parent
ef0c922762
commit
d5087fc4e7
@ -7,7 +7,27 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
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',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
@ -34,7 +54,7 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/sources/sources-list',
|
href: '/sources/sources-list',
|
||||||
label: 'Sources',
|
label: 'Sources Admin',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: 'mdiSourceRepository' in icon ? icon['mdiSourceRepository' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
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',
|
href: '/extensions/extensions-list',
|
||||||
label: 'Extensions',
|
label: 'Extensions Admin',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: 'mdiPuzzle' in icon ? icon['mdiPuzzle' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon: 'mdiPuzzle' in icon ? icon['mdiPuzzle' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
permissions: 'READ_EXTENSIONS'
|
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',
|
href: '/chapters/chapters-list',
|
||||||
label: 'Chapters',
|
label: 'Chapters',
|
||||||
@ -72,14 +84,6 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
permissions: 'READ_CHAPTER_PAGES'
|
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',
|
href: '/categories/categories-list',
|
||||||
label: 'Categories',
|
label: 'Categories',
|
||||||
@ -208,4 +212,4 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default menuAside
|
export default menuAside
|
||||||
83
frontend/src/pages/browse-sources.tsx
Normal file
83
frontend/src/pages/browse-sources.tsx
Normal 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
|
||||||
124
frontend/src/pages/manga/view/[sourceId]/[mangaId].tsx
Normal file
124
frontend/src/pages/manga/view/[sourceId]/[mangaId].tsx
Normal 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
|
||||||
111
frontend/src/pages/reader/[sourceId]/[chapterId].tsx
Normal file
111
frontend/src/pages/reader/[sourceId]/[chapterId].tsx
Normal 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
|
||||||
122
frontend/src/pages/sources/browse/[id].tsx
Normal file
122
frontend/src/pages/sources/browse/[id].tsx
Normal 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
|
||||||
76
frontend/src/stores/mangaSlice.ts
Normal file
76
frontend/src/stores/mangaSlice.ts
Normal 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
|
||||||
@ -3,6 +3,7 @@ import styleReducer from './styleSlice';
|
|||||||
import mainReducer from './mainSlice';
|
import mainReducer from './mainSlice';
|
||||||
import authSlice from './authSlice';
|
import authSlice from './authSlice';
|
||||||
import openAiSlice from './openAiSlice';
|
import openAiSlice from './openAiSlice';
|
||||||
|
import mangaReducer from './mangaSlice';
|
||||||
|
|
||||||
import usersSlice from "./users/usersSlice";
|
import usersSlice from "./users/usersSlice";
|
||||||
import rolesSlice from "./roles/rolesSlice";
|
import rolesSlice from "./roles/rolesSlice";
|
||||||
@ -34,6 +35,7 @@ export const store = configureStore({
|
|||||||
main: mainReducer,
|
main: mainReducer,
|
||||||
auth: authSlice,
|
auth: authSlice,
|
||||||
openAi: openAiSlice,
|
openAi: openAiSlice,
|
||||||
|
manga: mangaReducer,
|
||||||
|
|
||||||
users: usersSlice,
|
users: usersSlice,
|
||||||
roles: rolesSlice,
|
roles: rolesSlice,
|
||||||
@ -64,4 +66,4 @@ reading_progress: reading_progressSlice,
|
|||||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||||
export type RootState = ReturnType<typeof store.getState>
|
export type RootState = ReturnType<typeof store.getState>
|
||||||
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
|
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
|
||||||
export type AppDispatch = typeof store.dispatch
|
export type AppDispatch = typeof store.dispatch
|
||||||
Loading…
x
Reference in New Issue
Block a user