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 -

+
+ +

+ Filters +

+
{hasActiveFilters && (
@@ -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 ( - 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 && ( + + )} + {nextContentId && ( + + )}
@@ -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 ? ( + + ) : ( +
+ )} + {nextContentId ? ( + + ) : ( +
+ )} +
+ )}
) : ( /* ------------------------------------------------------------------ */ @@ -234,8 +315,8 @@ const Chapter = () => { {`Page {
+ + {/* CHAPTER NAVIGATION */} + {(previousContentId || nextContentId) && ( +
+ {previousContentId ? ( + + ) : ( +
+ )} + {nextContentId ? ( + + ) : ( +
+ )} +
+ )}
)} 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 && ( + + )}
@@ -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 */} +
+
+
+
+ +

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 ? ( + + ) : ( + + )} )} @@ -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 && ( -
- +
+ + + {provider.supportsChapterFetch && provider.active && ( +
+ {provider.chaptersAvailable > 0 && ( + + )}
- )} -
- +
0 + ? "cursor-pointer" + : "invisible" + } + > + - - + }`} + /> +
+
+