polish(standalone): unify home Projects list + solid source chips

Home page feedback: merge "Your projects" (local) and the gallery/backend list
into one "Projects" section where each ROW carries its own source chip (instead
of a per-section header chip). Sections are now Open → Projects → Tools →
Libraries. Local rows keep open/export/rename/delete; remote rows just open.
LocalProjectsSection → ProjectsSection.

Source chips were too pale on the editor canvas — switch from translucent pastel
to solid fills with white text + an inset ring, so they read on any backdrop
(home, dark boot screen, light editor canvas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-20 08:53:16 +02:00
commit 4839a9d73a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
4 changed files with 172 additions and 180 deletions

View file

@ -1,120 +0,0 @@
import * as React from "react";
import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Download, Loader2, Pencil, Trash2 } from "lucide-react";
import { useLocalProjects } from "@/lib/api";
import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source";
import { SOURCE_DESCRIPTORS } from "@/lib/project-source-shared";
import { zipFiles } from "@/lib/zip";
import { Button } from "@/components/ui/button";
import { SourceChip } from "@/components/SourceChip";
/**
* The home-page list of browser-local (IndexedDB) projects folders imported
* via "Open a local folder" plus anything saved in the editor. Each row opens
* the project (its own /p/:slug URL), exports it as a .zip, renames, or deletes.
* Only rendered when the local store is enabled (LOCAL_PROJECTS_ENABLED).
*/
export function LocalProjectsSection() {
const { data: projects, isLoading } = useLocalProjects();
const qc = useQueryClient();
const store = localProjectStore();
const [busy, setBusy] = React.useState<string | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: ["local-projects"] });
const exportZip = async (slug: string) => {
if (!store) return;
setBusy(slug);
try {
const files = await store.readFiles(slug);
downloadBytes(`${slug}.zip`, zipFiles(files));
} finally {
setBusy(null);
}
};
const rename = async (slug: string, current: string) => {
if (!store) return;
const name = window.prompt("Rename project", current)?.trim();
if (!name || name === current) return;
await store.renameProject(slug, name);
void refresh();
};
const remove = async (slug: string, name: string) => {
if (!store) return;
if (!window.confirm(`Delete "${name}" from this browser? This can't be undone.`))
return;
await store.deleteProject(slug);
void refresh();
};
return (
<section className="mb-10">
<h2 className="mb-3 flex items-center gap-2 text-lg font-medium">
Your projects
<SourceChip descriptor={SOURCE_DESCRIPTORS.local} />
</h2>
{isLoading ? (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading
</p>
) : projects && projects.length > 0 ? (
<div className="divide-y rounded-lg border">
{projects.map((p) => (
<div
key={p.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<div className="min-w-0">
<p className="truncate font-medium">{p.name}</p>
<p className="text-xs text-muted-foreground">/p/{p.slug}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button asChild variant="secondary" size="sm">
<Link to={`/p/${p.slug}`}>Open</Link>
</Button>
<Button
variant="ghost"
size="sm"
title="Download .zip"
disabled={busy === p.slug}
onClick={() => void exportZip(p.slug)}
>
{busy === p.slug ? (
<Loader2 className="animate-spin" size={15} />
) : (
<Download size={15} />
)}
</Button>
<Button
variant="ghost"
size="sm"
title="Rename"
onClick={() => void rename(p.slug, p.name)}
>
<Pencil size={15} />
</Button>
<Button
variant="ghost"
size="sm"
title="Delete"
onClick={() => void remove(p.slug, p.name)}
>
<Trash2 size={15} />
</Button>
</div>
</div>
))}
</div>
) : (
<p className="rounded-lg border px-4 py-6 text-sm text-muted-foreground">
No saved projects yet open a local folder above to start. Files stay
in this browser; export anytime with Download .zip.
</p>
)}
</section>
);
}

View file

