1
0
Fork 0
polaris-task-force/src/components/frontend/projects/ProjectsView.tsx

178 lines
6.3 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { FolderKanbanIcon, PlusIcon, SearchIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Item,
ItemContent,
ItemDescription,
ItemFooter,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { CreateProjectDialog } from "@/components/frontend/projects/CreateProjectDialog";
import { cn } from "@/lib/utils";
export interface ProjectListItem {
readonly id: number;
readonly name: string;
readonly key: string;
readonly status: string;
readonly lead: { id: number; displayName: string } | null;
readonly description?: string | null;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface ProjectsViewProps {
readonly projects: readonly ProjectListItem[];
readonly currentUserId: number;
readonly isAdmin: boolean;
}
const PROJECT_STATUS_STYLES: Readonly<Record<string, string>> = {
active: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
planning: "bg-sky-500/15 text-sky-400 border-sky-500/30",
on_hold: "bg-amber-500/15 text-amber-400 border-amber-500/30",
completed: "bg-violet-500/15 text-violet-400 border-violet-500/30",
archived: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
};
function labelFor(value: string): string {
return value
.replaceAll("_", " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
}
function dateFor(value: string): string {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function ProjectsView({ projects, currentUserId: _currentUserId, isAdmin }: ProjectsViewProps) {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const query = search.trim().toLowerCase();
const filteredProjects = useMemo(
() =>
projects.filter((project) =>
query
? `${project.name} ${project.key} ${project.description ?? ""}`.toLowerCase().includes(query)
: true,
),
[projects, query],
);
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Projects
</h2>
<span className="text-xs text-muted-foreground">{projects.length} total</span>
</div>
<p className="text-sm text-muted-foreground">
Plan work, track progress, and ship with your team.
</p>
</div>
{isAdmin && (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon data-icon="inline-start" />
Create project
</Button>
)}
</div>
<div className="relative w-full lg:max-w-xs">
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search projects"
className="pl-8"
placeholder="Search projects..."
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
{filteredProjects.length === 0 ? (
<Item variant="outline">
<ItemMedia variant="icon">
<FolderKanbanIcon className="text-muted-foreground" />
</ItemMedia>
<ItemContent className="items-center gap-1">
<ItemTitle>
{projects.length === 0 ? "No projects yet" : "No matching projects"}
</ItemTitle>
<ItemDescription>
{projects.length === 0
? "Create a project to start organizing your work."
: "Try a different search phrase."}
</ItemDescription>
</ItemContent>
</Item>
) : (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{filteredProjects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.key}/board`}
className="group block h-full"
>
<Item
variant="outline"
className="h-full min-h-44 items-start transition-colors group-hover:bg-accent/50"
>
<ItemHeader className="items-center">
<Badge variant="outline" className="font-mono text-[10px] tracking-wide">
{project.key}
</Badge>
<Badge
variant="outline"
className={cn(
"text-[10px] uppercase tracking-wide",
PROJECT_STATUS_STYLES[project.status] ??
"bg-muted/50 text-muted-foreground border-border",
)}
>
{labelFor(project.status)}
</Badge>
</ItemHeader>
<ItemContent className="w-full min-w-0 flex-1 gap-2">
<ItemTitle className="line-clamp-2 text-base font-semibold">
{project.name}
</ItemTitle>
{project.description && typeof project.description === "string" && (
<ItemDescription>{project.description}</ItemDescription>
)}
{!project.description && typeof project.description !== "string" && (
<ItemDescription>No description provided.</ItemDescription>
)}
</ItemContent>
<ItemFooter className="w-full flex-wrap items-end gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="min-w-0 flex-1 truncate">
Lead: {project.lead?.displayName ?? "Unassigned"}
</span>
<span className="shrink-0 font-mono text-[10px]">
Created {dateFor(project.createdAt)}
</span>
</ItemFooter>
</Item>
</Link>
))}
</div>
)}
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}