Compare commits
3 commits
25231b5e88
...
d19bb520ed
Author | SHA1 | Date | |
---|---|---|---|
d19bb520ed | |||
a20297f34e | |||
6c559a854b |
7 changed files with 195 additions and 154 deletions
8
.hintrc
Normal file
8
.hintrc
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"development"
|
||||||
|
],
|
||||||
|
"hints": {
|
||||||
|
"no-inline-styles": "off"
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import StyledLink from "./StyledLink.tsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Skeleton } from "../ui/skeleton.tsx";
|
import { Skeleton } from "../ui/skeleton.tsx";
|
||||||
import { Palette, Users, Trash2, Clock, LayersIcon } from "lucide-react";
|
import { Palette, Users, Trash2, Clock, LayersIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils.ts";
|
||||||
|
|
||||||
function clipTagsWhenOverflow(tags: string[], limit: number) {
|
function clipTagsWhenOverflow(tags: string[], limit: number) {
|
||||||
let l = 0;
|
let l = 0;
|
||||||
|
@ -22,8 +23,12 @@ function clipTagsWhenOverflow(tags: string[], limit: number) {
|
||||||
|
|
||||||
|
|
||||||
function GalleryCardImpl({
|
function GalleryCardImpl({
|
||||||
doc: x
|
doc: x,
|
||||||
}: { doc: Document; }) {
|
className,
|
||||||
|
}: {
|
||||||
|
doc: Document;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
const ref = useRef<HTMLUListElement>(null);
|
const ref = useRef<HTMLUListElement>(null);
|
||||||
const [clipCharCount, setClipCharCount] = useState(200);
|
const [clipCharCount, setClipCharCount] = useState(200);
|
||||||
const isDeleted = x.deleted_at !== null;
|
const isDeleted = x.deleted_at !== null;
|
||||||
|
@ -77,102 +82,108 @@ function GalleryCardImpl({
|
||||||
};
|
};
|
||||||
}, [originalTags]);
|
}, [originalTags]);
|
||||||
|
|
||||||
return <Card className="flex h-[200px] overflow-hidden transition-all duration-200 hover:shadow-lg hover:shadow-primary/20 group">
|
return <Card className={cn("flex flex-col sm:flex-row overflow-hidden",
|
||||||
{isDeleted ? (
|
"transition-all duration-200 hover:shadow-lg hover:shadow-primary/20 group",
|
||||||
<div className="bg-gradient-to-br from-red-500/20 to-red-800/30 flex items-center justify-center h-[200px] w-[142px] rounded-l-xl border-r border-border/50">
|
className
|
||||||
<div className="flex flex-col items-center gap-2 text-primary-foreground">
|
)}>
|
||||||
<Trash2 className="h-8 w-8 opacity-80" />
|
{isDeleted ? (
|
||||||
<span className="text-sm font-medium">Deleted</span>
|
<div className="h-[240px] sm:h-[200px] w-full sm:w-[142px]
|
||||||
</div>
|
bg-gradient-to-br from-red-500/20 to-red-800/30
|
||||||
|
flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-primary-foreground">
|
||||||
|
<Trash2 className="size-8 opacity-80" />
|
||||||
|
<span className="text-sm font-medium">Deleted</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="relative rounded-l-xl overflow-hidden h-[200px] w-[142px] flex-none bg-gradient-to-br from-primary/5 to-primary/10 flex items-center justify-center group-hover:from-primary/10 group-hover:to-primary/20 transition-all duration-300">
|
|
||||||
<LazyImage
|
|
||||||
src={`/api/doc/${x.id}/comic/thumbnail`}
|
|
||||||
alt={x.title}
|
|
||||||
className="max-h-full max-w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1 opacity-80">
|
|
||||||
<LayersIcon className="h-3 w-3" />
|
|
||||||
<span>{x.pagenum}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<CardHeader className="flex-none">
|
|
||||||
<CardTitle className="group-hover:text-primary transition-colors duration-200">
|
|
||||||
<StyledLink className="line-clamp-2 font-bold" to={`/doc/${x.id}`}>
|
|
||||||
{x.title}
|
|
||||||
</StyledLink>
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 leading-tight text-sm">
|
|
||||||
{artists.length > 0 && (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Palette className="size-3.5 text-primary/70" />
|
|
||||||
<span className="flex flex-wrap items-center">
|
|
||||||
{artists.map((x, i) => (
|
|
||||||
<Fragment key={`artist:${x}`}>
|
|
||||||
<StyledLink
|
|
||||||
to={`/search?allow_tag=artist:${x}`}
|
|
||||||
className="hover:text-primary hover:underline transition-colors"
|
|
||||||
>
|
|
||||||
{x}
|
|
||||||
</StyledLink>
|
|
||||||
{i + 1 < artists.length && <span className="opacity-50 mx-1">,</span>}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{groups.length > 0 && (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Users className="size-3.5 text-primary/70" />
|
|
||||||
<span className="flex flex-wrap items-center">
|
|
||||||
{groups.map((x, i) => (
|
|
||||||
<Fragment key={`group:${x}`}>
|
|
||||||
<StyledLink
|
|
||||||
to={`/search?allow_tag=group:${x}`}
|
|
||||||
className="hover:text-primary hover:underline transition-colors"
|
|
||||||
>
|
|
||||||
{x}
|
|
||||||
</StyledLink>
|
|
||||||
{i + 1 < groups.length && <span className="opacity-50 mx-1">,</span>}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Clock className="size-3.5" />
|
|
||||||
<span className="text-xs">{new Date(x.created_at).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex-1 overflow-hidden">
|
|
||||||
<ul ref={ref} className="flex flex-wrap gap-1.5 items-baseline content-start">
|
|
||||||
{clippedTags.map(tag => (
|
|
||||||
<TagBadge
|
|
||||||
key={tag}
|
|
||||||
tagname={tag}
|
|
||||||
className="transition-all duration-200 hover:shadow-sm hover:scale-105"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{clippedTags.length < originalTags.length && (
|
|
||||||
<TagBadge
|
|
||||||
key={"..."}
|
|
||||||
tagname="..."
|
|
||||||
className="inline-block opacity-70"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
) : (
|
||||||
|
<div className="h-[240px] sm:h-[200px] w-full sm:w-[142px]
|
||||||
|
relative overflow-hidden flex-none bg-gradient-to-br from-primary/5 to-primary/10 flex items-center justify-center group-hover:from-primary/10 group-hover:to-primary/20 transition-all duration-300">
|
||||||
|
<LazyImage
|
||||||
|
src={`/api/doc/${x.id}/comic/thumbnail`}
|
||||||
|
alt={x.title}
|
||||||
|
className="max-h-full max-w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1 opacity-80">
|
||||||
|
<LayersIcon className="size-3" />
|
||||||
|
<span>{x.pagenum}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
<CardHeader className="flex-none pb-2 sm:pb-4">
|
||||||
|
<CardTitle className="group-hover:text-primary transition-colors duration-200">
|
||||||
|
<StyledLink className="line-clamp-1 font-bold sm:line-clamp-2" to={`/doc/${x.id}`}>
|
||||||
|
{x.title}
|
||||||
|
</StyledLink>
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 leading-tight text-sm">
|
||||||
|
{artists.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 sm:gap-1.5">
|
||||||
|
<Palette className="size-3 sm:size-3.5 text-primary/70" />
|
||||||
|
<span className="flex flex-wrap items-center">
|
||||||
|
{artists.map((x, i) => (
|
||||||
|
<Fragment key={`artist:${x}`}>
|
||||||
|
<StyledLink
|
||||||
|
to={`/search?allow_tag=artist:${x}`}
|
||||||
|
className="hover:text-primary hover:underline transition-colors"
|
||||||
|
>
|
||||||
|
{x}
|
||||||
|
</StyledLink>
|
||||||
|
{i + 1 < artists.length && <span className="opacity-50 mx-1">,</span>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 sm:gap-1.5">
|
||||||
|
<Users className="size-3 sm:size-3.5 text-primary/70" />
|
||||||
|
<span className="flex flex-wrap items-center">
|
||||||
|
{groups.map((x, i) => (
|
||||||
|
<Fragment key={`group:${x}`}>
|
||||||
|
<StyledLink
|
||||||
|
to={`/search?allow_tag=group:${x}`}
|
||||||
|
className="hover:text-primary hover:underline transition-colors"
|
||||||
|
>
|
||||||
|
{x}
|
||||||
|
</StyledLink>
|
||||||
|
{i + 1 < groups.length && <span className="opacity-50 mx-1">,</span>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 sm:gap-1.5 text-muted-foreground">
|
||||||
|
<Clock className="size-3 sm:size-3.5" />
|
||||||
|
<span className="text-xs">{new Date(x.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex-1 overflow-hidden pt-0">
|
||||||
|
<ul ref={ref} className="flex flex-wrap gap-1 sm:gap-1.5 items-baseline content-start">
|
||||||
|
{clippedTags.map(tag => (
|
||||||
|
<TagBadge
|
||||||
|
key={tag}
|
||||||
|
tagname={tag}
|
||||||
|
className="transition-all duration-200 hover:shadow-sm hover:scale-105"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{clippedTags.length < originalTags.length && (
|
||||||
|
<TagBadge
|
||||||
|
key={"..."}
|
||||||
|
tagname="..."
|
||||||
|
className="inline-block opacity-70"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GalleryCardSkeleton({
|
export function GalleryCardSkeleton({
|
||||||
|
@ -180,18 +191,19 @@ export function GalleryCardSkeleton({
|
||||||
}: {
|
}: {
|
||||||
tagCount?: number;
|
tagCount?: number;
|
||||||
}) {
|
}) {
|
||||||
return <Card className="flex h-[200px]">
|
return <Card className="flex flex-col sm:flex-row">
|
||||||
<Skeleton className="rounded-xl overflow-hidden h-[200px] w-[142px] flex-none" />
|
<Skeleton className="h-[240px] sm:h-[200px] w-full sm:w-[142px]
|
||||||
|
rounded-xl overflow-hidden flex-none" />
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<CardHeader className="flex-none">
|
<CardHeader className="flex-none pb-2 sm:pb-4">
|
||||||
<Skeleton className="line-clamp-2 w-1/2 h-4" />
|
<Skeleton className="line-clamp-2 w-3/4 sm:w-1/2 h-4 sm:h-5" />
|
||||||
<Skeleton className="w-1/4 h-3" />
|
<Skeleton className="w-1/2 sm:w-1/4 h-3 mt-2" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 overflow-hidden">
|
<CardContent className="flex-1 overflow-hidden pt-0">
|
||||||
<ul className="flex flex-wrap gap-2 items-baseline content-start">
|
<ul className="flex flex-wrap gap-1 sm:gap-2 items-baseline content-start">
|
||||||
{Array.from({ length: tagCount }).map((_, i) => <Skeleton key={i}
|
{Array.from({ length: tagCount }).map((_, i) => <Skeleton key={i}
|
||||||
style={{ width: `${Math.random() * 100 + 50}px` }}
|
style={{ width: `${Math.random() * 80 + 40}px` }}
|
||||||
className="h-4" />)}
|
className="h-3 sm:h-4" />)}
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -112,7 +112,7 @@ export default function TagInput({
|
||||||
)}
|
)}
|
||||||
onClick={() => inputRef.current?.focus()}
|
onClick={() => inputRef.current?.focus()}
|
||||||
>
|
>
|
||||||
<ul className="flex gap-1 flex-none">
|
<ul className="flex gap-1 flex-grow-0">
|
||||||
{tags.map((tag) => <li className={cn(
|
{tags.map((tag) => <li className={cn(
|
||||||
tagBadgeVariants({ variant: getTagKind(tag) }),
|
tagBadgeVariants({ variant: getTagKind(tag) }),
|
||||||
"cursor-pointer"
|
"cursor-pointer"
|
||||||
|
@ -121,7 +121,7 @@ export default function TagInput({
|
||||||
}}>{tag}</li>)}
|
}}>{tag}</li>)}
|
||||||
</ul>
|
</ul>
|
||||||
<input ref={inputRef} type="text" className="flex-1 border-0 ml-2 focus:border-0 focus:outline-none
|
<input ref={inputRef} type="text" className="flex-1 border-0 ml-2 focus:border-0 focus:outline-none
|
||||||
bg-transparent text-sm" placeholder="Add tag"
|
bg-transparent text-sm w-0" placeholder="Add tag"
|
||||||
onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)}
|
onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)}
|
||||||
value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => {
|
value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
|
@ -172,7 +172,7 @@ export default function TagInput({
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
tags.length > 0 && <Button variant="ghost" className="flex-none" onClick={() => {
|
tags.length > 0 && <Button variant="ghost" className="flex-grow-0" onClick={() => {
|
||||||
setTags([]);
|
setTags([]);
|
||||||
setOpenInfo(null);
|
setOpenInfo(null);
|
||||||
}}>Clear</Button>
|
}}>Clear</Button>
|
||||||
|
|
|
@ -18,11 +18,11 @@ export default function Layout({ children }: LayoutProps) {
|
||||||
"transition-all duration-300 ease-in-out",
|
"transition-all duration-300 ease-in-out",
|
||||||
"border-r bg-background sticky top-0 h-screen",
|
"border-r bg-background sticky top-0 h-screen",
|
||||||
isSidebarOpen ? 'w-64' : 'w-16')}>
|
isSidebarOpen ? 'w-64' : 'w-16')}>
|
||||||
<div className="flex items-center justify-between p-4 border-b">
|
<div className="flex items-center justify-between p-2 border-b">
|
||||||
{isSidebarOpen && (
|
{isSidebarOpen && (
|
||||||
<h2 className="text-lg font-semibold">Ionian</h2>
|
<h2 className="text-lg font-semibold ml-4">Ionian</h2>
|
||||||
)}
|
)}
|
||||||
<SidebarToggle />
|
<SidebarToggle className="size-10 p-2 ml-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<SidebarNav />
|
<SidebarNav />
|
||||||
|
@ -30,7 +30,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 flex flex-col min-h-0 pb-16 md:pb-0 pt-0 md:pt-0">
|
<main className="flex-1 flex flex-col min-h-0 p-0 md:pb-0 pb-14">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { useMemo } from "react";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { sidebarAtom } from "./sidebarAtom";
|
import { sidebarAtom } from "./sidebarAtom";
|
||||||
|
|
||||||
const NAV_ICON_CLASS = "size-4";
|
export const NAV_ICON_CLASS = "size-4";
|
||||||
|
|
||||||
const NAV_LINKS = {
|
const NAV_LINKS = {
|
||||||
search: { to: "/search", Icon: SearchIcon },
|
search: { to: "/search", Icon: SearchIcon },
|
||||||
|
@ -103,16 +103,20 @@ export function NavList() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사이드바 토글 버튼
|
// 사이드바 토글 버튼
|
||||||
export function SidebarToggle() {
|
export function SidebarToggle({
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
|
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
|
||||||
const isOpen = sidebarState.isCollapsed;
|
const isOpen = !sidebarState.isCollapsed;
|
||||||
const onToggle = () => setSidebarState((s) => ({ ...s, isCollapsed: !s.isCollapsed }));
|
const onToggle = () => setSidebarState((s) => ({ ...s, isCollapsed: !s.isCollapsed }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" onClick={onToggle} className="size-8 p-0">
|
<Button variant="ghost" size="sm" onClick={onToggle} className={className}>
|
||||||
{isOpen ? <PanelLeftCloseIcon className="size-4" /> : <PanelLeftIcon className="size-4" />}
|
{isOpen ? <PanelLeftCloseIcon className={NAV_ICON_CLASS} /> : <PanelLeftIcon className={NAV_ICON_CLASS} />}
|
||||||
<span className="sr-only">{isOpen ? "Close sidebar" : "Open sidebar"}</span>
|
<span className="sr-only">{isOpen ? "Close sidebar" : "Open sidebar"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
@ -131,7 +135,7 @@ export function MobileSidebarToggle() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="ghost" size="sm" onClick={onToggle} className="size-8 p-0">
|
<Button variant="ghost" size="sm" onClick={onToggle} className="size-8 p-0">
|
||||||
{isOpen ? <XIcon className="size-5" /> : <MenuIcon className="size-5" />}
|
{isOpen ? <XIcon className={NAV_ICON_CLASS} /> : <MenuIcon className={NAV_ICON_CLASS} />}
|
||||||
<span className="sr-only">{isOpen ? "Close menu" : "Open menu"}</span>
|
<span className="sr-only">{isOpen ? "Close menu" : "Open menu"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
@ -151,9 +155,9 @@ export function SidebarNavItem({ children, name, to, className, onClick }: Sideb
|
||||||
const isCollapsed = sidebarState.isCollapsed;
|
const isCollapsed = sidebarState.isCollapsed;
|
||||||
|
|
||||||
const buttonClass = cn(
|
const buttonClass = cn(
|
||||||
buttonVariants({ variant: "ghost", size: "sm" }),
|
buttonVariants({ variant: "ghost", size: "lg" }),
|
||||||
"rounded-none md:rounded-md",
|
"rounded-none md:rounded-md flex-1 md:min-h-10 min-h-12 touch-manipulation justify-center",
|
||||||
isCollapsed ? "justify-center size-10 p-0" : "justify-start gap-3 h-10 px-3",
|
isCollapsed ? "p-0" : "md:justify-start gap-3 px-3",
|
||||||
className
|
className
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -205,7 +209,7 @@ export function SidebarNav() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<nav className="flex flex-col gap-2 p-3 flex-1 min-h-0">
|
<nav className="flex flex-col gap-2 p-2 flex-1 min-h-0">
|
||||||
{customNavItems && (
|
{customNavItems && (
|
||||||
<>
|
<>
|
||||||
<div className={cn("flex flex-col gap-2")}>
|
<div className={cn("flex flex-col gap-2")}>
|
||||||
|
|
|
@ -6,8 +6,9 @@ import { useSearchGalleryInfinite } from "../hook/useSearchGallery.ts";
|
||||||
import { Spinner } from "../components/Spinner.tsx";
|
import { Spinner } from "../components/Spinner.tsx";
|
||||||
import TagInput from "@/components/gallery/TagInput.tsx";
|
import TagInput from "@/components/gallery/TagInput.tsx";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { SearchIcon, TagIcon, AlertCircle, ImageIcon } from "lucide-react";
|
import { SearchIcon, TagIcon, AlertCircle, ImageIcon } from "lucide-react";
|
||||||
|
import { useMediaQuery } from "usehooks-ts";
|
||||||
|
|
||||||
export default function Gallery() {
|
export default function Gallery() {
|
||||||
const search = useSearch();
|
const search = useSearch();
|
||||||
|
@ -16,21 +17,24 @@ export default function Gallery() {
|
||||||
const tags = searchParams.getAll("allow_tag") ?? undefined;
|
const tags = searchParams.getAll("allow_tag") ?? undefined;
|
||||||
const limit = searchParams.get("limit");
|
const limit = searchParams.get("limit");
|
||||||
const cursor = searchParams.get("cursor");
|
const cursor = searchParams.get("cursor");
|
||||||
|
const isSmallScreen = useMediaQuery('(max-width: 640px)');
|
||||||
const { data, error, isLoading, size, setSize } = useSearchGalleryInfinite({
|
const { data, error, isLoading, size, setSize } = useSearchGalleryInfinite({
|
||||||
word, tags,
|
word, tags,
|
||||||
limit: limit ? Number.parseInt(limit) : undefined,
|
limit: limit ? Number.parseInt(limit) : undefined,
|
||||||
cursor: cursor ? Number.parseInt(cursor) : undefined
|
cursor: cursor ? Number.parseInt(cursor) : undefined
|
||||||
});
|
});
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useWindowVirtualizer({
|
||||||
count: size,
|
count: size,
|
||||||
// biome-ignore lint/style/noNonNullAssertion: could not be null
|
scrollMargin: parentRef.current?.offsetTop ?? 0,
|
||||||
getScrollElement: () => parentRef.current!,
|
|
||||||
estimateSize: (index) => {
|
estimateSize: (index) => {
|
||||||
if (!data) return 8;
|
if (!data) return 8;
|
||||||
const docs = data?.[index];
|
const docs = data?.[index];
|
||||||
if (!docs) return 8;
|
if (!docs) return 8;
|
||||||
return docs.data.length * (200 + 8) + 32 + 8 + 8 * 2; // 200px for image, 8px for gap, 32px for title, 8px for padding
|
const cardHeight = isSmallScreen ? 409.5 : 200;
|
||||||
|
const gap = 8;
|
||||||
|
const headerHeight = docs.startCursor ? (isSmallScreen ? 44 : 56) : 0;
|
||||||
|
return docs.data.length * (cardHeight + gap) + headerHeight + gap * 2;
|
||||||
},
|
},
|
||||||
overscan: 1,
|
overscan: 1,
|
||||||
});
|
});
|
||||||
|
@ -60,44 +64,56 @@ export default function Gallery() {
|
||||||
const NoResult = data.reduce((acc, x) => acc + x.data.length, 0) === 0;
|
const NoResult = data.reduce((acc, x) => acc + x.data.length, 0) === 0;
|
||||||
|
|
||||||
if (NoResult) {
|
if (NoResult) {
|
||||||
return <div className="flex flex-col items-center justify-center p-12 text-center">
|
return <div className="flex flex-col items-center justify-center p-8 sm:p-12 text-center">
|
||||||
<ImageIcon className="w-16 h-16 text-muted-foreground mb-4 opacity-50" />
|
<ImageIcon className="w-12 h-12 sm:w-16 sm:h-16 text-muted-foreground mb-3 sm:mb-4 opacity-50" />
|
||||||
<h3 className="text-3xl font-semibold text-muted-foreground mb-2">검색 결과가 없습니다</h3>
|
<h3 className="text-xl sm:text-3xl font-semibold text-muted-foreground mb-2">검색 결과가 없습니다</h3>
|
||||||
<p className="text-muted-foreground">다른 검색어나 태그로 시도해보세요</p>
|
<p className="text-sm sm:text-base text-muted-foreground">다른 검색어나 태그로 시도해보세요</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return <div className="w-full relative"
|
return <div className="w-full relative"
|
||||||
style={{ height: virtualizer.getTotalSize() }}>
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
{// TODO: date based grouping
|
{// TODO: date based grouping
|
||||||
virtualItems.map((item) => {
|
virtualItems.map((item) => {
|
||||||
const isLoaderRow = item.index === size - 1 && isLoadingMore;
|
const isLoaderRow = item.index === size - 1 && isLoadingMore;
|
||||||
if (isLoaderRow) {
|
if (isLoaderRow) {
|
||||||
return <div key={item.index}
|
return <div key={item.index}
|
||||||
className="w-full flex justify-center top-0 left-0 absolute p-8"
|
className="w-full flex justify-center top-0 left-0 absolute p-4 sm:p-8"
|
||||||
style={{
|
style={{
|
||||||
height: `${item.size}px`,
|
height: `${item.size}px`,
|
||||||
transform: `translateY(${item.start}px)`
|
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`
|
||||||
}}>
|
}}
|
||||||
<span className="text-muted-foreground"><Spinner />컨텐츠를 불러오는 중...</span>
|
data-index={item.index}
|
||||||
|
>
|
||||||
|
<span className="text-sm sm:text-base text-muted-foreground flex items-center gap-2">
|
||||||
|
<Spinner />컨텐츠를 불러오는 중...
|
||||||
|
</span>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
const docs = data[item.index];
|
const docs = data[item.index];
|
||||||
if (!docs) return null;
|
if (!docs) return null;
|
||||||
return <div className="w-full grid gap-2 content-start top-0 left-0 absolute mb-4" key={item.index}
|
return <div className="w-full grid gap-2 content-start top-0 left-0 absolute" key={item.index}
|
||||||
style={{
|
style={{
|
||||||
height: `${item.size}px`,
|
// height: `${item.size}px`,
|
||||||
transform: `translateY(${item.start}px)`
|
transform: `translateY(${item.start}px)`
|
||||||
}}>
|
}}
|
||||||
|
data-index={item.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
>
|
||||||
{docs.startCursor && (
|
{docs.startCursor && (
|
||||||
<div className="bg-muted/50 rounded-lg p-2">
|
<div className="bg-muted/50 rounded-lg p-2 sm:p-3">
|
||||||
<h3 className="text-2xl font-medium flex items-center gap-2">
|
<h3 className="text-lg sm:text-2xl font-medium flex items-center gap-2">
|
||||||
ID <span className="text-primary">{docs.startCursor}</span>이하
|
ID <span className="text-primary">{docs.startCursor}</span>이하
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{docs?.data?.map((x) => (
|
{docs?.data?.map((x) => (
|
||||||
<GalleryCard doc={x} key={x.id} />
|
<GalleryCard doc={x} key={x.id}
|
||||||
|
className="h-[409.5px] sm:h-[200px]"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
})
|
})
|
||||||
|
@ -106,20 +122,20 @@ export default function Gallery() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<div className="p-4 grid gap-2 h-dvh overflow-auto items-start content-start" ref={parentRef}>
|
return (<div className="p-2 sm:p-4 grid gap-2 items-start content-start" ref={parentRef}>
|
||||||
<Search />
|
<Search />
|
||||||
|
|
||||||
{((word ?? "").length > 0 || tags.length > 0) &&
|
{((word ?? "").length > 0 || tags.length > 0) &&
|
||||||
<div className="bg-primary rounded-full p-2 mt-3 shadow-lg flex flex-wrap items-center gap-2">
|
<div className="bg-primary rounded-full p-2 mt-2 sm:mt-3 shadow-lg flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||||
{word && (
|
{word && (
|
||||||
<div className="flex items-center bg-primary-foreground/20 rounded-full px-3 py-1 text-primary-foreground">
|
<div className="flex items-center bg-primary-foreground/20 rounded-full px-2.5 sm:px-3 py-1 text-primary-foreground">
|
||||||
<SearchIcon className="w-4 h-4 mr-2" />
|
<SearchIcon className="size-3.5 sm:size-4 mr-1.5 sm:mr-2" />
|
||||||
<span className="font-medium">{word}</span>
|
<span className="font-medium">{word}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{tags && tags.length > 0 && (
|
{tags && tags.length > 0 && (
|
||||||
<div className="flex items-center flex-wrap gap-1">
|
<div className="flex items-center flex-wrap gap-1">
|
||||||
<TagIcon className="w-4 h-4 text-primary-foreground ml-2" />
|
<TagIcon className="size-3.5 sm:size-4 text-primary-foreground ml-1 sm:ml-2" />
|
||||||
<ul className="inline-flex flex-wrap gap-1 ml-1">
|
<ul className="inline-flex flex-wrap gap-1 ml-1">
|
||||||
{tags.map(x => <TagBadge tagname={x} key={x} />)}
|
{tags.map(x => <TagBadge tagname={x} key={x} />)}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -129,8 +145,8 @@ export default function Gallery() {
|
||||||
}
|
}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-6 bg-destructive/10 rounded-lg flex items-center">
|
<div className="p-4 sm:p-6 bg-destructive/10 rounded-lg flex items-center">
|
||||||
<AlertCircle className="w-6 h-6 text-destructive mr-2" />
|
<AlertCircle className="size-5 sm:size-6 text-destructive mr-2 flex-shrink-0" />
|
||||||
<div className="text-destructive font-medium">{String(error)}</div>
|
<div className="text-destructive font-medium">{String(error)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -154,7 +170,7 @@ function Search() {
|
||||||
const searchParams = new URLSearchParams(search);
|
const searchParams = new URLSearchParams(search);
|
||||||
const [tags, setTags] = useState(searchParams.get("allow_tag")?.split(",") ?? []);
|
const [tags, setTags] = useState(searchParams.get("allow_tag")?.split(",") ?? []);
|
||||||
const [word, setWord] = useState(searchParams.get("word") ?? "");
|
const [word, setWord] = useState(searchParams.get("word") ?? "");
|
||||||
return <div className="flex flex-col sm:flex-row gap-3">
|
return <div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
|
||||||
<TagInput
|
<TagInput
|
||||||
className="flex-1 shadow-sm"
|
className="flex-1 shadow-sm"
|
||||||
input={word}
|
input={word}
|
||||||
|
@ -177,7 +193,7 @@ function Search() {
|
||||||
navigate(`/search?${params.toString()}`);
|
navigate(`/search?${params.toString()}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SearchIcon className="w-4 h-4" />
|
<SearchIcon className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
검색
|
검색
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { useEventListener } from "usehooks-ts";
|
||||||
import type { Document } from "dbtype";
|
import type { Document } from "dbtype";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { SidebarNavItem } from "@/components/layout/nav";
|
import { NAV_ICON_CLASS, SidebarNavItem } from "@/components/layout/nav";
|
||||||
|
|
||||||
interface ComicPageProps {
|
interface ComicPageProps {
|
||||||
params: {
|
params: {
|
||||||
|
@ -150,13 +150,14 @@ export default function ComicPage({
|
||||||
name="Back"
|
name="Back"
|
||||||
to={`/doc/${params.id}`}
|
to={`/doc/${params.id}`}
|
||||||
>
|
>
|
||||||
<ExitIcon />
|
<ExitIcon className={NAV_ICON_CLASS} />
|
||||||
</SidebarNavItem>
|
</SidebarNavItem>
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
name={isFullScreen ? "Exit FS" : "Enter FS"}
|
name={isFullScreen ? "Exit FS" : "Enter FS"}
|
||||||
onClick={toggleFullScreen}
|
onClick={toggleFullScreen}
|
||||||
>
|
>
|
||||||
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
|
{isFullScreen ? <ExitFullScreenIcon className={NAV_ICON_CLASS} /> :
|
||||||
|
<EnterFullScreenIcon className={NAV_ICON_CLASS} />}
|
||||||
</SidebarNavItem>
|
</SidebarNavItem>
|
||||||
<Popover>
|
<Popover>
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
|
|
Loading…
Add table
Reference in a new issue