diff --git a/.env.local b/.env.local
deleted file mode 100644
index c418598..0000000
--- a/.env.local
+++ /dev/null
@@ -1,2 +0,0 @@
-VITE_API_BASE_URL=http://localhost:8080
-VITE_OMV_BASE_URL=http://omv.badger-pirarucu.ts.net:9000/mangamochi-dev
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index f23ba7c..7dbee46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -222,7 +222,7 @@ web_modules/
.yarn-integrity
# dotenv environment variable files
-.env
+.env*
.env.development.local
.env.test.local
.env.production.local
diff --git a/src/components/MangaLoadingState.tsx b/src/components/MangaLoadingState.tsx
new file mode 100644
index 0000000..1167f72
--- /dev/null
+++ b/src/components/MangaLoadingState.tsx
@@ -0,0 +1,40 @@
+import { Spinner } from "@/components/ui/spinner";
+import { useRef } from "react";
+
+const CHEEKY_MESSAGES = [
+ "Sharpening katanas and downloading manga...",
+ "Searching for the One Piece... and your titles.",
+ "Summoning the Great Sage for faster loading...",
+ "Powering up to Super Saiyan level... please wait.",
+ "Collecting all seven Dragon Balls to fetch data...",
+ "Even Saitama takes a second to load... sometimes.",
+ "Naruto is training, wait for the results...",
+ "Loading... because we don't have a Death Note for bugs.",
+ "Entering the Hidden Leaf Village... of data.",
+ "Waiting for the next chapter... and your results.",
+ "Training in the Hyperbolic Time Chamber for better speed...",
+ "Collecting chakra for the ultimate data retrieval...",
+ "Waiting for the next hiatus to end... oh wait, just loading.",
+ "Reading the manga faster than you can... hold on.",
+ "Asking the Shinigami for the right data...",
+ "Is this a Jojo reference? No, it's just loading.",
+ "Surpassing our limits... Right here! Right now!",
+ "Hunting for the rarest manga volumes in the digital world...",
+ "Dodging spoilers while fetching your manga...",
+ "Preparing the transmutation circle for your results...",
+];
+
+export const MangaLoadingState = () => {
+ const loadingMessage = useRef(
+ CHEEKY_MESSAGES[Math.floor(Math.random() * CHEEKY_MESSAGES.length)],
+ );
+
+ return (
+
+
+
+ {loadingMessage.current}
+
+
+ );
+};
diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..a70e713
--- /dev/null
+++ b/src/components/ui/spinner.tsx
@@ -0,0 +1,16 @@
+import { Loader2Icon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+
+ )
+}
+
+export { Spinner }
diff --git a/src/contexts/UIStateContext.tsx b/src/contexts/UIStateContext.tsx
index 6aeca64..d60664b 100644
--- a/src/contexts/UIStateContext.tsx
+++ b/src/contexts/UIStateContext.tsx
@@ -26,6 +26,8 @@ interface UIStateContextType {
setSortOption: (sort: SortOption) => void;
searchText: string;
setSearchText: (text: string) => void;
+ isSidebarOpen: boolean;
+ setIsSidebarOpen: (open: boolean) => void;
resetFilters: () => void;
/* Manga Provider Card State */
@@ -49,6 +51,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
const [showAdultContent, setShowAdultContent] = useState(false);
const [sortOption, setSortOption] = useState("title-asc");
const [searchText, setSearchText] = useState("");
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true);
/* Manga Provider Card State */
const [expandedProviderIds, setExpandedProviderIds] = useState([]);
@@ -104,6 +107,8 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
setSortOption,
searchText,
setSearchText,
+ isSidebarOpen,
+ setIsSidebarOpen,
resetFilters,
expandedProviderIds,
toggleProviderId,
@@ -119,6 +124,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
showAdultContent,
sortOption,
searchText,
+ isSidebarOpen,
resetFilters,
expandedProviderIds,
toggleProviderId,
diff --git a/src/features/home/components/FilterSidebar.tsx b/src/features/home/components/FilterSidebar.tsx
index 0fb0aad..919222f 100644
--- a/src/features/home/components/FilterSidebar.tsx
+++ b/src/features/home/components/FilterSidebar.tsx
@@ -1,4 +1,4 @@
-import { Star, X } from "lucide-react";
+import { PanelLeftClose, Star, X } from "lucide-react";
import { useGetGenres } from "@/api/generated/catalog/catalog.ts";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -18,6 +18,8 @@ interface FilterSidebarProps {
onRatingChange: (rating: number) => void;
onUserFavoritesChange: (favorites: boolean) => void;
onShowAdultContentChange: (showAdult: boolean) => void;
+ onHide: () => void;
+ isDisabled?: boolean;
}
const STATUSES = ["Ongoing", "Completed", "Hiatus"];
@@ -40,6 +42,8 @@ export function FilterSidebar({
onRatingChange,
onUserFavoritesChange,
onShowAdultContentChange,
+ onHide,
+ isDisabled = false,
}: FilterSidebarProps) {
const { data: genresData, isPending: isPendingGenres } = useGetGenres();
const { isAuthenticated } = useAuth();
@@ -79,14 +83,25 @@ export function FilterSidebar({
{/* Header */}
-
- Filters
-
+
{hasActiveFilters && (
Clear
@@ -110,6 +125,7 @@ export function FilterSidebar({
@@ -119,6 +135,7 @@ export function FilterSidebar({
@@ -145,8 +162,8 @@ export function FilterSidebar({
isSelected
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
- }`}
- onClick={() => toggleGenre(genre.id)}
+ } ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
+ onClick={() => !isDisabled && toggleGenre(genre.id)}
>
{genre.name}
{isSelected && }
@@ -174,8 +191,8 @@ export function FilterSidebar({
isSelected
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
- }`}
- onClick={() => toggleStatus(status)}
+ } ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
+ onClick={() => !isDisabled && toggleStatus(status)}
>
{status}
{isSelected && }
@@ -203,7 +220,8 @@ export function FilterSidebar({
isSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
- }`}
+ } ${isDisabled ? "opacity-50 pointer-events-none" : ""}`}
+ disabled={isDisabled}
>
{
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
+ const [isImageLoading, setIsImageLoading] = useState(true);
const updateQueryData = useCallback(
- (oldData: PageMangaListDTO | undefined, isFavorite: boolean) => ({
+ (
+ oldData: DefaultResponseDTOPageMangaListDTO | undefined,
+ isFavorite: boolean,
+ ) => ({
...oldData,
- content:
- oldData?.content?.map((manga) =>
- manga.id === manga.id ? { ...manga, favorite: isFavorite } : manga,
- ) || [],
+ data: {
+ ...oldData?.data,
+ content:
+ oldData?.data?.content?.map((item) =>
+ item.id === manga.id ? { ...item, favorite: isFavorite } : item,
+ ) || [],
+ },
}),
- [],
+ [manga.id],
);
const { mutate: mutateFavorite, isPending: isPendingFavorite } =
@@ -41,8 +50,8 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
mutation: {
onSuccess: () =>
queryClient.setQueryData(
- [queryKey],
- (oldData: PageMangaListDTO | undefined) =>
+ queryKey,
+ (oldData: DefaultResponseDTOPageMangaListDTO | undefined) =>
updateQueryData(oldData, true),
),
},
@@ -53,8 +62,8 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
mutation: {
onSuccess: () =>
queryClient.setQueryData(
- [queryKey],
- (oldData: PageMangaListDTO | undefined) =>
+ queryKey,
+ (oldData: DefaultResponseDTOPageMangaListDTO | undefined) =>
updateQueryData(oldData, false),
),
},
@@ -74,6 +83,9 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
+ {isImageLoading && (
+
+ )}
{
"/placeholder.svg"
}
alt={manga.title}
- className="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
+ onLoad={() => setIsImageLoading(false)}
+ className={cn(
+ "absolute inset-0 h-full w-full object-cover transition-all duration-300 group-hover:scale-105",
+ isImageLoading ? "opacity-0" : "opacity-100",
+ )}
/>
{
diff --git a/src/features/home/components/SortDropdown.tsx b/src/features/home/components/SortDropdown.tsx
index 61d266e..ccf4715 100644
--- a/src/features/home/components/SortDropdown.tsx
+++ b/src/features/home/components/SortDropdown.tsx
@@ -21,11 +21,13 @@ export type SortOption =
interface SortDropdownProps {
currentSort: SortOption;
onSortChange: (sort: SortOption) => void;
+ isDisabled?: boolean;
}
export const SortDropdown = ({
currentSort,
onSortChange,
+ isDisabled = false,
}: SortDropdownProps) => {
const getSortLabel = () => {
switch (currentSort) {
@@ -51,7 +53,12 @@ export const SortDropdown = ({
return (
-
+
{getSortLabel()}
diff --git a/src/pages/Chapter.tsx b/src/pages/Chapter.tsx
index 48173a2..1bb75e4 100644
--- a/src/pages/Chapter.tsx
+++ b/src/pages/Chapter.tsx
@@ -6,6 +6,7 @@ import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-inte
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Button } from "@/components/ui/button";
import { useReadingTracker } from "@/features/chapter/hooks/useReadingTracker.ts";
+import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
const Chapter = () => {
const { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker();
@@ -109,6 +110,14 @@ const Chapter = () => {
}
}, [infiniteScroll]);
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
if (!data?.data) {
return (
@@ -125,6 +134,25 @@ const Chapter = () => {
}
const images = data.data.contentImageKeys;
+ const previousContentId = data.data.previousContentId;
+ const nextContentId = data.data.nextContentId;
+
+ const goToNextChapter = () => {
+ if (nextContentId) {
+ setCurrentPage(1);
+ setVisibleCount(1);
+ window.scrollTo({ top: 0 });
+ navigate(`/manga/${mangaId}/chapter/${nextContentId}`);
+ }
+ };
+ const goToPreviousChapter = () => {
+ if (previousContentId) {
+ setCurrentPage(1);
+ setVisibleCount(1);
+ window.scrollTo({ top: 0 });
+ navigate(`/manga/${mangaId}/chapter/${previousContentId}`);
+ }
+ };
/** Standard navigation (non-infinite mode) */
const goToNextPage = () => {
@@ -171,10 +199,33 @@ const Chapter = () => {
{data.data.mangaTitle}
-
- Chapter {chapterNumber}
+
+ {chapterNumber}
+
+ {previousContentId && (
+
+
+ Prev
+
+ )}
+ {nextContentId && (
+
+ Next
+
+
+ )}
@@ -202,7 +253,7 @@ const Chapter = () => {
{data.data.mangaTitle}
- Chapter {chapterNumber}
+ {chapterNumber}
@@ -224,6 +275,36 @@ const Chapter = () => {
{/* LOAD MORE SENTINEL */}
+
+ {/* CHAPTER NAVIGATION (infinite scroll mode) */}
+ {(previousContentId || nextContentId) && (
+
+ {previousContentId ? (
+
+
+ Previous
+
+ ) : (
+
+ )}
+ {nextContentId ? (
+
+ Next
+
+
+ ) : (
+
+ )}
+
+ )}
) : (
/* ------------------------------------------------------------------ */
@@ -234,8 +315,8 @@ const Chapter = () => {
{
+
+ {/* CHAPTER NAVIGATION */}
+ {(previousContentId || nextContentId) && (
+
+ {previousContentId ? (
+
+
+ Previous
+
+ ) : (
+
+ )}
+ {nextContentId ? (
+
+ Next
+
+
+ ) : (
+
+ )}
+
+ )}
>
)}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 97a586e..c935e49 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,14 +1,17 @@
-import { BookOpen, Search } from "lucide-react";
+import { BookOpen, PanelLeftOpen, Search } from "lucide-react";
import { useEffect, useRef } from "react";
import { useDebounce } from "use-debounce";
import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
import { AuthHeader } from "@/components/AuthHeader.tsx";
+import { Button } from "@/components/ui/button.tsx";
import { Pagination } from "@/components/Pagination.tsx";
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Input } from "@/components/ui/input.tsx";
+import { Skeleton } from "@/components/ui/skeleton.tsx";
import { useUIState } from "@/contexts/UIStateContext.tsx";
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
+import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
import { SortDropdown } from "@/features/home/components/SortDropdown.tsx";
import { useDynamicPageSize } from "@/hooks/useDynamicPageSize.ts";
@@ -33,11 +36,18 @@ const Home = () => {
setSortOption,
searchText,
setSearchText,
+ isSidebarOpen,
+ setIsSidebarOpen,
} = useUIState();
const [debouncedSearchText] = useDebounce(searchText, 500);
- const { data: mangasData, queryKey: mangasQueryKey } = useGetMangas({
+ const {
+ data: mangasData,
+ queryKey: mangasQueryKey,
+ isPending,
+ isFetching,
+ } = useGetMangas({
page: currentPage - 1,
size: itemsPerPage,
sort: ["id"],
@@ -47,6 +57,7 @@ const Home = () => {
userFavorites,
score: minRating,
});
+ const isFiltersDisabled = isFetching;
const startSearchRef = useRef(debouncedSearchText);
@@ -62,20 +73,24 @@ const Home = () => {
return (
-
+ {isSidebarOpen && (
+
setIsSidebarOpen(false)}
+ isDisabled={isFiltersDisabled}
+ />
+ )}
-
+
@@ -86,12 +101,31 @@ const Home = () => {
MangaMochi
-
- {mangasData?.data?.totalElements} titles available
-
+
+ {isFetching ? (
+
+ ) : (
+ <>
+
+ {mangasData?.data?.totalElements ?? 0} titles available
+
+ >
+ )}
+
+ {!isSidebarOpen && (
+
setIsSidebarOpen(true)}
+ className="gap-2"
+ >
+
+ Filters
+
+ )}
@@ -107,15 +141,18 @@ const Home = () => {
onChange={(e) => {
setSearchText(e.target.value);
}}
- className="pl-10 bg-card border-border"
+ disabled={isFiltersDisabled}
+ className="pl-10 bg-card border-border disabled:opacity-50"
/>
-
- {mangasData?.data?.content && mangasData.data.content.length > 0 ? (
+
+ {isPending ? (
+
+ ) : mangasData?.data?.content && mangasData.data.content.length > 0 ? (
<>
{mangasData.data?.totalElements && (
@@ -130,6 +167,7 @@ const Home = () => {
)}
@@ -149,7 +187,7 @@ const Home = () => {
)}
>
) : (
-
+
No manga found matching your filters.
diff --git a/src/pages/Manga.tsx b/src/pages/Manga.tsx
index ee1028a..4b150c0 100644
--- a/src/pages/Manga.tsx
+++ b/src/pages/Manga.tsx
@@ -14,7 +14,7 @@ import { useCallback } from "react";
import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { useGetManga } from "@/api/generated/catalog/catalog.ts";
-import {useFetchAllContentImages, useFetchContentProviderContentList} from "@/api/generated/ingestion/ingestion.ts";
+import { useFetchAllContentImages, useFetchContentProviderContentList } from "@/api/generated/ingestion/ingestion.ts";
import {
useFollowManga,
useSetFavorite,
@@ -35,6 +35,8 @@ import { useUIState } from "@/contexts/UIStateContext.tsx";
import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
import { useScrollPersistence } from "@/hooks/useScrollPersistence.ts";
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
const Manga = () => {
const { isAuthenticated } = useAuth();
@@ -44,7 +46,7 @@ const Manga = () => {
const queryClient = useQueryClient();
- const { data: mangaData, queryKey } = useGetManga(mangaId);
+ const { data: mangaData, queryKey, isLoading } = useGetManga(mangaId);
const { mutate, isPending: fetchPending } =
useFetchContentProviderContentList({
@@ -122,7 +124,85 @@ const Manga = () => {
[mangaData?.data?.id, mutateUnfollow, mutateFollow],
);
- if (!mangaData) {
+ if (isLoading) {
+ return (
+
+ {/* Header */}
+
+
+
+
+
navigate("/")}
+ className="gap-2"
+ >
+
+ Back to Library
+
+
MangaMochi
+
+
+
+
+
+
+ {/* Content Shell */}
+
+
+
+ {/* Cover Skeleton */}
+
+
+ {/* Details Skeleton */}
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+
+ );
+ }
+
+ if (!mangaData?.data) {
return (
@@ -174,8 +254,8 @@ const Manga = () => {
src={
(mangaData.data?.coverImageKey &&
import.meta.env.VITE_OMV_BASE_URL +
- "/" +
- mangaData.data?.coverImageKey) ||
+ "/" +
+ mangaData.data?.coverImageKey) ||
"/placeholder.svg"
}
alt={mangaData.data?.title ?? ""}
@@ -212,7 +292,13 @@ const Manga = () => {
}}
disabled={isPendingFollowChange}
>
- {mangaData?.data?.following ?
:
}
+ {isPendingFollowChange ? (
+
+ ) : mangaData?.data?.following ? (
+
+ ) : (
+
+ )}
{
}}
disabled={isPendingFavoriteChange}
>
-
+ ) : (
+
+ }`}
+ />
+ )}
>
)}
@@ -285,7 +374,7 @@ const Manga = () => {
Chapters
{mangaData.data?.chapterCount &&
- mangaData.data?.chapterCount > 0
+ mangaData.data?.chapterCount > 0
? mangaData.data?.chapterCount
: "-"}
@@ -349,11 +438,18 @@ const Manga = () => {
toggleProvider(provider.id ?? -1)}
+ onOpenChange={() => {
+ if (provider.chaptersAvailable > 0) {
+ toggleProvider(provider.id ?? -1);
+ }
+ }}
>
-
-
-
+
+ 0 ? "cursor-pointer" : "cursor-default"}`}
+ >
+
@@ -366,48 +462,71 @@ const Manga = () => {
- {provider.supportsChapterFetch && provider.active && (
-
- 0}
- onClick={() =>
- fetchAllMutate({
- mangaContentProviderId: provider.id?? -1,
- })
- }
- className="gap-2"
- >
-
- Fetch all from Provider
-
+
+
+
+ {provider.supportsChapterFetch && provider.active && (
+
+ {provider.chaptersAvailable > 0 && (
+ {
+ e.stopPropagation();
+ fetchAllMutate({
+ mangaContentProviderId: provider.id ?? -1,
+ });
+ }}
+ className="gap-2"
+ >
+ {fetchAllPending ? (
+
+ ) : (
+
+ )}
+ Fetch all from Provider
+
+ )}
+ onClick={(e) => {
+ e.stopPropagation();
mutate({
mangaContentProviderId: provider.id ?? -1,
- })
- }
+ });
+ }}
className="gap-2"
>
-
+ {isPending ? (
+
+ ) : (
+
+ )}
Fetch list from Provider
- )}
-
-
+ 0
+ ? "cursor-pointer"
+ : "invisible"
+ }
+ >
+
-
-
+ }`}
+ />
+
+
+