All files / src/pages projects.tsx

100% Statements 57/57
97.36% Branches 37/38
100% Functions 22/22
100% Lines 53/53

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310                                                                                                          1x                   34x 34x   1x         81x 81x 81x 81x 81x 81x 81x   81x     81x 81x   81x 81x       81x   81x 2x     2x 1x 1x 1x     2x     81x 6x 6x 6x           81x 34x   121x 121x 121x         81x 65x 65x 9x   65x 65x 27x   108x                   81x   81x 2x                           79x                                                                           27x                     2x 2x                           150x         6x                                                         210x                                                 24x                                     1x              
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { LayoutGrid, List, Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { ProjectCard } from "../components/projects/ProjectCard";
import { ProjectCreationWizard } from "../components/projects/ProjectCreationWizard";
import { ProjectEmptyState } from "../components/projects/ProjectEmptyState";
import { ProjectFacets } from "../components/projects/ProjectFacets";
import { ProjectListItem } from "../components/projects/ProjectListItem";
import type { ProjectStatus } from "../components/projects/project-status";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import { Skeleton } from "../components/ui/skeleton";
import {
	Table,
	TableBody,
	TableCaption,
	TableHead,
	TableHeader,
	TableRow,
} from "../components/ui/table";
import { useProjectsWithPhotoCount } from "../hooks/queries/useProjectQueries";
import { usePublishReadiness } from "../hooks/queries/usePublishReadiness";
import { useLocalitiesByCity } from "../hooks/queries/useTaxonomyQueries";
import { useSortable } from "../hooks/useSortable";
import type { Project } from "../lib/api";
import { usePro } from "../lib/pro-context";
import { queryKeys } from "../lib/query-keys";
import { cn } from "../lib/utils";
 
/**
 * The portfolio list — `.context/mockups/projects-list.html` (plan §1.5).
 *
 * Facets are `.label` CODES WITH COUNTS, not pills and not a popover
 * multi-select: `ALL 12  LIVE 8  DRAFT 3  ARCHIVED 1`. Single-select, and
 * All really means all — the old default was `{draft, published}`, which is
 * why an archived project had no page it appeared on and its unarchive action
 * was unreachable from anywhere.
 *
 * Project actions are NOT here. They live in the detail page's `[ ⋯ ]` menu,
 * complete at every status, which is where the mockup puts them.
 *
 * WHY `useProjectsWithPhotoCount` AND NOT `useProjects`
 * The photo figure is on the chip and in the table, and the no-photos chip —
 * the one state that gets an action — needs to know the count is zero rather
 * than infer it from a missing cover. The counted variant costs one correlated
 * subquery (images only, which is the publish bar's own definition) and skips
 * the API's KV cache for this list; `prefetchProjects()` warms this key so the
 * route loader still lands the page with data in hand.
 */
 
const SORT_OPTIONS = [
	{ value: "dateCreated-desc", label: "Newest" },
	{ value: "dateCreated-asc", label: "Oldest" },
	{ value: "photoCount-desc", label: "Most photos" },
	{ value: "title-asc", label: "A – Z" },
] as const;
 
type ViewMode = "grid" | "list";
 
function readStoredViewMode(): ViewMode {
	try {
		return (localStorage.getItem("projects-view-mode") as ViewMode) || "grid";
	} catch {
		return "grid";
	}
}
 
export function ProjectsPage() {
	const { proId, pro, isLoading: proLoading } = usePro();
	const navigate = useNavigate();
	const queryClient = useQueryClient();
	const [searchQuery, setSearchQuery] = useState("");
	const [statusFilter, setStatusFilter] = useState<"" | ProjectStatus>("");
	const [wizardOpen, setWizardOpen] = useState(false);
	const [viewMode, setViewMode] = useState<ViewMode>(readStoredViewMode);
 
	const { data: projects = [], isLoading } = useProjectsWithPhotoCount(proId);
	// `photoMinimum` is server-owned (PROJECT_PHOTO_MINIMUM). The chip reads it
	// rather than printing "three".
	const { data: readiness } = usePublishReadiness(proId);
	const { data: localities = [] } = useLocalitiesByCity(pro?.cityId ?? null);
 
	const localityNames = useMemo(
		() => new Map(localities.map((l) => [l.id, l.name])),
		[localities],
	);
 
	const openCreateModal = () => setWizardOpen(true);
 
	const handleWizardComplete = (projectId: string) => {
		setWizardOpen(false);
		// Invalidate immediately, then again after a short delay to catch the
		// async cover image denormalization (runs in waitUntil on the API).
		if (proId) {
			queryClient.invalidateQueries({ queryKey: queryKeys.projects.lists() });
			setTimeout(() => {
				queryClient.invalidateQueries({ queryKey: queryKeys.projects.lists() });
			}, 1500);
		}
		navigate({ to: "/projects/$projectId", params: { projectId } });
	};
 
	const handleViewModeChange = (mode: ViewMode) => {
		setViewMode(mode);
		try {
			localStorage.setItem("projects-view-mode", mode);
		} catch {
			// A browser with storage denied still gets the toggle, just not the memory.
		}
	};
 
	const counts = useMemo(
		() => ({
			all: projects.length,
			draft: projects.filter((p) => p.status === "draft").length,
			published: projects.filter((p) => p.status === "published").length,
			archived: projects.filter((p) => p.status === "archived").length,
		}),
		[projects],
	);
 
	const filteredProjects = useMemo(() => {
		let result = projects;
		if (statusFilter) {
			result = result.filter((p) => p.status === statusFilter);
		}
		const q = searchQuery.trim().toLowerCase();
		if (!q) return result;
		return result.filter(
			(p) =>
				p.title?.toLowerCase().includes(q) ||
				p.description?.toLowerCase().includes(q),
		);
	}, [projects, statusFilter, searchQuery]);
 
	const {
		sorted: sortedProjects,
		sortKey,
		sortOrder,
		setSort,
	} = useSortable(filteredProjects, "dateCreated" as keyof Project, "desc");
 
	if (proLoading || isLoading) {
		return (
			<div className="space-y-4">
				<Skeleton className="h-8 w-40" />
				<Skeleton className="h-px w-full" />
				<Skeleton className="h-8 w-72" />
				<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
					<Skeleton className="h-64 w-full" />
					<Skeleton className="h-64 w-full" />
					<Skeleton className="h-64 w-full" />
				</div>
			</div>
		);
	}
 
	return (
		<>
			<div>
				<div className="flex flex-wrap items-center justify-between gap-3">
					{/* Page titles cap at ~1.375rem; the display ramp is for empty
					    states and auth. `text-xl` is the token under that cap. */}
					<h1 className="font-heading text-xl font-bold text-foreground-default">
						Projects
					</h1>
					<Button onClick={openCreateModal}>
						<Plus aria-hidden="true" />
						Add project
					</Button>
				</div>
 
				{/* The page rule: once per page, carrying this screen's one
				    3px index-red inset bar. */}
				<Separator strong marked className="my-4" />
 
				{projects.length > 0 && (
					<div className="flex flex-wrap items-center gap-2">
						<ProjectFacets
							value={statusFilter}
							onChange={setStatusFilter}
							counts={counts}
						/>
 
						<div className="ml-auto flex flex-wrap items-center gap-2">
							<div className="relative">
								<Search
									aria-hidden="true"
									className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-foreground-subtle"
								/>
								<Input
									type="search"
									aria-label="Search projects"
									placeholder="Search"
									value={searchQuery}
									onChange={(e) => setSearchQuery(e.target.value)}
									className="h-8 w-44 pl-8"
								/>
							</div>
 
							<Select
								aria-label="Sort projects"
								tier="dense"
								className="w-auto"
								value={`${String(sortKey)}-${sortOrder}`}
								onChange={(e) => {
									const [key, order] = e.target.value.split("-");
									setSort(key as keyof Project, order as "asc" | "desc");
								}}
								options={SORT_OPTIONS}
							/>
 
							{/* fieldset, not div role="group" — see ProjectFacets.tsx */}
							<fieldset className="m-0 flex min-w-0 items-center gap-0.5 border-0 p-0">
								<legend className="sr-only">View</legend>
								{(
									[
										["grid", "Grid view", LayoutGrid],
										["list", "List view", List],
									] as const
								).map(([mode, label, Icon]) => (
									<button
										key={mode}
										type="button"
										aria-pressed={viewMode === mode}
										title={label}
										onClick={() => handleViewModeChange(mode)}
										className={cn(
											"inline-flex size-8 items-center justify-center rounded-sm transition-colors",
											"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
											viewMode === mode
												? "bg-background-muted text-foreground-default"
												: "text-foreground-muted hover:bg-foreground-default/7",
										)}
									>
										<Icon aria-hidden="true" className="size-4" />
										<span className="sr-only">{label}</span>
									</button>
								))}
							</fieldset>
						</div>
					</div>
				)}
 
				<div className="mt-5">
					{sortedProjects.length === 0 && projects.length > 0 ? (
						<p className="py-12 text-center text-foreground-muted">
							No projects match your{" "}
							{searchQuery.trim() ? "search" : "filter"}.
						</p>
					) : sortedProjects.length === 0 ? (
						<ProjectEmptyState onCreateProject={openCreateModal} />
					) : viewMode === "grid" ? (
						<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
							{sortedProjects.map((project) => (
								<ProjectCard
									key={project.id}
									project={project}
									photoMinimum={readiness?.photoMinimum}
								/>
							))}
						</div>
					) : (
						<div className="overflow-hidden rounded-lg bg-background-elevated shadow-card">
							<Table density="dense">
								<TableCaption>
									{sortedProjects.length} portfolio{" "}
									{sortedProjects.length === 1 ? "project" : "projects"}
								</TableCaption>
								<TableHeader>
									<TableRow>
										<TableHead>Status</TableHead>
										<TableHead>Project</TableHead>
										<TableHead>Locality</TableHead>
										<TableHead numeric>Photos</TableHead>
										<TableHead numeric>Updated</TableHead>
									</TableRow>
								</TableHeader>
								<TableBody>
									{sortedProjects.map((project) => (
										<ProjectListItem
											key={project.id}
											project={project}
											localityName={
												project.localityId
													? localityNames.get(project.localityId)
													: null
											}
										/>
									))}
								</TableBody>
							</Table>
						</div>
					)}
				</div>
			</div>
 
			<ProjectCreationWizard
				isOpen={wizardOpen}
				onClose={() => setWizardOpen(false)}
				onComplete={handleWizardComplete}
				proId={proId ?? ""}
			/>
		</>
	);
}