223 lines
6.8 KiB
TypeScript
223 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
|
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
|
|
import { ProjectCard } from "@/components/ProjectCard";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { EmptyState } from "@/components/ui/EmptyState";
|
|
import { Select } from "@/components/ui/Select";
|
|
import { TextInput } from "@/components/ui/TextInput";
|
|
import type { ProjectResponse } from "@/lib/api/types";
|
|
import {
|
|
filterProjects,
|
|
type ProjectFilter,
|
|
type ProjectSort,
|
|
type ProjectViewMode,
|
|
} from "@/lib/projects/projects";
|
|
import { buttonClass, cardClass, cn } from "@/lib/ui/variants";
|
|
|
|
interface ProjectLibraryProps {
|
|
projects: ProjectResponse[];
|
|
}
|
|
|
|
const VIEW_STORAGE_KEY = "ww.project_view_mode";
|
|
|
|
export function ProjectLibrary({ projects }: ProjectLibraryProps) {
|
|
const [search, setSearch] = useState("");
|
|
const [filter, setFilter] = useState<ProjectFilter>("all");
|
|
const [sort, setSort] = useState<ProjectSort>("updated_at");
|
|
const [viewMode, setViewMode] = useState<ProjectViewMode>("cards");
|
|
|
|
useEffect(() => {
|
|
const saved = window.localStorage.getItem(VIEW_STORAGE_KEY);
|
|
if (saved === "cards" || saved === "compact") setViewMode(saved);
|
|
}, []);
|
|
|
|
const setView = (value: ProjectViewMode): void => {
|
|
setViewMode(value);
|
|
window.localStorage.setItem(VIEW_STORAGE_KEY, value);
|
|
};
|
|
|
|
const visibleProjects = useMemo(
|
|
() => filterProjects(projects, { search, filter, sort }),
|
|
[projects, search, filter, sort],
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div
|
|
className={cardClass(
|
|
"grid gap-3 p-3 md:grid-cols-[1fr_auto_auto_auto] md:items-center",
|
|
)}
|
|
>
|
|
<label className="relative block">
|
|
<span className="sr-only">搜索作品</span>
|
|
<Search
|
|
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-soft"
|
|
aria-hidden="true"
|
|
/>
|
|
<TextInput
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="搜索标题、题材、主题或一句话故事"
|
|
className="pl-9"
|
|
/>
|
|
</label>
|
|
<Select
|
|
aria-label="筛选作品"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value as ProjectFilter)}
|
|
>
|
|
<option value="all">全部作品</option>
|
|
<option value="pending_review">待审稿</option>
|
|
<option value="with_genre">有题材</option>
|
|
<option value="uncategorized">未归类</option>
|
|
</Select>
|
|
<Select
|
|
aria-label="排序作品"
|
|
value={sort}
|
|
onChange={(e) => setSort(e.target.value as ProjectSort)}
|
|
>
|
|
<option value="updated_at">最近编辑</option>
|
|
<option value="title">按标题</option>
|
|
<option value="genre">按题材</option>
|
|
</Select>
|
|
<div
|
|
className="inline-flex justify-self-start rounded border border-line bg-bg p-1 md:justify-self-end"
|
|
role="group"
|
|
aria-label="视图密度"
|
|
>
|
|
<ViewButton
|
|
label="卡片"
|
|
active={viewMode === "cards"}
|
|
onClick={() => setView("cards")}
|
|
>
|
|
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
|
|
</ViewButton>
|
|
<ViewButton
|
|
label="紧凑"
|
|
active={viewMode === "compact"}
|
|
onClick={() => setView("compact")}
|
|
>
|
|
<List className="h-4 w-4" aria-hidden="true" />
|
|
</ViewButton>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs text-ink-soft">
|
|
<Clock3 className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
|
|
显示 {visibleProjects.length} / {projects.length} 个作品
|
|
</p>
|
|
|
|
{visibleProjects.length === 0 ? (
|
|
<EmptyState
|
|
icon={Search}
|
|
title="没有匹配的作品"
|
|
description="换一个关键词或清空筛选,也可以直接创建一本新作品。"
|
|
action={
|
|
<div className="flex flex-wrap justify-center gap-2">
|
|
<Button
|
|
onClick={() => {
|
|
setSearch("");
|
|
setFilter("all");
|
|
}}
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
清空筛选
|
|
</Button>
|
|
<Link
|
|
href="/projects/new"
|
|
className={buttonClass({ variant: "primary", size: "sm" })}
|
|
>
|
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
|
新建作品
|
|
</Link>
|
|
</div>
|
|
}
|
|
/>
|
|
) : viewMode === "cards" ? (
|
|
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
{visibleProjects.map((p) => (
|
|
<li key={p.id}>
|
|
<ProjectCard project={p} />
|
|
</li>
|
|
))}
|
|
<li>
|
|
<NewProjectCard />
|
|
</li>
|
|
</ul>
|
|
) : (
|
|
<ul className="flex flex-col gap-2">
|
|
{visibleProjects.map((p) => (
|
|
<li key={p.id}>
|
|
<ProjectCard project={p} compact />
|
|
</li>
|
|
))}
|
|
<li>
|
|
<Link
|
|
href="/projects/new"
|
|
className={buttonClass({
|
|
variant: "outline",
|
|
className: "w-full border-dashed px-4 py-3",
|
|
})}
|
|
>
|
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
|
新建作品
|
|
</Link>
|
|
</li>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ViewButton({
|
|
active,
|
|
label,
|
|
children,
|
|
onClick,
|
|
}: {
|
|
active: boolean;
|
|
label: string;
|
|
children: ReactNode;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={label}
|
|
aria-pressed={active}
|
|
onClick={onClick}
|
|
className={cn(
|
|
"rounded px-2 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
|
active
|
|
? "bg-panel text-cinnabar shadow-paper"
|
|
: "text-ink-soft hover:text-cinnabar",
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function NewProjectCard() {
|
|
return (
|
|
<Link
|
|
href="/projects/new"
|
|
className={buttonClass({
|
|
variant: "outline",
|
|
className: "group h-full min-h-[176px] flex-col border-dashed text-center",
|
|
})}
|
|
>
|
|
<span className="mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors group-hover:bg-cinnabar group-hover:text-panel">
|
|
<Plus className="h-5 w-5" aria-hidden="true" />
|
|
</span>
|
|
<span className="font-serif text-xl text-cinnabar">新建</span>
|
|
<span className="mt-2 text-sm text-ink-soft">从一句灵感开始一本书</span>
|
|
</Link>
|
|
);
|
|
}
|