Compare commits

..

No commits in common. "25231b5e881c36ed00f707523e0e9aa180f04960" and "837c87fba4166e50039c52375103b7f930e03465" have entirely different histories.

9 changed files with 362 additions and 330 deletions

View file

@ -1,5 +1,5 @@
import type { Document } from "dbtype"; import type { Document } from "dbtype";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import TagBadge, { toPrettyTagname } from "@/components/gallery/TagBadge.tsx"; import TagBadge, { toPrettyTagname } from "@/components/gallery/TagBadge.tsx";
import { Fragment, useLayoutEffect, useRef, useState } from "react"; import { Fragment, useLayoutEffect, useRef, useState } from "react";
import { LazyImage } from "./LazyImage.tsx"; import { LazyImage } from "./LazyImage.tsx";
@ -106,10 +106,10 @@ function GalleryCardImpl({
{x.title} {x.title}
</StyledLink> </StyledLink>
</CardTitle> </CardTitle>
<div className="flex flex-wrap items-center gap-x-3 leading-tight text-sm"> <CardDescription className="flex flex-wrap items-center gap-x-3">
{artists.length > 0 && ( {artists.length > 0 && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Palette className="size-3.5 text-primary/70" /> <Palette className="h-3.5 w-3.5 text-primary/70" />
<span className="flex flex-wrap items-center"> <span className="flex flex-wrap items-center">
{artists.map((x, i) => ( {artists.map((x, i) => (
<Fragment key={`artist:${x}`}> <Fragment key={`artist:${x}`}>
@ -128,7 +128,7 @@ function GalleryCardImpl({
{groups.length > 0 && ( {groups.length > 0 && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Users className="size-3.5 text-primary/70" /> <Users className="h-3.5 w-3.5 text-primary/70" />
<span className="flex flex-wrap items-center"> <span className="flex flex-wrap items-center">
{groups.map((x, i) => ( {groups.map((x, i) => (
<Fragment key={`group:${x}`}> <Fragment key={`group:${x}`}>
@ -146,10 +146,10 @@ function GalleryCardImpl({
)} )}
<div className="flex items-center gap-1.5 text-muted-foreground"> <div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="size-3.5" /> <Clock className="h-3.5 w-3.5" />
<span className="text-xs">{new Date(x.created_at).toLocaleDateString()}</span> <span className="text-xs">{new Date(x.created_at).toLocaleDateString()}</span>
</div> </div>
</div> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex-1 overflow-hidden"> <CardContent className="flex-1 overflow-hidden">

View file

@ -1,6 +1,5 @@
import { useAtomValue } from "jotai"; import { useState } from "react";
import { SidebarNav, BottomNav, SidebarToggle } from "./nav"; import { SidebarNav, BottomNav, SidebarToggle } from "./nav";
import { sidebarAtom } from "./sidebarAtom";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface LayoutProps { interface LayoutProps {
@ -8,8 +7,11 @@ interface LayoutProps {
} }
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const sidebarState = useAtomValue(sidebarAtom); const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const isSidebarOpen = !sidebarState.isCollapsed;
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};
return ( return (
<div className="flex flex-col md:flex-row relative"> <div className="flex flex-col md:flex-row relative">
@ -22,10 +24,13 @@ export default function Layout({ children }: LayoutProps) {
{isSidebarOpen && ( {isSidebarOpen && (
<h2 className="text-lg font-semibold">Ionian</h2> <h2 className="text-lg font-semibold">Ionian</h2>
)} )}
<SidebarToggle /> <SidebarToggle
isOpen={isSidebarOpen}
onToggle={toggleSidebar}
/>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<SidebarNav /> <SidebarNav isCollapsed={!isSidebarOpen} />
</div> </div>
</aside> </aside>

View file

@ -1,118 +1,101 @@
import { Link } from "wouter" import { Link } from "wouter"
import { SearchIcon, SettingsIcon, TagsIcon, ArchiveIcon, UserIcon, LayoutListIcon, PanelLeftIcon, PanelLeftCloseIcon, MenuIcon, XIcon, type LucideIcon } from "lucide-react" import { SearchIcon, SettingsIcon, TagsIcon, ArchiveIcon, UserIcon, LayoutListIcon, PanelLeftIcon, PanelLeftCloseIcon, MenuIcon, XIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button.tsx" import { Button, buttonVariants } from "@/components/ui/button.tsx"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"
import { useLogin } from "@/state/user.ts"; import { useLogin } from "@/state/user.ts";
import { useNavItems } from "./navAtom"; import { useNavItems } from "./navAtom";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useMemo } from "react";
import { useAtom, useAtomValue } from "jotai";
import { sidebarAtom } from "./sidebarAtom";
const NAV_ICON_CLASS = "size-4"; interface NavItemProps {
const NAV_LINKS = {
search: { to: "/search", Icon: SearchIcon },
tags: { to: "/tags", Icon: TagsIcon },
difference: { to: "/difference", Icon: ArchiveIcon },
queue: { to: "/queue", Icon: LayoutListIcon },
settings: { to: "/setting", Icon: SettingsIcon }
} satisfies Record<string, { to: string; Icon: LucideIcon }>;
type NavLinkKey = keyof typeof NAV_LINKS;
type NavItemData = {
key: string;
icon: React.ReactNode; icon: React.ReactNode;
to?: string; to: string;
name: string; name: string;
className?: string; className?: string;
} }
const createNavItem = (key: NavLinkKey, name: string, className?: string): NavItemData => {
const { Icon, to } = NAV_LINKS[key]; export function NavItem({
return { icon,
key,
icon: <Icon className={NAV_ICON_CLASS} />,
to, to,
name, name,
className className
}; }: NavItemProps) {
}; return <Tooltip>
<TooltipTrigger asChild>
<Link
href={to}
className={buttonVariants({ variant: "ghost", className })}
>
{icon}
<span className="sr-only">{name}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{name}</TooltipContent>
</Tooltip>
}
function useNavItemsData() { interface NavItemButtonProps {
const loginInfo = useLogin(); icon: React.ReactNode;
const isLoggedIn = Boolean(loginInfo); onClick: () => void;
name: string;
className?: string;
}
return useMemo(() => { export function NavItemButton({
const accountItem: NavItemData = { icon,
key: "account", onClick,
icon: <UserIcon className={NAV_ICON_CLASS} />, name,
to: isLoggedIn ? "/profile" : "/login", className
name: isLoggedIn ? "Profiles" : "Login", }: NavItemButtonProps) {
}; return <Tooltip>
<TooltipTrigger asChild>
return ({ <Button
main: [ onClick={onClick}
createNavItem("search", "Search"), variant="ghost"
createNavItem("tags", "Tags"), className={className}
createNavItem("difference", "Difference"), >
], {icon}
footer: [ <span className="sr-only">{name}</span>
accountItem, </Button>
createNavItem("settings", "Settings") </TooltipTrigger>
], <TooltipContent side="right">{name}</TooltipContent>
bottomNav: (useCustomItems: boolean) => useCustomItems ? [] : [ </Tooltip>
createNavItem("tags", "Tags"),
createNavItem("difference", "Diff"),
{ ...accountItem, name: isLoggedIn ? "Profile" : "Login" },
createNavItem("settings", "Settings")
]
})
}, [isLoggedIn]);
} }
export function NavList() { export function NavList() {
const customNavItems = useNavItems(); const loginInfo = useLogin();
const { main, footer } = useNavItemsData(); const navItems = useNavItems();
return ( return <aside className="h-dvh flex flex-col">
<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">
{customNavItems && ( {navItems && <>{navItems} <Separator /> </>}
<> <NavItem icon={<SearchIcon className="h-5 w-5" />} to="/search" name="Search" />
{customNavItems()} <NavItem icon={<TagsIcon className="h-5 w-5" />} to="/tags" name="Tags" />
<Separator /> <NavItem icon={<ArchiveIcon className="h-5 w-5" />} to="/difference" name="Difference" />
</>
)}
{main.map(({ key, icon, to, name, className }) => (
<SidebarNavItem key={key} name={name} to={to} className={className}>
{icon}
</SidebarNavItem>
))}
</nav> </nav>
<nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5 flex-grow-0"> <nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5 flex-grow-0">
{footer.map(({ key, icon, to, name, className }) => ( <NavItem icon={<UserIcon className="h-5 w-5" />} to={loginInfo ? "/profile" : "/login"} name={loginInfo ? "Profiles" : "Login"} />
<SidebarNavItem key={key} name={name} to={to} className={className}> <NavItem icon={<SettingsIcon className="h-5 w-5" />} to="/setting" name="Settings" />
{icon}
</SidebarNavItem>
))}
</nav> </nav>
</aside> </aside>
);
} }
// 사이드바 토글 버튼 // 사이드바 토글 버튼
export function SidebarToggle() { export function SidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) {
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
const isOpen = sidebarState.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
{isOpen ? <PanelLeftCloseIcon className="size-4" /> : <PanelLeftIcon className="size-4" />} variant="ghost"
size="sm"
onClick={onToggle}
className="h-8 w-8 p-0"
>
{isOpen ? (
<PanelLeftCloseIcon className="h-4 w-4" />
) : (
<PanelLeftIcon className="h-4 w-4" />
)}
<span className="sr-only">{isOpen ? "Close sidebar" : "Open sidebar"}</span> <span className="sr-only">{isOpen ? "Close sidebar" : "Open sidebar"}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -124,158 +107,202 @@ export function SidebarToggle() {
} }
// 모바일용 사이드바 토글 버튼 // 모바일용 사이드바 토글 버튼
export function MobileSidebarToggle() { export function MobileSidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) {
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
const isOpen = sidebarState.isCollapsed;
const onToggle = () => setSidebarState((s) => ({ ...s, isCollapsed: !s.isCollapsed }));
return ( return (
<Button variant="ghost" size="sm" onClick={onToggle} className="size-8 p-0"> <Button
{isOpen ? <XIcon className="size-5" /> : <MenuIcon className="size-5" />} variant="ghost"
size="sm"
onClick={onToggle}
className="h-8 w-8 p-0"
>
{isOpen ? (
<XIcon className="h-5 w-5" />
) : (
<MenuIcon className="h-5 w-5" />
)}
<span className="sr-only">{isOpen ? "Close menu" : "Open menu"}</span> <span className="sr-only">{isOpen ? "Close menu" : "Open menu"}</span>
</Button> </Button>
); );
} }
// 사이드바 네비게이션 아이템 // 데스크탑용 사이드바 네비게이션
interface SidebarNavItemProps { export function SidebarNav({ isCollapsed, onNavigate }: { isCollapsed: boolean; onNavigate?: () => void }) {
children: React.ReactNode; const loginInfo = useLogin();
name: string; const navItems = useNavItems();
to?: string;
className?: string; return (
onClick?: () => void; <div className="flex flex-col h-full">
<nav className="flex flex-col gap-2 p-3 flex-1 min-h-0">
{navItems && (
<>
<div className={cn("space-y-2", isCollapsed && "items-center")}>
{navItems}
</div>
<Separator className="my-3" />
</>
)}
<div className="flex flex-col gap-2">
<SidebarNavItem
icon={<SearchIcon className="h-5 w-5" />}
to="/search"
name="Search"
isCollapsed={isCollapsed}
onNavigate={onNavigate}
/>
<SidebarNavItem
icon={<TagsIcon className="h-5 w-5" />}
to="/tags"
name="Tags"
isCollapsed={isCollapsed}
onNavigate={onNavigate}
/>
<SidebarNavItem
icon={<ArchiveIcon className="h-5 w-5" />}
to="/difference"
name="Difference"
isCollapsed={isCollapsed}
onNavigate={onNavigate}
/>
<SidebarNavItem
icon={<LayoutListIcon className="h-5 w-5" />}
to="/queue"
name="Task Queue"
isCollapsed={isCollapsed}
onNavigate={onNavigate}
/>
</div>
</nav>
<div className="border-t p-3 flex flex-col gap-2 flex-shrink-0">
<SidebarNavItem
icon={<UserIcon className="h-5 w-5" />}
to={loginInfo ? "/profile" : "/login"}
name={loginInfo ? "Profiles" : "Login"}
isCollapsed={isCollapsed}
onNavigate={onNavigate}
/>
<SidebarNavItem
icon={<SettingsIcon className="h-5 w-5" />}
to="/setting"
name="Settings"
isCollapsed={isCollapsed}
onNavigate={onNavigate}
/>
</div>
</div>
);
} }
export function SidebarNavItem({ children, name, to, className, onClick }: SidebarNavItemProps) { // 사이드바 네비게이션 아이템
const sidebarState = useAtomValue(sidebarAtom); interface SidebarNavItemProps {
const isCollapsed = sidebarState.isCollapsed; icon: React.ReactNode;
to: string;
const buttonClass = cn( name: string;
buttonVariants({ variant: "ghost", size: "sm" }), isCollapsed: boolean;
"rounded-none md:rounded-md", onNavigate?: () => void;
isCollapsed ? "justify-center size-10 p-0" : "justify-start gap-3 h-10 px-3", }
className
);
const Container = to ? ({ children, ref }: { children: React.ReactNode,
ref?: React.Ref<HTMLAnchorElement>
}) => (
<Link
ref={ref}
href={to} className={buttonClass}
onClick={onClick}
>
{children}
</Link>
) : ({ children, ref }: { children: React.ReactNode,
ref?: React.Ref<HTMLButtonElement>
}) => (
<button className={buttonClass} onClick={onClick} ref={ref}>
{children}
</button>
);
const linkContent = (
<Container>
{children}
<span className={cn(
"sr-only",
!isCollapsed && "text-sm truncate leading-normal md:not-sr-only"
)
}>{name}</span>
</Container>
);
function SidebarNavItem({ icon, to, name, isCollapsed, onNavigate }: SidebarNavItemProps) {
if (isCollapsed) { if (isCollapsed) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{linkContent}</TooltipTrigger> <TooltipTrigger asChild>
<Link
href={to}
className={cn(
buttonVariants({ variant: "ghost", size: "sm" }),
"justify-center h-10 w-10 p-0"
)}
onClick={onNavigate}
>
{icon}
<span className="sr-only">{name}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{name}</TooltipContent> <TooltipContent side="right">{name}</TooltipContent>
</Tooltip> </Tooltip>
); );
} }
return linkContent;
}
// 데스크탑용 사이드바 네비게이션
export function SidebarNav() {
const customNavItems = useNavItems();
const { main, footer } = useNavItemsData();
return ( return (
<div className="flex flex-col h-full"> <Link
<nav className="flex flex-col gap-2 p-3 flex-1 min-h-0"> href={to}
{customNavItems && ( className={cn(
<> buttonVariants({ variant: "ghost", size: "sm" }),
<div className={cn("flex flex-col gap-2")}> "justify-start gap-3 h-10 px-3"
{customNavItems()}
</div>
<Separator className="my-3" />
</>
)} )}
<div className="flex flex-col gap-2"> onClick={onNavigate}
{main.map(({ key, icon, to, name, className }) => (
<SidebarNavItem
key={key}
name={name}
to={to}
className={className}
> >
{icon} {icon}
</SidebarNavItem> <span>{name}</span>
))} </Link>
</div>
</nav>
<div className="border-t p-3 flex flex-col gap-2 flex-shrink-0">
{footer.map(({ key, icon, to, name, className }) => (
<SidebarNavItem
key={key}
name={name}
to={to}
className={className}
>
{icon}
</SidebarNavItem>
))}
</div>
</div>
); );
} }
// 모바일용 하단 네비게이션 // 모바일용 하단 네비게이션
export function BottomNav() { export function BottomNav() {
const customNavItems = useNavItems(); const loginInfo = useLogin();
const { main, bottomNav } = useNavItemsData(); const navItems = useNavItems();
const searchItem = { ...main[0] }; // Search item
const items = bottomNav(Boolean(customNavItems));
return ( return (
<nav className="mb-1"> <nav className="mb-1">
<div className="flex justify-around items-center max-w-md mx-auto overflow-hidden bg-background/50 backdrop-blur-md border rounded-full"> <div className="flex justify-around items-center max-w-md mx-auto
<SidebarNavItem overflow-hidden
name={searchItem.name} bg-background/50 backdrop-blur-md border rounded-full">
to={searchItem.to} <BottomNavItem
className={searchItem.className} icon={<SearchIcon className="h-5 w-5" />}
> to="/search"
{searchItem.icon} name="Search"
</SidebarNavItem> className="flex-1"
{customNavItems ? ( />
customNavItems() {navItems ? navItems : <>
) : ( <BottomNavItem
items.map(({ key, icon, to, name, className }) => icon={<TagsIcon className="h-5 w-5" />}
<SidebarNavItem to="/tags"
key={key} name="Tags"
name={name} className="flex-1"
to={to} />
className={className} <BottomNavItem
> icon={<ArchiveIcon className="h-5 w-5" />}
{icon} to="/difference"
</SidebarNavItem> name="Diff"
) className="flex-1"
)} />
<BottomNavItem
icon={<UserIcon className="h-5 w-5" />}
to={loginInfo ? "/profile" : "/login"}
name={loginInfo ? "Profile" : "Login"}
className="flex-1"
/>
<BottomNavItem
icon={<SettingsIcon className="h-5 w-5" />}
to="/setting"
name="Settings"
className="flex-1"
/>
</>}
</div> </div>
</nav> </nav>
); );
} }
// 하단 네비게이션 아이템
interface BottomNavItemProps {
icon: React.ReactNode;
to: string;
name: string;
className?: string;
}
function BottomNavItem({ icon, to, name, className }: BottomNavItemProps) {
return (
<Link
href={to}
className={cn("flex flex-col items-center gap-1 p-2 hover:bg-accent text-xs min-w-0", className)}
>
{icon}
<span className="text-xs truncate leading-normal">{name}</span>
</Link>
);
}

View file

@ -1,23 +1,27 @@
import { atom, useAtomValue, useSetAtom } from "@/lib/atom"; import { atom, useAtomValue, useSetAtom } from "@/lib/atom";
import { useLayoutEffect } from "react"; import { useLayoutEffect, useRef } from "react";
const NavItems = atom<(() => React.ReactNode) | null>(null); const NavItems = atom<React.ReactNode>(null);
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export function useNavItems() { export function useNavItems() {
return useAtomValue(NavItems); return useAtomValue(NavItems);
} }
export function PageNavItem({navRender, children}:{navRender: () => React.ReactNode, children: React.ReactNode}) { export function PageNavItem({items, children}:{items: React.ReactNode, children: React.ReactNode}) {
const currentNavItems = useAtomValue(NavItems);
const setNavItems = useSetAtom(NavItems); const setNavItems = useSetAtom(NavItems);
const prevValueRef = useRef<React.ReactNode>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
setNavItems(() => navRender); // Store current value before setting new one
prevValueRef.current = currentNavItems;
setNavItems(items);
return () => { return () => {
setNavItems(null); setNavItems(prevValueRef.current);
}; };
}, [ setNavItems, navRender]); }, [items, currentNavItems, setNavItems]);
return children; return children;
} }

View file

@ -1,3 +0,0 @@
import { atom } from "jotai";
export const sidebarAtom = atom({ isCollapsed: false });

View file

@ -67,7 +67,7 @@ ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(
([, itemConfig]) => itemConfig.theme || itemConfig.color ([_, config]) => config.theme || config.color
) )
if (!colorConfig.length) { if (!colorConfig.length) {

View file

@ -67,7 +67,7 @@ export default function Gallery() {
</div> </div>
} }
else { else {
return <div className="w-full relative" return <div className="w-full relative overflow-auto h-full"
style={{ height: virtualizer.getTotalSize() }}> style={{ height: virtualizer.getTotalSize() }}>
{// TODO: date based grouping {// TODO: date based grouping
virtualItems.map((item) => { virtualItems.map((item) => {

View file

@ -1,3 +1,4 @@
import { NavItem, NavItemButton } from "@/components/layout/nav";
import { PageNavItem } from "@/components/layout/navAtom"; import { PageNavItem } from "@/components/layout/navAtom";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -8,7 +9,6 @@ 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";
interface ComicPageProps { interface ComicPageProps {
params: { params: {
@ -30,8 +30,7 @@ function ComicViewer({
const [fade, setFade] = useState(false); const [fade, setFade] = useState(false);
const pageDown = useCallback((step: number) => setCurPage(Math.max(curPage - step, 0)), [curPage, setCurPage]); const pageDown = useCallback((step: number) => setCurPage(Math.max(curPage - step, 0)), [curPage, setCurPage]);
const pageUp = useCallback((step: number) => setCurPage(Math.min(curPage + step, totalPage - 1)), [curPage, setCurPage, totalPage]); const pageUp = useCallback((step: number) => setCurPage(Math.min(curPage + step, totalPage - 1)), [curPage, setCurPage, totalPage]);
const makeSrc = useCallback((page: number) => `/api/doc/${doc.id}/comic/${page}`, [doc.id]); const currentImageRef = useRef<HTMLImageElement>(null);
const [src, setSrc] = useState(() => makeSrc(curPage));
useEffect(() => { useEffect(() => {
const onKeyUp = (e: KeyboardEvent) => { const onKeyUp = (e: KeyboardEvent) => {
@ -49,44 +48,46 @@ function ComicViewer({
}, [pageDown, pageUp]); }, [pageDown, pageUp]);
useEffect(() => { useEffect(() => {
if (currentImageRef.current) {
if (curPage < 0 || curPage >= totalPage) { if (curPage < 0 || curPage >= totalPage) {
return; return;
} }
const preloadImg = new Image(); const img = new Image();
preloadImg.src = `/api/doc/${doc.id}/comic/${curPage}`; img.src = `/api/doc/${doc.id}/comic/${curPage}`;
if (preloadImg.complete) { if (img.complete) {
setSrc(preloadImg.src); currentImageRef.current.src = img.src;
setFade(false); setFade(false);
return; return;
} }
setFade(true); setFade(true);
const listener = () => { const listener = () => {
setSrc(preloadImg.src); // biome-ignore lint/style/noNonNullAssertion: <explanation>
const currentImage = currentImageRef.current!;
currentImage.src = img.src;
setFade(false); setFade(false);
}; };
preloadImg.addEventListener("load", listener); img.addEventListener("load", listener);
return () => { return () => {
preloadImg.removeEventListener("load", listener); img.removeEventListener("load", listener);
// abort loading // abort loading
preloadImg.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; img.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
// img 객체가 GC 되도록 함 // TODO: use web worker to abort loading image in the future
}; };
}
}, [curPage, doc.id, totalPage]); }, [curPage, doc.id, totalPage]);
return ( return (
<div className="overflow-hidden w-full h-dvh relative"> <div className="overflow-hidden w-full h-dvh relative">
<div className="absolute left-0 w-1/2 h-full z-10 select-none" onPointerDown={() => pageDown(1)} /> <div className="absolute left-0 w-1/2 h-full z-10 select-none" onMouseDown={() => pageDown(1)} />
<img <img
ref={currentImageRef}
className={cn("max-w-full max-h-full top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 absolute", className={cn("max-w-full max-h-full top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 absolute",
fade ? "opacity-70 transition-opacity duration-300 ease-in-out" : "opacity-100" fade ? "opacity-70 transition-opacity duration-300 ease-in-out" : "opacity-100"
)} )}
src={src} alt="main content" />
alt="main content" <div className="absolute right-0 w-1/2 h-full z-10 select-none" onMouseDown={() => pageUp(1)} />
/>
<div className="absolute right-0 w-1/2 h-full z-10 select-none" onPointerDown={() => pageUp(1)} />
{curPage + 1 < totalPage && ( {curPage + 1 < totalPage && (
<img src={makeSrc(curPage + 1)} alt="next page" className="sr-only" /> <img src={`/api/doc/${doc.id}/comic/${curPage + 1}`} alt="next page" className="sr-only" />
)} )}
</div> </div>
); );
@ -144,46 +145,31 @@ export default function ComicPage({
} }
return ( return (
<PageNavItem navRender={() => <PageNavItem items={<>
<> <NavItem
<SidebarNavItem className="flex-1"
name="Back" to={`/doc/${params.id}`} name="Back" icon={<ExitIcon />} />
to={`/doc/${params.id}`} <NavItemButton
> className="flex-1"
<ExitIcon /> name={isFullScreen ? "Exit Fullscreen" : "Enter Fullscreen"}
</SidebarNavItem> icon={isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
<SidebarNavItem onClick={() => {
name={isFullScreen ? "Exit FS" : "Enter FS"} toggleFullScreen();
onClick={toggleFullScreen} }} />
>
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
</SidebarNavItem>
<Popover> <Popover>
<SidebarNavItem <PopoverTrigger
name="Page" className="flex-1"
> >
<PopoverTrigger asChild> <span className="text-sm text-ellipsis" >{curPage + 1}/{data.additional.page as number}</span>
<span className="text-xs truncate leading-normal">
{curPage + 1}/{data.additional.page as number}
</span>
</PopoverTrigger> </PopoverTrigger>
</SidebarNavItem>
<PopoverContent className="w-28"> <PopoverContent className="w-28">
<Input <Input type="number" value={curPage + 1} onChange={(e) =>
type="number" setCurPage(clip(Number.parseInt(e.target.value) - 1,
value={curPage + 1}
onChange={(e) =>
setCurPage(clip(
Number.parseInt(e.target.value) - 1,
0, 0,
(data.additional.page as number) - 1 (data.additional.page as number) - 1))} />
))
}
/>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</> </>}>
}>
<ComicViewer <ComicViewer
curPage={curPage} curPage={curPage}
onChangePage={setCurPage} onChangePage={setCurPage}

View file

@ -51,6 +51,43 @@ export async function renderComicPage({ path, page, reqHeaders, set }: RenderOpt
} }
const readStream = await createReadableStreamFromZip(zip.reader, entry); const readStream = await createReadableStreamFromZip(zip.reader, entry);
const nodeReadable = new Readable({
read() {
// noop
},
});
let zipClosed = false;
const closeZip = async () => {
if (!zipClosed) {
zipClosed = true;
await zip.reader.close();
}
};
readStream.pipeTo(new WritableStream({
write(chunk) {
nodeReadable.push(chunk);
},
close() {
nodeReadable.push(null);
},
abort(err) {
nodeReadable.destroy(err);
},
})).catch((err) => {
nodeReadable.destroy(err);
});
nodeReadable.on("close", () => {
closeZip().catch(console.error);
});
nodeReadable.on("error", () => {
closeZip().catch(console.error);
});
nodeReadable.on("end", () => {
closeZip().catch(console.error);
});
const ext = entry.filename.split(".").pop()?.toLowerCase() ?? "jpeg"; const ext = entry.filename.split(".").pop()?.toLowerCase() ?? "jpeg";
headers["Content-Type"] = extensionToMime(ext); headers["Content-Type"] = extensionToMime(ext);
@ -59,31 +96,7 @@ export async function renderComicPage({ path, page, reqHeaders, set }: RenderOpt
} }
set.status = 200; set.status = 200;
return nodeReadable;
// Ensure zip file is closed after stream ends
const streamWithCleanup = new ReadableStream({
async start(controller) {
try {
const reader = readStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
controller.enqueue(value);
}
controller.close();
} catch (error) {
controller.error(error);
throw error;
} finally {
await zip.reader.close();
}
},
cancel: async () => {
await zip.reader.close();
}
});
return streamWithCleanup
} catch (error) { } catch (error) {
await zip.reader.close(); await zip.reader.close();
throw error; throw error;