Refactor GalleryCard to include a skeleton loading state

This commit is contained in:
monoid 2024-10-18 03:23:36 +09:00
parent e06787fb3f
commit a99b62a229
5 changed files with 114 additions and 61 deletions

View File

@ -5,6 +5,7 @@ import { Fragment, useLayoutEffect, useRef, useState } from "react";
import { LazyImage } from "./LazyImage.tsx"; import { LazyImage } from "./LazyImage.tsx";
import StyledLink from "./StyledLink.tsx"; import StyledLink from "./StyledLink.tsx";
import React from "react"; import React from "react";
import { Skeleton } from "../ui/skeleton.tsx";
function clipTagsWhenOverflow(tags: string[], limit: number) { function clipTagsWhenOverflow(tags: string[], limit: number) {
let l = 0; let l = 0;
@ -87,4 +88,27 @@ function GalleryCardImpl({
</Card>; </Card>;
} }
export function GalleryCardSkeleton({
tagCount = 20
}: {
tagCount?: number;
}) {
return <Card className="flex h-[200px]">
<Skeleton className="rounded-xl overflow-hidden h-[200px] w-[142px] flex-none" />
<div className="flex-1 flex flex-col">
<CardHeader className="flex-none">
<Skeleton className="line-clamp-2 w-1/2 h-4" />
<Skeleton className="w-1/4 h-3" />
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
<ul className="flex flex-wrap gap-2 items-baseline content-start">
{Array.from({ length: tagCount }).map((_, i) => <Skeleton key={i}
style={{ width: `${Math.random() * 100 + 50}px` }}
className="h-4" />)}
</ul>
</CardContent>
</div>
</Card>
}
export const GalleryCard = React.memo(GalleryCardImpl); export const GalleryCard = React.memo(GalleryCardImpl);

View File

@ -63,7 +63,7 @@ export function NavList() {
const loginInfo = useLogin(); const loginInfo = useLogin();
const navItems = useNavItems(); const navItems = useNavItems();
return <aside className="h-dvh flex flex-col fixed"> return <aside className="h-dvh flex flex-col">
<nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1"> <nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1">
{navItems && <>{navItems} <Separator/> </>} {navItems && <>{navItems} <Separator/> </>}
<NavItem icon={<SearchIcon className="h-5 w-5" />} to="/search" name="Search" /> <NavItem icon={<SearchIcon className="h-5 w-5" />} to="/search" name="Search" />

View File

@ -2,11 +2,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { useGalleryDoc, useGalleryDocSimilar, useRehashDoc } from "../hook/useGalleryDoc.ts"; import { useGalleryDoc, useGalleryDocSimilar, useRehashDoc } from "../hook/useGalleryDoc.ts";
import TagBadge from "@/components/gallery/TagBadge"; import TagBadge from "@/components/gallery/TagBadge";
import StyledLink from "@/components/gallery/StyledLink"; import StyledLink from "@/components/gallery/StyledLink";
import { Link } from "wouter"; import { Link, useLocation } from "wouter";
import { classifyTags } from "../lib/classifyTags.tsx"; import { classifyTags } from "../lib/classifyTags.tsx";
import { DescTagItem, DescItem } from "../components/gallery/DescItem.tsx"; import { DescTagItem, DescItem } from "../components/gallery/DescItem.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { GalleryCard } from "@/components/gallery/GalleryCard.tsx"; import { GalleryCard } from "@/components/gallery/GalleryCard.tsx";
import { useEffect, useRef } from "react";
export interface ContentInfoPageProps { export interface ContentInfoPageProps {
params: { params: {
@ -14,12 +15,33 @@ export interface ContentInfoPageProps {
}; };
} }
function Wrapper({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [pathname] = useLocation();
useEffect(() => {
if (ref.current) {
ref.current.scrollTo({
top: 0,
left: 0,
});
}
}, [pathname]);
return <div className="p-4 overflow-auto h-dvh" ref={ref}>
{children}
</div>;
}
export function ContentInfoPage({ params }: ContentInfoPageProps) { export function ContentInfoPage({ params }: ContentInfoPageProps) {
const { data, error, isLoading } = useGalleryDoc(params.id); const { data, error, isLoading } = useGalleryDoc(params.id);
const rehashDoc = useRehashDoc(); const rehashDoc = useRehashDoc();
if (isLoading) { if (isLoading) {
return <div className="p-4">Loading...</div> return <div className="p-4 flex items-center justify-center h-full">
<span className="animate-pulse text-4xl">
Loading...
</span>
</div>
} }
if (error) { if (error) {
@ -36,7 +58,7 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
const contentLocation = `/doc/${params.id}/reader`; const contentLocation = `/doc/${params.id}/reader`;
return ( return (
<div className="p-4"> <Wrapper>
<Link to={contentLocation}> <Link to={contentLocation}>
<div className="m-auto h-[400px] mb-4 flex justify-center items-center flex-none bg-[#272733] <div className="m-auto h-[400px] mb-4 flex justify-center items-center flex-none bg-[#272733]
rounded-xl shadow-lg overflow-hidden"> rounded-xl shadow-lg overflow-hidden">
@ -48,10 +70,10 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
</Link> </Link>
<Card className="flex-1 relative"> <Card className="flex-1 relative">
<div className="absolute top-0 right-0 p-2"> <div className="absolute top-0 right-0 p-2">
<Button variant="ghost" onClick={async () => { <Button variant="ghost" onClick={async () => {
// Rehash // Rehash
await rehashDoc(params.id); await rehashDoc(params.id);
}}>Rehash</Button> }}>Rehash</Button>
</div> </div>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
@ -85,7 +107,7 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
</CardContent> </CardContent>
</Card> </Card>
<SimilarContentCard id={params.id} /> <SimilarContentCard id={params.id} />
</div> </Wrapper>
); );
} }
@ -93,7 +115,7 @@ function SimilarContentCard({
id, id,
}: { }: {
id: string; id: string;
}){ }) {
const { data, error, isLoading } = useGalleryDocSimilar(id); const { data, error, isLoading } = useGalleryDocSimilar(id);
if (isLoading) { if (isLoading) {
@ -112,9 +134,9 @@ function SimilarContentCard({
<div className="space-y-4 mt-4 mx-2"> <div className="space-y-4 mt-4 mx-2">
<h2 className="text-2xl font-bold">Contents with Similar Tags</h2> <h2 className="text-2xl font-bold">Contents with Similar Tags</h2>
<div className="grid gap-4 grid-cols-[repeat(auto_fill_300px)]"> <div className="grid gap-4 grid-cols-[repeat(auto_fill_300px)]">
{data.map((doc) => ( {data.map((doc) => (
<GalleryCard key={doc.id} doc={doc} /> <GalleryCard key={doc.id} doc={doc} />
))} ))}
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,6 @@
import { useLocation, useSearch } from "wouter"; import { useLocation, useSearch } from "wouter";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { GalleryCard } from "@/components/gallery/GalleryCard.tsx"; import { GalleryCard, GalleryCardSkeleton } from "@/components/gallery/GalleryCard.tsx";
import TagBadge from "@/components/gallery/TagBadge.tsx"; import TagBadge from "@/components/gallery/TagBadge.tsx";
import { useSearchGalleryInfinite } from "../hook/useSearchGallery.ts"; import { useSearchGalleryInfinite } from "../hook/useSearchGallery.ts";
import { Spinner } from "../components/Spinner.tsx"; import { Spinner } from "../components/Spinner.tsx";
@ -35,8 +35,6 @@ export default function Gallery() {
overscan: 1, overscan: 1,
}); });
const virtualItems = virtualizer.getVirtualItems(); const virtualItems = virtualizer.getVirtualItems();
useEffect(() => { useEffect(() => {
const lastItems = virtualItems.slice(-1); const lastItems = virtualItems.slice(-1);
@ -53,19 +51,56 @@ export default function Gallery() {
virtualizer.measure(); virtualizer.measure();
}, [virtualizer, data]); }, [virtualizer, data]);
if (isLoading) {
return <div className="p-4">Loading...</div> const renderContent = () => {
} if (!data) {
if (error) { return null;
return <div className="p-4">Error: {String(error)}</div> }
} const isLoadingMore = data && size > 0 && (data[size - 1] === undefined);
if (!data) { const NoResult = data.reduce((acc, x) => acc + x.data.length, 0) === 0;
return <div className="p-4">No data</div>
if (NoResult) {
return <div className="p-4 text-3xl">No results</div>
}
else {
return <div className="w-full relative overflow-auto h-full"
style={{ height: virtualizer.getTotalSize() }}>
{// TODO: date based grouping
virtualItems.map((item) => {
const isLoaderRow = item.index === size - 1 && isLoadingMore;
if (isLoaderRow) {
return <div key={item.index} className="w-full flex justify-center top-0 left-0 absolute"
style={{
height: `${item.size}px`,
transform: `translateY(${item.start}px)`
}}>
<Spinner />
</div>;
}
const docs = data[item.index];
if (!docs) return null;
return <div className="w-full grid gap-2 content-start top-0 left-0 absolute mb-2" key={item.index}
style={{
height: `${item.size}px`,
transform: `translateY(${item.start}px)`
}}>
{docs.startCursor && <div>
<h3 className="text-3xl">Start with {docs.startCursor}</h3>
<Separator />
</div>}
{docs?.data?.map((x) => {
return (
<GalleryCard doc={x} key={x.id} />
);
})}
</div>
})
}
</div>
}
} }
return (<div className="p-4 grid gap-2 h-dvh overflow-auto items-start content-start" ref={parentRef}>
const isLoadingMore = data && size > 0 && (data[size - 1] === undefined);
return (<div className="p-4 grid gap-2 overflow-auto h-dvh items-start content-start" ref={parentRef}>
<Search /> <Search />
{(word || tags) && {(word || tags) &&
<div className="bg-primary rounded-full p-1 sticky top-0 shadow-md z-10"> <div className="bg-primary rounded-full p-1 sticky top-0 shadow-md z-10">
@ -75,41 +110,13 @@ export default function Gallery() {
</ul></span>} </ul></span>}
</div> </div>
} }
{data?.length === 0 && <div className="p-4 text-3xl">No results</div>} {error && <div className="p-4">Error: {String(error)}</div>}
<div className="w-full relative" {isLoading && <>
style={{ height: virtualizer.getTotalSize() }}> <GalleryCardSkeleton />
{// TODO: date based grouping <GalleryCardSkeleton />
virtualItems.map((item) => { <GalleryCardSkeleton />
const isLoaderRow = item.index === size - 1 && isLoadingMore; </>}
if (isLoaderRow) { {renderContent()}
return <div key={item.index} className="w-full flex justify-center top-0 left-0 absolute"
style={{
height: `${item.size}px`,
transform: `translateY(${item.start}px)`
}}>
<Spinner />
</div>;
}
const docs = data[item.index];
if (!docs) return null;
return <div className="w-full grid gap-2 content-start top-0 left-0 absolute mb-2" key={item.index}
style={{
height: `${item.size}px`,
transform: `translateY(${item.start}px)`
}}>
{docs.startCursor && <div>
<h3 className="text-3xl">Start with {docs.startCursor}</h3>
<Separator />
</div>}
{docs?.data?.map((x) => {
return (
<GalleryCard doc={x} key={x.id} />
);
})}
</div>
})
}
</div>
</div> </div>
); );
} }

View File

@ -76,7 +76,7 @@ function ComicViewer({
}, [curPage, doc.id, totalPage]); }, [curPage, doc.id, totalPage]);
return ( return (
<div className="overflow-hidden w-full h-full relative"> <div className="overflow-hidden w-full h-dvh relative">
<div className="absolute left-0 w-1/2 h-full z-10" onMouseDown={() => PageDown(1)} /> <div className="absolute left-0 w-1/2 h-full z-10" onMouseDown={() => PageDown(1)} />
<img <img
ref={currentImageRef} ref={currentImageRef}