feat: improve app ui and project metadata
This commit is contained in:
212
apps/web/components/projects/ProjectLibrary.tsx
Normal file
212
apps/web/components/projects/ProjectLibrary.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"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, 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="grid gap-3 rounded border border-line bg-panel 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="flex items-center justify-center gap-2 rounded border border-dashed border-line bg-panel/70 px-4 py-3 text-sm text-cinnabar transition-colors hover:border-cinnabar hover:bg-panel"
|
||||
>
|
||||
<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="group flex h-full min-h-[176px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel/70 text-center transition-colors hover:border-cinnabar hover:bg-panel"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user