@ -0,0 +1,158 @@
import * as React from "react";
import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import type { Project } from "@pcbjam/shared";
import { Download, Loader2, Pencil, Trash2 } from "lucide-react";
import { useLocalProjects, useProjects } from "@/lib/api";
import { PROJECT_SOURCE_KIND } from "@/lib/config";
import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source";
import {
SOURCE_DESCRIPTORS,
type SourceDescriptor,
} from "@/lib/project-source-shared";
import { zipFiles } from "@/lib/zip";
import { Button } from "@/components/ui/button";
import { SourceChip } from "@/components/SourceChip";
/**
* The unified "Projects" list: browser-local projects (imported folders + saved
* work) and the configured remote source (the CDN gallery, or a backend) in one
* list, each ROW carrying its own source chip so the kind is explicit per
* project rather than per section. Local rows are writable (export / rename /
* delete); remote rows just open.
*/
export function ProjectsSection() {
const localQ = useLocalProjects();
const primaryQ = useProjects();
const qc = useQueryClient();
const store = localProjectStore();
const [busy, setBusy] = React.useState<string | null>(null);
const staticMode = PROJECT_SOURCE_KIND === "static";
const remoteDescriptor: SourceDescriptor = staticMode
? SOURCE_DESCRIPTORS["remote-ro"]
: SOURCE_DESCRIPTORS["remote-rw"];
const local = localQ.data ?? [];
const localSlugs = new Set(local.map((p) => p.slug));
const remote = (primaryQ.data ?? []).filter((p) => !localSlugs.has(p.slug));
const refresh = () => qc.invalidateQueries({ queryKey: ["local-projects"] });
const exportZip = async (slug: string) => {
if (!store) return;
setBusy(slug);
try {
downloadBytes(`${slug}.zip`, zipFiles(await store.readFiles(slug)));
} finally {
setBusy(null);
}
};
const rename = async (slug: string, current: string) => {
if (!store) return;
const name = window.prompt("Rename project", current)?.trim();
if (!name || name === current) return;
await store.renameProject(slug, name);
void refresh();
};
const remove = async (slug: string, name: string) => {
if (!store) return;
if (!window.confirm(`Delete "${name}" from this browser? This can't be undone.`))
return;
await store.deleteProject(slug);
void refresh();
};
const empty = local.length === 0 && remote.length === 0;
return (
<section className="mb-10">
<h2 className="mb-3 text-lg font-medium">Projects</h2>
{primaryQ.isLoading && empty && (
<p className="mb-2 flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading
</p>
)}
<div className="divide-y rounded-lg border">
{local.map((p) => (
<ProjectRow key={p.id} project={p} descriptor={SOURCE_DESCRIPTORS.local}>
<Button
variant="ghost"
size="sm"
title="Download .zip"
disabled={busy === p.slug}
onClick={() => void exportZip(p.slug)}
>
{busy === p.slug ? (
<Loader2 className="animate-spin" size={15} />
) : (
<Download size={15} />
)}
</Button>
<Button
variant="ghost"
size="sm"
title="Rename"
onClick={() => void rename(p.slug, p.name)}
>
<Pencil size={15} />
</Button>
<Button
variant="ghost"
size="sm"
title="Delete"
onClick={() => void remove(p.slug, p.name)}
>
<Trash2 size={15} />
</Button>
</ProjectRow>
))}
{remote.map((p) => (
<ProjectRow key={p.id} project={p} descriptor={remoteDescriptor} />
))}
{empty && !primaryQ.isLoading && (
<div className="px-4 py-6 text-sm text-muted-foreground">
{primaryQ.error
? staticMode
? `Couldn't load the example gallery (${(primaryQ.error as Error).message}). Open a local folder above to start.`
: `No backend reachable (${(primaryQ.error as Error).message}). Open a local folder above, or configure VITE_API_BASE_URL.`
: "No projects yet — open a local folder above to start. Files stay in this browser; export anytime with Download .zip."}
</div>
)}
</div>
</section>
);
}
function ProjectRow({
project,
descriptor,
children,
}: {
project: Project;
descriptor: SourceDescriptor;
children?: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate font-medium">{project.name}</p>
<SourceChip descriptor={descriptor} />
</div>
<p className="text-xs text-muted-foreground">/p/{project.slug}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button asChild variant="secondary" size="sm">
<Link to={`/p/${project.slug}`}>Open</Link>
</Button>
{children}
</div>
</div>
);
}

View file

