feat!: use elysia js intead of koa				#18
		
		
	
					 7 changed files with 299 additions and 292 deletions
				
			
		| 
						 | 
				
			
			@ -16,7 +16,6 @@ import SettingPage from "@/page/settingPage.tsx";
 | 
			
		|||
import ComicPage from "@/page/reader/comicPage.tsx";
 | 
			
		||||
import DifferencePage from "./page/differencePage.tsx";
 | 
			
		||||
import TagsPage from "./page/tagsPage.tsx";
 | 
			
		||||
import TaskQueuePage from "./page/taskQueuePage.tsx";
 | 
			
		||||
 | 
			
		||||
const App = () => {
 | 
			
		||||
	const { isDarkMode } = useTernaryDarkMode();
 | 
			
		||||
| 
						 | 
				
			
			@ -43,7 +42,6 @@ const App = () => {
 | 
			
		|||
					<Route path="/doc/:id/reader" component={ComicPage}/>
 | 
			
		||||
					<Route path="/difference" component={DifferencePage}/>
 | 
			
		||||
					<Route path="/tags" component={TagsPage}/>
 | 
			
		||||
					<Route path="/queue" component={TaskQueuePage} />
 | 
			
		||||
					<Route component={NotFoundPage} />
 | 
			
		||||
				</Switch>
 | 
			
		||||
			</Layout>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -55,29 +55,35 @@ export function AppearanceCard() {
 | 
			
		|||
                <RadioGroup 
 | 
			
		||||
                    value={ternaryDarkMode} 
 | 
			
		||||
                    onValueChange={(v) => setTernaryDarkMode(v as TernaryDarkMode)}
 | 
			
		||||
                    className="flex space-x-2 items-center"
 | 
			
		||||
                    className="grid grid-cols-1 sm:grid-cols-3 gap-4"
 | 
			
		||||
                >
 | 
			
		||||
                    <RadioGroupItem id="dark" value="dark" className="sr-only" />
 | 
			
		||||
                    <Label htmlFor="dark">
 | 
			
		||||
                        <div className="grid place-items-center">
 | 
			
		||||
                            <DarkModeView />
 | 
			
		||||
                            <span>Dark Mode</span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </Label>
 | 
			
		||||
                    <RadioGroupItem id="light" value="light" className="sr-only" />
 | 
			
		||||
                    <Label htmlFor="light">
 | 
			
		||||
                        <div className="grid place-items-center">
 | 
			
		||||
                            <LightModeView />
 | 
			
		||||
                            <span>Light Mode</span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </Label>
 | 
			
		||||
                    <RadioGroupItem id="system" value="system" className="sr-only" />
 | 
			
		||||
                    <Label htmlFor="system">
 | 
			
		||||
                        <div className="grid place-items-center">
 | 
			
		||||
                            {isSystemDarkMode ? <DarkModeView /> : <LightModeView />}
 | 
			
		||||
                            <span>System Mode</span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </Label>
 | 
			
		||||
                    <div className="relative">
 | 
			
		||||
                        <RadioGroupItem id="dark" value="dark" className="sr-only" />
 | 
			
		||||
                        <Label htmlFor="dark" className="cursor-pointer">
 | 
			
		||||
                            <div className="grid place-items-center space-y-2 p-2 rounded-lg hover:bg-accent/50 transition-colors">
 | 
			
		||||
                                <DarkModeView />
 | 
			
		||||
                                <span className="text-sm font-medium">Dark Mode</span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </Label>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div className="relative">
 | 
			
		||||
                        <RadioGroupItem id="light" value="light" className="sr-only" />
 | 
			
		||||
                        <Label htmlFor="light" className="cursor-pointer">
 | 
			
		||||
                            <div className="grid place-items-center space-y-2 p-2 rounded-lg hover:bg-accent/50 transition-colors">
 | 
			
		||||
                                <LightModeView />
 | 
			
		||||
                                <span className="text-sm font-medium">Light Mode</span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </Label>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div className="relative">
 | 
			
		||||
                        <RadioGroupItem id="system" value="system" className="sr-only" />
 | 
			
		||||
                        <Label htmlFor="system" className="cursor-pointer">
 | 
			
		||||
                            <div className="grid place-items-center space-y-2 p-2 rounded-lg hover:bg-accent/50 transition-colors">
 | 
			
		||||
                                {isSystemDarkMode ? <DarkModeView /> : <LightModeView />}
 | 
			
		||||
                                <span className="text-sm font-medium">System Mode</span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </Label>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </RadioGroup>
 | 
			
		||||
            </CardContent>
 | 
			
		||||
        </Card>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,206 +0,0 @@
 | 
			
		|||
import { useState, useEffect } from 'react'
 | 
			
		||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import { Progress } from "@/components/ui/progress"
 | 
			
		||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
 | 
			
		||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
 | 
			
		||||
 | 
			
		||||
interface Task {
 | 
			
		||||
  id: string;
 | 
			
		||||
  status: "Processed" | "Processing" | "Queued" | "Exception";
 | 
			
		||||
  createdAt: Date;
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  expectedProgress: number;
 | 
			
		||||
  currentJobDescription: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const generateMockTasks = (): Task[] => {
 | 
			
		||||
  const statuses: Task['status'][] = ["Processed", "Processing", "Queued", "Exception"];
 | 
			
		||||
  return Array.from({ length: 50 }, (_, i) => ({
 | 
			
		||||
    id: `task-${i + 1}`,
 | 
			
		||||
    status: statuses[Math.floor(Math.random() * statuses.length)],
 | 
			
		||||
    createdAt: new Date(Date.now() - Math.random() * 10000000000),
 | 
			
		||||
    title: `Task ${i + 1}`,
 | 
			
		||||
    description: `This is a description for Task ${i + 1}`,
 | 
			
		||||
    expectedProgress: Math.random(),
 | 
			
		||||
    currentJobDescription: `Current job for Task ${i + 1}`
 | 
			
		||||
  }));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function DynamicWorkQueue() {
 | 
			
		||||
  const [tasks, setTasks] = useState<Task[]>([])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setTasks(generateMockTasks());
 | 
			
		||||
 | 
			
		||||
    const intervalId = setInterval(() => {
 | 
			
		||||
      setTasks(prevTasks => {
 | 
			
		||||
        let newTasks: Task[] = prevTasks;
 | 
			
		||||
        // if processed tasks are more than 10, remove the oldest one
 | 
			
		||||
        if (newTasks.filter(task => task.status === "Processed").length > 10) {
 | 
			
		||||
          newTasks = newTasks.filter(task => task.status !== "Processed");
 | 
			
		||||
        }
 | 
			
		||||
        // update the progress of each task
 | 
			
		||||
        newTasks = newTasks.map(task => ({
 | 
			
		||||
          ...task,
 | 
			
		||||
          expectedProgress: Math.min(1, task.expectedProgress + Math.random() * 0.2),
 | 
			
		||||
          status: task.expectedProgress >= 1 ? "Processed" : task.status
 | 
			
		||||
        }));
 | 
			
		||||
        // if there are no queued tasks, add a new one
 | 
			
		||||
        if (newTasks.filter(task => task.status === "Queued").length === 0) {
 | 
			
		||||
          newTasks.push({
 | 
			
		||||
            id: `task-${newTasks.length + 1}`,
 | 
			
		||||
            status: "Queued",
 | 
			
		||||
            createdAt: new Date(),
 | 
			
		||||
            title: `Task ${newTasks.length + 1}`,
 | 
			
		||||
            description: `This is a description for Task ${newTasks.length + 1}`,
 | 
			
		||||
            expectedProgress: 0,
 | 
			
		||||
            currentJobDescription: `Current job for Task ${newTasks.length + 1}`
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
        return newTasks;
 | 
			
		||||
      });
 | 
			
		||||
    }, 5000);
 | 
			
		||||
 | 
			
		||||
    return () => clearInterval(intervalId);
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  const updateTaskStatus = (taskId: string, newStatus: Task['status']) => {
 | 
			
		||||
    setTasks(prevTasks =>
 | 
			
		||||
      prevTasks.map(task =>
 | 
			
		||||
        task.id === taskId ? { ...task, status: newStatus } : task
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const renderTaskList = (status: Task['status']) => {
 | 
			
		||||
    const filteredTasks = tasks.filter(task => task.status === status)
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        {filteredTasks.length === 0 ? (
 | 
			
		||||
          <p className="text-gray-500 p-4">No tasks</p>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <ul className="space-y-4 p-4">
 | 
			
		||||
            {filteredTasks.map(task => (
 | 
			
		||||
              <li key={task.id} className="border p-4 rounded-md shadow-sm">
 | 
			
		||||
                <h4 className="font-medium text-primary">{task.title}</h4>
 | 
			
		||||
                <p className="text-sm text-gray-600">{task.description}</p>
 | 
			
		||||
                <p className="text-sm text-gray-500 mt-1">Created: {task.createdAt.toLocaleString()}</p>
 | 
			
		||||
                <div className="mt-2">
 | 
			
		||||
                  <Progress value={task.expectedProgress * 100} className="h-2" />
 | 
			
		||||
                  <p className="text-xs text-right mt-1">{Math.round(task.expectedProgress * 100)}%</p>
 | 
			
		||||
                </div>
 | 
			
		||||
                <p className="text-sm mt-1 text-gray-700">{task.currentJobDescription}</p>
 | 
			
		||||
                {status === "Queued" && (
 | 
			
		||||
                  <Button
 | 
			
		||||
                    onClick={() => updateTaskStatus(task.id, "Processing")}
 | 
			
		||||
                    className="mt-2"
 | 
			
		||||
                    size="sm"
 | 
			
		||||
                  >
 | 
			
		||||
                    Start Processing
 | 
			
		||||
                  </Button>
 | 
			
		||||
                )}
 | 
			
		||||
                {status === "Processing" && (
 | 
			
		||||
                  <Button
 | 
			
		||||
                    onClick={() => updateTaskStatus(task.id, "Processed")}
 | 
			
		||||
                    className="mt-2"
 | 
			
		||||
                    size="sm"
 | 
			
		||||
                  >
 | 
			
		||||
                    Mark as Processed
 | 
			
		||||
                  </Button>
 | 
			
		||||
                )}
 | 
			
		||||
              </li>
 | 
			
		||||
            ))}
 | 
			
		||||
          </ul>
 | 
			
		||||
        )}
 | 
			
		||||
      </>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const renderProcessedTaskSummary = () => {
 | 
			
		||||
    const processedTasks = tasks.filter(task => task.status === "Processed");
 | 
			
		||||
    const totalProcessed = processedTasks.length;
 | 
			
		||||
    const averageProgress = processedTasks.reduce((sum, task) => sum + task.expectedProgress, 0) / totalProcessed || 0;
 | 
			
		||||
 | 
			
		||||
    const chartData = [
 | 
			
		||||
      { name: 'Processed', value: totalProcessed },
 | 
			
		||||
      { name: 'Remaining', value: tasks.length - totalProcessed },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="space-y-4">
 | 
			
		||||
        <div className="grid grid-cols-2 gap-4">
 | 
			
		||||
          <Card>
 | 
			
		||||
            <CardHeader>
 | 
			
		||||
              <CardTitle>Total Processed</CardTitle>
 | 
			
		||||
            </CardHeader>
 | 
			
		||||
            <CardContent>
 | 
			
		||||
              <p className="text-4xl font-bold">{totalProcessed}</p>
 | 
			
		||||
            </CardContent>
 | 
			
		||||
          </Card>
 | 
			
		||||
          <Card>
 | 
			
		||||
            <CardHeader>
 | 
			
		||||
              <CardTitle>Average Progress</CardTitle>
 | 
			
		||||
            </CardHeader>
 | 
			
		||||
            <CardContent>
 | 
			
		||||
              <p className="text-4xl font-bold">{(averageProgress * 100).toFixed(2)}%</p>
 | 
			
		||||
            </CardContent>
 | 
			
		||||
          </Card>
 | 
			
		||||
        </div>
 | 
			
		||||
        <Card>
 | 
			
		||||
          <CardHeader>
 | 
			
		||||
            <CardTitle>Processed vs Remaining Tasks</CardTitle>
 | 
			
		||||
          </CardHeader>
 | 
			
		||||
          <CardContent>
 | 
			
		||||
            <ResponsiveContainer width="100%" height={300}>
 | 
			
		||||
              <BarChart data={chartData}>
 | 
			
		||||
                <XAxis dataKey="name" />
 | 
			
		||||
                <YAxis />
 | 
			
		||||
                <Tooltip />
 | 
			
		||||
                <Bar dataKey="value" fill="#8884d8" />
 | 
			
		||||
              </BarChart>
 | 
			
		||||
            </ResponsiveContainer>
 | 
			
		||||
          </CardContent>
 | 
			
		||||
        </Card>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container mx-auto p-4 max-w-4xl">
 | 
			
		||||
      <Card>
 | 
			
		||||
        <CardHeader>
 | 
			
		||||
          <CardTitle>Dynamic Work Queue</CardTitle>
 | 
			
		||||
        </CardHeader>
 | 
			
		||||
        <CardContent>
 | 
			
		||||
          <Tabs defaultValue="Queued" className="w-full">
 | 
			
		||||
            <TabsList className="grid w-full grid-cols-5">
 | 
			
		||||
              <TabsTrigger value="Queued">Queued ({tasks.filter(t => t.status === "Queued").length})</TabsTrigger>
 | 
			
		||||
              <TabsTrigger value="Processing">Processing ({tasks.filter(t => t.status === "Processing").length})</TabsTrigger>
 | 
			
		||||
              <TabsTrigger value="Processed">Processed ({tasks.filter(t => t.status === "Processed").length})</TabsTrigger>
 | 
			
		||||
              <TabsTrigger value="Exception">Exception ({tasks.filter(t => t.status === "Exception").length})</TabsTrigger>
 | 
			
		||||
              <TabsTrigger value="Summary">Summary</TabsTrigger>
 | 
			
		||||
            </TabsList>
 | 
			
		||||
            <TabsContent value="Queued">
 | 
			
		||||
              {renderTaskList("Queued")}
 | 
			
		||||
            </TabsContent>
 | 
			
		||||
            <TabsContent value="Processing">
 | 
			
		||||
              {renderTaskList("Processing")}
 | 
			
		||||
            </TabsContent>
 | 
			
		||||
            <TabsContent value="Processed">
 | 
			
		||||
              {renderTaskList("Processed")}
 | 
			
		||||
            </TabsContent>
 | 
			
		||||
            <TabsContent value="Exception">
 | 
			
		||||
              {renderTaskList("Exception")}
 | 
			
		||||
            </TabsContent>
 | 
			
		||||
            <TabsContent value="Summary">
 | 
			
		||||
              {renderProcessedTaskSummary()}
 | 
			
		||||
            </TabsContent>
 | 
			
		||||
          </Tabs>
 | 
			
		||||
        </CardContent>
 | 
			
		||||
      </Card>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,49 +1,48 @@
 | 
			
		|||
import { useLayoutEffect, useState } from "react";
 | 
			
		||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "../ui/resizable";
 | 
			
		||||
import { NavList } from "./nav";
 | 
			
		||||
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { SidebarNav, BottomNav, SidebarToggle } from "./nav";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
interface LayoutProps {
 | 
			
		||||
    children?: React.ReactNode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Layout({ children }: LayoutProps) {
 | 
			
		||||
    const MIN_SIZE_IN_PIXELS = 70;
 | 
			
		||||
    const [minSize, setMinSize] = useState(MIN_SIZE_IN_PIXELS);
 | 
			
		||||
    const [isSidebarOpen, setIsSidebarOpen] = useState(true);
 | 
			
		||||
 | 
			
		||||
    useLayoutEffect(() => {
 | 
			
		||||
        const panelGroup = document.querySelector('[data-panel-group-id="main"]');
 | 
			
		||||
        const resizeHandles = document.querySelectorAll(
 | 
			
		||||
            "[data-panel-resize-handle-id]"
 | 
			
		||||
        );
 | 
			
		||||
        if (!panelGroup || !resizeHandles) return;
 | 
			
		||||
        const observer = new ResizeObserver(() => {
 | 
			
		||||
            let width = panelGroup?.clientWidth;
 | 
			
		||||
            if (!width) return;
 | 
			
		||||
            width -= [...resizeHandles].reduce((acc, resizeHandle) => acc + resizeHandle.clientWidth, 0);
 | 
			
		||||
            // Minimum size in pixels is a percentage of the PanelGroup's height,
 | 
			
		||||
            // less the (fixed) height of the resize handles.
 | 
			
		||||
            setMinSize((MIN_SIZE_IN_PIXELS / width) * 100);
 | 
			
		||||
        });
 | 
			
		||||
        observer.observe(panelGroup);
 | 
			
		||||
        for (const resizeHandle of resizeHandles) {
 | 
			
		||||
            observer.observe(resizeHandle);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return () => {
 | 
			
		||||
            observer.disconnect();
 | 
			
		||||
        };
 | 
			
		||||
    }, []);
 | 
			
		||||
    const toggleSidebar = () => {
 | 
			
		||||
        setIsSidebarOpen(!isSidebarOpen);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <ResizablePanelGroup direction="horizontal" id="main">
 | 
			
		||||
            <ResizablePanel minSize={minSize} collapsible maxSize={minSize}>
 | 
			
		||||
                <NavList />
 | 
			
		||||
            </ResizablePanel>
 | 
			
		||||
            <ResizableHandle withHandle className="z-20" />
 | 
			
		||||
            <ResizablePanel >
 | 
			
		||||
        <div className="flex flex-col md:flex-row relative">
 | 
			
		||||
            {/* Desktop Sidebar - 데스크탑에서만 보이는 사이드바 */}
 | 
			
		||||
            <aside className={cn("hidden md:flex md:flex-col",
 | 
			
		||||
                "transition-all duration-300 ease-in-out",
 | 
			
		||||
                "border-r bg-background sticky top-0 h-screen",
 | 
			
		||||
                isSidebarOpen ? 'w-64' : 'w-16')}>
 | 
			
		||||
                <div className="flex items-center justify-between p-4 border-b">
 | 
			
		||||
                    {isSidebarOpen && (
 | 
			
		||||
                        <h2 className="text-lg font-semibold">Ionian</h2>
 | 
			
		||||
                    )}
 | 
			
		||||
                    <SidebarToggle
 | 
			
		||||
                        isOpen={isSidebarOpen}
 | 
			
		||||
                        onToggle={toggleSidebar}
 | 
			
		||||
                    />
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className="flex-1 overflow-y-auto">
 | 
			
		||||
                    <SidebarNav isCollapsed={!isSidebarOpen} />
 | 
			
		||||
                </div>
 | 
			
		||||
            </aside>
 | 
			
		||||
 | 
			
		||||
            {/* Main Content */}
 | 
			
		||||
            <main className="flex-1 flex flex-col min-h-0 pb-16 md:pb-0 pt-0 md:pt-0">
 | 
			
		||||
                {children}
 | 
			
		||||
            </ResizablePanel>
 | 
			
		||||
        </ResizablePanelGroup>
 | 
			
		||||
            </main>
 | 
			
		||||
 | 
			
		||||
            {/* Mobile Bottom Navigation - 모바일에서만 보이는 하단 네비게이션 */}
 | 
			
		||||
            <div className="md:hidden fixed bottom-0 left-0 right-0 z-30">
 | 
			
		||||
                <BottomNav />
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,10 +1,11 @@
 | 
			
		|||
import { Link } from "wouter"
 | 
			
		||||
import { SearchIcon, SettingsIcon, TagsIcon, ArchiveIcon, UserIcon, LayoutListIcon } 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"
 | 
			
		||||
import { useLogin } from "@/state/user.ts";
 | 
			
		||||
import { useNavItems } from "./navAtom";
 | 
			
		||||
import { Separator } from "../ui/separator";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
interface NavItemProps {
 | 
			
		||||
    icon: React.ReactNode;
 | 
			
		||||
| 
						 | 
				
			
			@ -65,15 +66,241 @@ export function NavList() {
 | 
			
		|||
 | 
			
		||||
    return <aside className="h-dvh flex flex-col">
 | 
			
		||||
        <nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1">
 | 
			
		||||
            {navItems && <>{navItems} <Separator/> </>}
 | 
			
		||||
            {navItems && <>{navItems} <Separator /> </>}
 | 
			
		||||
            <NavItem icon={<SearchIcon className="h-5 w-5" />} to="/search" name="Search" />
 | 
			
		||||
            <NavItem icon={<TagsIcon className="h-5 w-5" />} to="/tags" name="Tags" />
 | 
			
		||||
            <NavItem icon={<ArchiveIcon className="h-5 w-5" />} to="/difference" name="Difference" />
 | 
			
		||||
            <NavItem icon={<LayoutListIcon className="h-5 w-5" />} to="/queue" name="Task Queue" />
 | 
			
		||||
        </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"} />
 | 
			
		||||
            <NavItem icon={<UserIcon className="h-5 w-5" />} to={loginInfo ? "/profile" : "/login"} name={loginInfo ? "Profiles" : "Login"} />
 | 
			
		||||
            <NavItem icon={<SettingsIcon className="h-5 w-5" />} to="/setting" name="Settings" />
 | 
			
		||||
        </nav>
 | 
			
		||||
    </aside>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 사이드바 토글 버튼
 | 
			
		||||
export function SidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) {
 | 
			
		||||
    return (
 | 
			
		||||
        <Tooltip>
 | 
			
		||||
            <TooltipTrigger asChild>
 | 
			
		||||
                <Button
 | 
			
		||||
                    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>
 | 
			
		||||
                </Button>
 | 
			
		||||
            </TooltipTrigger>
 | 
			
		||||
            <TooltipContent side="right">
 | 
			
		||||
                {isOpen ? "Close sidebar" : "Open sidebar"}
 | 
			
		||||
            </TooltipContent>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 모바일용 사이드바 토글 버튼
 | 
			
		||||
export function MobileSidebarToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) {
 | 
			
		||||
    return (
 | 
			
		||||
        <Button
 | 
			
		||||
            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>
 | 
			
		||||
        </Button>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 데스크탑용 사이드바 네비게이션
 | 
			
		||||
export function SidebarNav({ isCollapsed, onNavigate }: { isCollapsed: boolean; onNavigate?: () => void }) {
 | 
			
		||||
    const loginInfo = useLogin();
 | 
			
		||||
    const navItems = useNavItems();
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <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>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 사이드바 네비게이션 아이템
 | 
			
		||||
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() {
 | 
			
		||||
    const loginInfo = useLogin();
 | 
			
		||||
    const navItems = useNavItems();
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <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">
 | 
			
		||||
                <BottomNavItem
 | 
			
		||||
                    icon={<SearchIcon className="h-5 w-5" />}
 | 
			
		||||
                    to="/search"
 | 
			
		||||
                    name="Search"
 | 
			
		||||
                    className="flex-1"
 | 
			
		||||
                />
 | 
			
		||||
                {navItems ? navItems : <>
 | 
			
		||||
                    <BottomNavItem
 | 
			
		||||
                        icon={<TagsIcon className="h-5 w-5" />}
 | 
			
		||||
                        to="/tags"
 | 
			
		||||
                        name="Tags"
 | 
			
		||||
                        className="flex-1"
 | 
			
		||||
                    />
 | 
			
		||||
                    <BottomNavItem
 | 
			
		||||
                        icon={<ArchiveIcon className="h-5 w-5" />}
 | 
			
		||||
                        to="/difference"
 | 
			
		||||
                        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>
 | 
			
		||||
        </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>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -24,18 +24,14 @@ export interface ContentInfoPageProps {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function Wrapper({ children }: { children: React.ReactNode }) {
 | 
			
		||||
    const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
    const [pathname] = useLocation();
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (ref.current) {
 | 
			
		||||
            ref.current.scrollTo({
 | 
			
		||||
                top: 0,
 | 
			
		||||
                left: 0,
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        document.scrollingElement?.scrollTo({
 | 
			
		||||
            top: 0,
 | 
			
		||||
        });
 | 
			
		||||
    }, [pathname]);
 | 
			
		||||
 | 
			
		||||
    return <div className="p-4 overflow-auto h-dvh" ref={ref}>
 | 
			
		||||
    return <div className="p-4">
 | 
			
		||||
        {children}
 | 
			
		||||
    </div>;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,13 +0,0 @@
 | 
			
		|||
import { lazy, Suspense } from 'react';
 | 
			
		||||
 | 
			
		||||
const DynamicWorkQueue = lazy(() => import('@/components/gallery/WorkQueue'));
 | 
			
		||||
 | 
			
		||||
export default function TaskQueuePage() {
 | 
			
		||||
    return (
 | 
			
		||||
        <div>
 | 
			
		||||
            <Suspense fallback={<div>Loading...</div>}>
 | 
			
		||||
                <DynamicWorkQueue />
 | 
			
		||||
            </Suspense>
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		
		Reference in a new issue