Compare commits
No commits in common. "25231b5e881c36ed00f707523e0e9aa180f04960" and "837c87fba4166e50039c52375103b7f930e03465" have entirely different histories.
25231b5e88
...
837c87fba4
9 changed files with 362 additions and 330 deletions
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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];
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
icon: <Icon className={NAV_ICON_CLASS} />,
|
|
||||||
to,
|
|
||||||
name,
|
|
||||||
className
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function useNavItemsData() {
|
export function NavItem({
|
||||||
const loginInfo = useLogin();
|
icon,
|
||||||
const isLoggedIn = Boolean(loginInfo);
|
to,
|
||||||
|
name,
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
return useMemo(() => {
|
interface NavItemButtonProps {
|
||||||
const accountItem: NavItemData = {
|
icon: React.ReactNode;
|
||||||
key: "account",
|
onClick: () => void;
|
||||||
icon: <UserIcon className={NAV_ICON_CLASS} />,
|
name: string;
|
||||||
to: isLoggedIn ? "/profile" : "/login",
|
className?: string;
|
||||||
name: isLoggedIn ? "Profiles" : "Login",
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return ({
|
export function NavItemButton({
|
||||||
main: [
|
icon,
|
||||||
createNavItem("search", "Search"),
|
onClick,
|
||||||
createNavItem("tags", "Tags"),
|
name,
|
||||||
createNavItem("difference", "Difference"),
|
className
|
||||||
],
|
}: NavItemButtonProps) {
|
||||||
footer: [
|
return <Tooltip>
|
||||||
accountItem,
|
<TooltipTrigger asChild>
|
||||||
createNavItem("settings", "Settings")
|
<Button
|
||||||
],
|
onClick={onClick}
|
||||||
bottomNav: (useCustomItems: boolean) => useCustomItems ? [] : [
|
variant="ghost"
|
||||||
createNavItem("tags", "Tags"),
|
className={className}
|
||||||
createNavItem("difference", "Diff"),
|
>
|
||||||
{ ...accountItem, name: isLoggedIn ? "Profile" : "Login" },
|
{icon}
|
||||||
createNavItem("settings", "Settings")
|
<span className="sr-only">{name}</span>
|
||||||
]
|
</Button>
|
||||||
})
|
</TooltipTrigger>
|
||||||
}, [isLoggedIn]);
|
<TooltipContent side="right">{name}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
{navItems && <>{navItems} <Separator /> </>}
|
||||||
{customNavItems && (
|
<NavItem icon={<SearchIcon className="h-5 w-5" />} to="/search" name="Search" />
|
||||||
<>
|
<NavItem icon={<TagsIcon className="h-5 w-5" />} to="/tags" name="Tags" />
|
||||||
{customNavItems()}
|
<NavItem icon={<ArchiveIcon className="h-5 w-5" />} to="/difference" name="Difference" />
|
||||||
<Separator />
|
</nav>
|
||||||
</>
|
<nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5 flex-grow-0">
|
||||||
)}
|
<NavItem icon={<UserIcon className="h-5 w-5" />} to={loginInfo ? "/profile" : "/login"} name={loginInfo ? "Profiles" : "Login"} />
|
||||||
{main.map(({ key, icon, to, name, className }) => (
|
<NavItem icon={<SettingsIcon className="h-5 w-5" />} to="/setting" name="Settings" />
|
||||||
<SidebarNavItem key={key} name={name} to={to} className={className}>
|
</nav>
|
||||||
{icon}
|
</aside>
|
||||||
</SidebarNavItem>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<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 }) => (
|
|
||||||
<SidebarNavItem key={key} name={name} to={to} className={className}>
|
|
||||||
{icon}
|
|
||||||
</SidebarNavItem>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</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 {
|
|
||||||
children: React.ReactNode;
|
|
||||||
name: string;
|
|
||||||
to?: string;
|
|
||||||
className?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarNavItem({ children, name, to, className, onClick }: SidebarNavItemProps) {
|
|
||||||
const sidebarState = useAtomValue(sidebarAtom);
|
|
||||||
const isCollapsed = sidebarState.isCollapsed;
|
|
||||||
|
|
||||||
const buttonClass = cn(
|
|
||||||
buttonVariants({ variant: "ghost", size: "sm" }),
|
|
||||||
"rounded-none md:rounded-md",
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCollapsed) {
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{name}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return linkContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데스크탑용 사이드바 네비게이션
|
// 데스크탑용 사이드바 네비게이션
|
||||||
export function SidebarNav() {
|
export function SidebarNav({ isCollapsed, onNavigate }: { isCollapsed: boolean; onNavigate?: () => void }) {
|
||||||
const customNavItems = useNavItems();
|
const loginInfo = useLogin();
|
||||||
const { main, footer } = useNavItemsData();
|
const navItems = useNavItems();
|
||||||
|
|
||||||
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-3 flex-1 min-h-0">
|
||||||
{customNavItems && (
|
{navItems && (
|
||||||
<>
|
<>
|
||||||
<div className={cn("flex flex-col gap-2")}>
|
<div className={cn("space-y-2", isCollapsed && "items-center")}>
|
||||||
{customNavItems()}
|
{navItems}
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-3" />
|
<Separator className="my-3" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{main.map(({ key, icon, to, name, className }) => (
|
<SidebarNavItem
|
||||||
<SidebarNavItem
|
icon={<SearchIcon className="h-5 w-5" />}
|
||||||
key={key}
|
to="/search"
|
||||||
name={name}
|
name="Search"
|
||||||
to={to}
|
isCollapsed={isCollapsed}
|
||||||
className={className}
|
onNavigate={onNavigate}
|
||||||
>
|
/>
|
||||||
{icon}
|
<SidebarNavItem
|
||||||
</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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t p-3 flex flex-col gap-2 flex-shrink-0">
|
<div className="border-t p-3 flex flex-col gap-2 flex-shrink-0">
|
||||||
{footer.map(({ key, icon, to, name, className }) => (
|
<SidebarNavItem
|
||||||
<SidebarNavItem
|
icon={<UserIcon className="h-5 w-5" />}
|
||||||
key={key}
|
to={loginInfo ? "/profile" : "/login"}
|
||||||
name={name}
|
name={loginInfo ? "Profiles" : "Login"}
|
||||||
to={to}
|
isCollapsed={isCollapsed}
|
||||||
className={className}
|
onNavigate={onNavigate}
|
||||||
>
|
/>
|
||||||
{icon}
|
<SidebarNavItem
|
||||||
</SidebarNavItem>
|
icon={<SettingsIcon className="h-5 w-5" />}
|
||||||
))}
|
to="/setting"
|
||||||
|
name="Settings"
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사이드바 네비게이션 아이템
|
||||||
|
interface SidebarNavItemProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
to: string;
|
||||||
|
name: string;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarNavItem({ icon, to, name, isCollapsed, onNavigate }: SidebarNavItemProps) {
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<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>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={to}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "ghost", size: "sm" }),
|
||||||
|
"justify-start gap-3 h-10 px-3"
|
||||||
|
)}
|
||||||
|
onClick={onNavigate}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{name}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 모바일용 하단 네비게이션
|
// 모바일용 하단 네비게이션
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
export const sidebarAtom = atom({ isCollapsed: false });
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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 (curPage < 0 || curPage >= totalPage) {
|
if (currentImageRef.current) {
|
||||||
return;
|
if (curPage < 0 || curPage >= totalPage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = new Image();
|
||||||
|
img.src = `/api/doc/${doc.id}/comic/${curPage}`;
|
||||||
|
if (img.complete) {
|
||||||
|
currentImageRef.current.src = img.src;
|
||||||
|
setFade(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFade(true);
|
||||||
|
const listener = () => {
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||||
|
const currentImage = currentImageRef.current!;
|
||||||
|
currentImage.src = img.src;
|
||||||
|
setFade(false);
|
||||||
|
};
|
||||||
|
img.addEventListener("load", listener);
|
||||||
|
return () => {
|
||||||
|
img.removeEventListener("load", listener);
|
||||||
|
// abort loading
|
||||||
|
img.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
||||||
|
// TODO: use web worker to abort loading image in the future
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const preloadImg = new Image();
|
|
||||||
preloadImg.src = `/api/doc/${doc.id}/comic/${curPage}`;
|
|
||||||
if (preloadImg.complete) {
|
|
||||||
setSrc(preloadImg.src);
|
|
||||||
setFade(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFade(true);
|
|
||||||
const listener = () => {
|
|
||||||
setSrc(preloadImg.src);
|
|
||||||
setFade(false);
|
|
||||||
};
|
|
||||||
preloadImg.addEventListener("load", listener);
|
|
||||||
return () => {
|
|
||||||
preloadImg.removeEventListener("load", listener);
|
|
||||||
// abort loading
|
|
||||||
preloadImg.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
|
||||||
// img 객체가 GC 되도록 함
|
|
||||||
};
|
|
||||||
|
|
||||||
}, [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"
|
||||||
|
name={isFullScreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||||
|
icon={isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
toggleFullScreen();
|
||||||
|
}} />
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<ExitIcon />
|
<span className="text-sm text-ellipsis" >{curPage + 1}/{data.additional.page as number}</span>
|
||||||
</SidebarNavItem>
|
</PopoverTrigger>
|
||||||
<SidebarNavItem
|
<PopoverContent className="w-28">
|
||||||
name={isFullScreen ? "Exit FS" : "Enter FS"}
|
<Input type="number" value={curPage + 1} onChange={(e) =>
|
||||||
onClick={toggleFullScreen}
|
setCurPage(clip(Number.parseInt(e.target.value) - 1,
|
||||||
>
|
0,
|
||||||
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
|
(data.additional.page as number) - 1))} />
|
||||||
</SidebarNavItem>
|
</PopoverContent>
|
||||||
<Popover>
|
</Popover>
|
||||||
<SidebarNavItem
|
</>}>
|
||||||
name="Page"
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<span className="text-xs truncate leading-normal">
|
|
||||||
{curPage + 1}/{data.additional.page as number}
|
|
||||||
</span>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</SidebarNavItem>
|
|
||||||
<PopoverContent className="w-28">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={curPage + 1}
|
|
||||||
onChange={(e) =>
|
|
||||||
setCurPage(clip(
|
|
||||||
Number.parseInt(e.target.value) - 1,
|
|
||||||
0,
|
|
||||||
(data.additional.page as number) - 1
|
|
||||||
))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
}>
|
|
||||||
<ComicViewer
|
<ComicViewer
|
||||||
curPage={curPage}
|
curPage={curPage}
|
||||||
onChangePage={setCurPage}
|
onChangePage={setCurPage}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Add table
Reference in a new issue