@ -14,10 +14,13 @@ const ICONS: Record<SourceKind, typeof HardDrive> = {
"remote-rw": Cloud,
};
// Solid fills with white text + a subtle inset ring, so the chip is legible on
// any backdrop — the home page, the dark boot screen, or the light editor canvas
// (the translucent pastel version washed out on the canvas).
const TONES: Record<SourceKind, string> = {
local: "border-emerald-500/40 bg-emerald-500/10 text-emerald-300",
"remote-ro": "border-amber-500/40 bg-amber-500/10 text-amber-300",
"remote-rw": "border-sky-500/40 bg-sky-500/10 text-sky-300",
local: "bg-emerald-600 text-white ring-emerald-300/30",
"remote-ro": "bg-amber-600 text-white ring-amber-300/30",
"remote-rw": "bg-sky-600 text-white ring-sky-300/30",
};
export function SourceChip({
@ -31,7 +34,7 @@ export function SourceChip({
return (
<span
title={descriptor.description}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${TONES[descriptor.kind]} ${className}`}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium shadow-sm ring-1 ring-inset ${TONES[descriptor.kind]} ${className}`}
>
<Icon size={13} />
{descriptor.label}

View file

@ -1,20 +1,18 @@
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import type { Lib, Tool } from "@pcbjam/shared";
import type { Tool } from "@pcbjam/shared";
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
import { useLibs, useProjects } from "@/lib/api";
import { useLibs } from "@/lib/api";
import { LOCAL_PROJECTS_ENABLED, PROJECT_SOURCE_KIND } from "@/lib/config";
import { localFileLibsSource } from "@/wasm/libs/local-file-source";
import type { LibsSource } from "@/wasm/libs/source";
import { downloadBytes } from "@/lib/download";
import { importFileList, importFsaFolder } from "@/lib/import-folder";
import { localProjectStore } from "@/lib/project-source";
import { SOURCE_DESCRIPTORS } from "@/lib/project-source-shared";
import { Button } from "@/components/ui/button";
import { ToolGrid } from "@/components/ToolGrid";
import { SourceChip } from "@/components/SourceChip";
import { LocalProjectsSection } from "@/components/LocalProjectsSection";
import { ProjectsSection } from "@/components/ProjectsSection";
import type { SaveBytes } from "@/wasm/save-flow";
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
import { WasmTool } from "@/components/WasmTool";
@ -116,7 +114,6 @@ export function HomePage() {
const localEnabled = LOCAL_PROJECTS_ENABLED;
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: projects, isLoading, error } = useProjects();
const symbolLibs = useLibs("symbol");
const footprintLibs = useLibs("footprint");
const inputRef = React.useRef<HTMLInputElement>(null);
@ -274,8 +271,9 @@ export function HomePage() {
)}
</section>
{/* --- Your browser-local projects (imported folders + saved work) --- */}
{localEnabled && <LocalProjectsSection />}
{/* --- Projects (browser-local + the gallery/backend, one list with
a per-row source chip) --- */}
<ProjectsSection />
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
<section className="mb-10">
@ -283,53 +281,6 @@ export function HomePage() {
<ToolGrid onLaunch={(tool) => setLaunchedTool({ tool })} />
</section>
{/* --- Projects (backend, or the static example gallery) --- */}
<section className="mb-10">
<h2 className="mb-3 flex items-center gap-2 text-lg font-medium">
{staticMode ? "Example projects" : "Projects from the backend"}
<SourceChip
descriptor={
staticMode
? SOURCE_DESCRIPTORS["remote-ro"]
: SOURCE_DESCRIPTORS["remote-rw"]
}
/>
</h2>
{isLoading && (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading
</p>
)}
{error && (
<p className="text-sm text-muted-foreground">
{staticMode
? `Couldn't load the example gallery (${(error as Error).message}). Use a local folder above.`
: `No backend reachable (${(error as Error).message}). Use a local folder above, or configure VITE_API_BASE_URL.`}
</p>
)}
<div className="divide-y rounded-lg border">
{projects?.map((p) => (
<div
key={p.id}
className="flex items-center justify-between px-4 py-3"
>
<div>
<p className="font-medium">{p.name}</p>
<p className="text-xs text-muted-foreground">/p/{p.slug}</p>
</div>
<Button asChild variant="secondary" size="sm">
<Link to={`/p/${p.slug}`}>Open</Link>
</Button>
</div>
))}
{projects && projects.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground">
{staticMode ? "No example projects." : "The backend has no projects."}
</div>
)}
</div>
</section>
{/* --- Backend libraries (hidden in the no-backend static demo) --- */}
{!staticMode && (
<section>