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,
|
||||
};
|
||||
|
||||
// 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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue