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:
parent
ce9ede1e9e
commit
4839a9d73a
4 changed files with 172 additions and 180 deletions
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
158
web/standalone/src/components/ProjectsSection.tsx
Normal file
158
web/standalone/src/components/ProjectsSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,10 +14,13 @@ const ICONS: Record<SourceKind, typeof HardDrive> = {
|
||||||
"remote-rw": Cloud,
|
"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> = {
|
const TONES: Record<SourceKind, string> = {
|
||||||
local: "border-emerald-500/40 bg-emerald-500/10 text-emerald-300",
|
local: "bg-emerald-600 text-white ring-emerald-300/30",
|
||||||
"remote-ro": "border-amber-500/40 bg-amber-500/10 text-amber-300",
|
"remote-ro": "bg-amber-600 text-white ring-amber-300/30",
|
||||||
"remote-rw": "border-sky-500/40 bg-sky-500/10 text-sky-300",
|
"remote-rw": "bg-sky-600 text-white ring-sky-300/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SourceChip({
|
export function SourceChip({
|
||||||
|
|
@ -31,7 +34,7 @@ export function SourceChip({
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
title={descriptor.description}
|
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} />
|
<Icon size={13} />
|
||||||
{descriptor.label}
|
{descriptor.label}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
import * as React from "react";
|
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 { 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 { 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 { LOCAL_PROJECTS_ENABLED, PROJECT_SOURCE_KIND } from "@/lib/config";
|
||||||
import { localFileLibsSource } from "@/wasm/libs/local-file-source";
|
import { localFileLibsSource } from "@/wasm/libs/local-file-source";
|
||||||
import type { LibsSource } from "@/wasm/libs/source";
|
import type { LibsSource } from "@/wasm/libs/source";
|
||||||
import { downloadBytes } from "@/lib/download";
|
import { downloadBytes } from "@/lib/download";
|
||||||
import { importFileList, importFsaFolder } from "@/lib/import-folder";
|
import { importFileList, importFsaFolder } from "@/lib/import-folder";
|
||||||
import { localProjectStore } from "@/lib/project-source";
|
import { localProjectStore } from "@/lib/project-source";
|
||||||
import { SOURCE_DESCRIPTORS } from "@/lib/project-source-shared";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ToolGrid } from "@/components/ToolGrid";
|
import { ToolGrid } from "@/components/ToolGrid";
|
||||||
import { SourceChip } from "@/components/SourceChip";
|
import { ProjectsSection } from "@/components/ProjectsSection";
|
||||||
import { LocalProjectsSection } from "@/components/LocalProjectsSection";
|
|
||||||
import type { SaveBytes } from "@/wasm/save-flow";
|
import type { SaveBytes } from "@/wasm/save-flow";
|
||||||
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
|
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
|
||||||
import { WasmTool } from "@/components/WasmTool";
|
import { WasmTool } from "@/components/WasmTool";
|
||||||
|
|
@ -116,7 +114,6 @@ export function HomePage() {
|
||||||
const localEnabled = LOCAL_PROJECTS_ENABLED;
|
const localEnabled = LOCAL_PROJECTS_ENABLED;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: projects, isLoading, error } = useProjects();
|
|
||||||
const symbolLibs = useLibs("symbol");
|
const symbolLibs = useLibs("symbol");
|
||||||
const footprintLibs = useLibs("footprint");
|
const footprintLibs = useLibs("footprint");
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -274,8 +271,9 @@ export function HomePage() {
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* --- Your browser-local projects (imported folders + saved work) --- */}
|
{/* --- Projects (browser-local + the gallery/backend, one list with
|
||||||
{localEnabled && <LocalProjectsSection />}
|
a per-row source chip) --- */}
|
||||||
|
<ProjectsSection />
|
||||||
|
|
||||||
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
|
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
|
|
@ -283,53 +281,6 @@ export function HomePage() {
|
||||||
<ToolGrid onLaunch={(tool) => setLaunchedTool({ tool })} />
|
<ToolGrid onLaunch={(tool) => setLaunchedTool({ tool })} />
|
||||||
</section>
|
</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) --- */}
|
{/* --- Backend libraries (hidden in the no-backend static demo) --- */}
|
||||||
{!staticMode && (
|
{!staticMode && (
|
||||||
<section>
|
<section>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue