Files
writer-work-flow/apps/web/components/projects/ProjectLibrary.tsx
Yaojia Wang 40cc3fc9e3 style(ui): 收窄宽屏留白——作品库加宽到 max-w-7xl + xl 四列,写作正文 720→768px
- PageContainer wide 档 max-w-6xl→max-w-7xl;作品库改用 wide 档
- 作品库卡片网格 lg 三列基础上加 xl:grid-cols-4,宽屏铺满少留白
- 写作台正文测量 720→768px(约 42 中文字/行,仍舒适),收窄两侧空档
2026-07-12 18:39:01 +02:00

210 lines
6.6 KiB
TypeScript

"use client";
import Link from "next/link";
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { ProjectCard } from "@/components/projects/ProjectCard";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
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, focusRing } 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>
<SegmentedControl
ariaLabel="视图密度"
className="justify-self-start md:justify-self-end"
value={viewMode}
onChange={setView}
options={[
{
value: "cards",
label: (
<>
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
<span className="sr-only"></span>
</>
),
},
{
value: "compact",
label: (
<>
<List className="h-4 w-4" aria-hidden="true" />
<span className="sr-only"></span>
</>
),
},
]}
/>
</div>
<p
className="text-xs text-ink-soft"
role="status"
aria-live="polite"
>
<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 xl:grid-cols-4">
{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 NewProjectCard() {
return (
<Link
href="/projects/new"
className={cardClass({
tone: "soft",
flat: true,
interactive: true,
className: cn(
"group flex h-full min-h-[184px] flex-col items-center justify-center gap-3 border-dashed p-5 text-center",
focusRing,
),
})}
>
<span className="flex h-12 w-12 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors duration-fast ease-standard group-hover:bg-cinnabar group-hover:text-panel">
<Plus className="h-5 w-5" aria-hidden="true" />
</span>
<span className="font-serif text-title-md text-cinnabar"></span>
<span className="text-caption text-muted-soft"></span>
</Link>
);
}