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?.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 ? (
+
+ ) : (
+
+ {pages.map((url: string, index: number) => (
+
+

+
+ ))}
+
+ )}
+
+ {!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')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+ ) : (
+
+ {searchResults.map((manga: any) => (
+
+
+
+ {manga.coverUrl ? (
+

+ ) : (
+
+ 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