Files
writer-work-flow/apps/web/components/projects/ProjectLibrary.tsx

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>
);
}