mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(web): redesign Designs tab cards — covers, tags, overflow menu, multi-select (#1161)
* feat(web): redesign Designs tab cards — covers, tags, overflow menu, multi-select - Render real previews on project cards: HTML iframe / image / video / hashed gradient fallback with project initial; lazily fetches the project's primary file when metadata.entryFile is unset, prefers index.html → newest html → image → video. - Live artifact card thumbnails embed the rendered artifact URL via sandboxed iframe. - Replace the per-card close button with a `…` overflow menu (Rename, Delete) that opens on hover/click; click-outside and Esc close it. - Add multi-select mode (toolbar toggle → checkbox per card → "N selected · Delete · Cancel" pill) with batch delete via the existing onDelete prop. - Add a category tag to every card (Prototype / Live Artifact / Slide / Media) derived from project.metadata.intent / kind / skillId. - Replace browser prompt() and confirm() with custom modals (rename input + danger-confirm) reusing the existing .modal shell. - Add `more-horizontal` icon and 16 new i18n keys across all 18 locales (zh-CN/zh-TW localized; others fall back to English). * test(e2e): update home delete flow for overflow menu + custom confirm modal The previous flow targeted a per-card X button labelled "delete project <name>" and asserted on a native `dialog` event. The card UI now exposes a `…` overflow menu and a styled confirm modal, so reach delete via the menu and assert against the modal's Cancel / Delete buttons instead. * fix(web): harden Designs tab preview sandbox * fix(web): hide Designs select mode in kanban
This commit is contained in:
parent
77f69257a7
commit
9c489aa045
26 changed files with 1160 additions and 48 deletions
|
|
@ -689,6 +689,15 @@ export function App() {
|
|||
}
|
||||
}, [route]);
|
||||
|
||||
const handleRenameProject = useCallback(async (id: string, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setProjects((curr) =>
|
||||
curr.map((p) => (p.id === id ? { ...p, name: trimmed } : p)),
|
||||
);
|
||||
void patchProject(id, { name: trimmed });
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate({ kind: 'home' });
|
||||
}, []);
|
||||
|
|
@ -929,6 +938,7 @@ export function App() {
|
|||
onOpenProject={handleOpenProject}
|
||||
onOpenLiveArtifact={handleOpenLiveArtifact}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onRenameProject={handleRenameProject}
|
||||
onChangeDefaultDesignSystem={handleChangeDefaultDesignSystem}
|
||||
onOpenSettings={openSettings}
|
||||
onAdoptPet={openPetSettings}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useT } from "../i18n";
|
||||
import { deleteLiveArtifact, fetchLiveArtifacts } from "../providers/registry";
|
||||
import { deleteLiveArtifact, fetchLiveArtifacts, fetchProjectFiles, liveArtifactPreviewUrl, projectFileUrl } from "../providers/registry";
|
||||
import type {
|
||||
DesignSystemSummary,
|
||||
LiveArtifactSummary,
|
||||
|
|
@ -55,6 +56,7 @@ interface Props {
|
|||
onOpen: (id: string) => void;
|
||||
onOpenLiveArtifact: (projectId: string, artifactId: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
export function DesignsTab({
|
||||
|
|
@ -64,6 +66,7 @@ export function DesignsTab({
|
|||
onOpen,
|
||||
onOpenLiveArtifact,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const [filter, setFilter] = useState("");
|
||||
|
|
@ -71,6 +74,21 @@ export function DesignsTab({
|
|||
const [liveArtifactsByProject, setLiveArtifactsByProject] = useState<
|
||||
Record<string, LiveArtifactSummary[]>
|
||||
>({});
|
||||
const [coverByProject, setCoverByProject] = useState<
|
||||
Record<string, { kind: "html" | "image" | "video"; name: string } | null>
|
||||
>({});
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const menuContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [renameTarget, setRenameTarget] = useState<{ id: string; original: string } | null>(null);
|
||||
const [renameInput, setRenameInput] = useState("");
|
||||
const [confirmTarget, setConfirmTarget] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const [view, setView] = useState<ViewMode>(() => {
|
||||
if (typeof window === "undefined") return "grid";
|
||||
try {
|
||||
|
|
@ -106,12 +124,98 @@ export function DesignsTab({
|
|||
};
|
||||
}, [projects]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (projects.length === 0) {
|
||||
setCoverByProject({});
|
||||
return;
|
||||
}
|
||||
void Promise.all(
|
||||
projects.map(async (project) => {
|
||||
if (project.metadata?.entryFile) return [project.id, null] as const;
|
||||
const files = await fetchProjectFiles(project.id);
|
||||
const html =
|
||||
files.find((f) => (f.path ?? f.name) === "index.html") ??
|
||||
files
|
||||
.filter((f) => f.kind === "html")
|
||||
.sort((a, b) => b.mtime - a.mtime)[0];
|
||||
if (html) {
|
||||
return [
|
||||
project.id,
|
||||
{ kind: "html" as const, name: html.path ?? html.name },
|
||||
] as const;
|
||||
}
|
||||
const image = files
|
||||
.filter((f) => f.kind === "image")
|
||||
.sort((a, b) => b.mtime - a.mtime)[0];
|
||||
if (image) {
|
||||
return [
|
||||
project.id,
|
||||
{ kind: "image" as const, name: image.path ?? image.name },
|
||||
] as const;
|
||||
}
|
||||
const video = files
|
||||
.filter((f) => f.kind === "video")
|
||||
.sort((a, b) => b.mtime - a.mtime)[0];
|
||||
if (video) {
|
||||
return [
|
||||
project.id,
|
||||
{ kind: "video" as const, name: video.path ?? video.name },
|
||||
] as const;
|
||||
}
|
||||
return [project.id, null] as const;
|
||||
}),
|
||||
).then((entries) => {
|
||||
if (cancelled) return;
|
||||
setCoverByProject(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpenId) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
const el = menuContainerRef.current;
|
||||
if (el && el.contains(e.target as Node)) return;
|
||||
setMenuOpenId(null);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMenuOpenId(null);
|
||||
};
|
||||
window.addEventListener("mousedown", onDocClick);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", onDocClick);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [menuOpenId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Drop selected ids that no longer exist
|
||||
setSelected((curr) => {
|
||||
const valid = new Set(projects.map((p) => p.id));
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
curr.forEach((id) => {
|
||||
if (valid.has(id)) next.add(id);
|
||||
else changed = true;
|
||||
});
|
||||
return changed ? next : curr;
|
||||
});
|
||||
}, [projects]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(DESIGNS_VIEW_STORAGE_KEY, view);
|
||||
} catch {}
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "kanban" && selectMode) exitSelectMode();
|
||||
}, [selectMode, view]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
let list: DesignListItem[] = projects
|
||||
|
|
@ -172,19 +276,75 @@ export function DesignsTab({
|
|||
skills.find((s) => s.id === id)?.name ?? "";
|
||||
const dsName = (id: string | null) =>
|
||||
designSystems.find((d) => d.id === id)?.title ?? "";
|
||||
const toggleSelected = (id: string) => {
|
||||
setSelected((curr) => {
|
||||
const next = new Set(curr);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
const handleRenameProject = (project: Project) => {
|
||||
setRenameTarget({ id: project.id, original: project.name });
|
||||
setRenameInput(project.name);
|
||||
};
|
||||
const commitRename = () => {
|
||||
if (!renameTarget) return;
|
||||
const trimmed = renameInput.trim();
|
||||
if (trimmed && trimmed !== renameTarget.original) {
|
||||
onRename(renameTarget.id, trimmed);
|
||||
}
|
||||
setRenameTarget(null);
|
||||
setRenameInput("");
|
||||
};
|
||||
const cancelRename = () => {
|
||||
setRenameTarget(null);
|
||||
setRenameInput("");
|
||||
};
|
||||
const handleDeleteProject = (project: Project) => {
|
||||
setConfirmTarget({
|
||||
title: t("designs.deleteTitle"),
|
||||
message: t("designs.deleteConfirm", { name: project.name }),
|
||||
confirmLabel: t("designs.menuDelete"),
|
||||
onConfirm: () => onDelete(project.id),
|
||||
});
|
||||
};
|
||||
const handleBatchDelete = () => {
|
||||
const ids = Array.from(selected);
|
||||
if (ids.length === 0) return;
|
||||
setConfirmTarget({
|
||||
title: t("designs.deleteTitle"),
|
||||
message: t("designs.deleteSelectedConfirm", { n: ids.length }),
|
||||
confirmLabel: t("designs.deleteSelected"),
|
||||
onConfirm: () => {
|
||||
ids.forEach((id) => onDelete(id));
|
||||
exitSelectMode();
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleDeleteLiveArtifact = async (
|
||||
projectId: string,
|
||||
artifact: LiveArtifactSummary,
|
||||
) => {
|
||||
if (!confirm(`${t("common.delete")} "${artifact.title}"?`)) return;
|
||||
const ok = await deleteLiveArtifact(projectId, artifact.id);
|
||||
if (!ok) return;
|
||||
setLiveArtifactsByProject((current) => ({
|
||||
...current,
|
||||
[projectId]: (current[projectId] ?? []).filter(
|
||||
(candidate) => candidate.id !== artifact.id,
|
||||
),
|
||||
}));
|
||||
setConfirmTarget({
|
||||
title: t("common.delete"),
|
||||
message: `${t("common.delete")} "${artifact.title}"?`,
|
||||
confirmLabel: t("designs.menuDelete"),
|
||||
onConfirm: async () => {
|
||||
const ok = await deleteLiveArtifact(projectId, artifact.id);
|
||||
if (!ok) return;
|
||||
setLiveArtifactsByProject((current) => ({
|
||||
...current,
|
||||
[projectId]: (current[projectId] ?? []).filter(
|
||||
(candidate) => candidate.id !== artifact.id,
|
||||
),
|
||||
}));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -225,6 +385,37 @@ export function DesignsTab({
|
|||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{view === "grid" && selectMode ? (
|
||||
<div className="designs-select-bar" role="group">
|
||||
<span className="designs-select-count">
|
||||
{t("designs.selectedCount", { n: selected.size })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="designs-select-delete"
|
||||
disabled={selected.size === 0}
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
{t("designs.deleteSelected")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="designs-select-cancel"
|
||||
onClick={exitSelectMode}
|
||||
>
|
||||
{t("designs.cancelSelect")}
|
||||
</button>
|
||||
</div>
|
||||
) : view === "grid" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="designs-select-toggle"
|
||||
onClick={() => setSelectMode(true)}
|
||||
>
|
||||
<Icon name="check" size={13} />
|
||||
<span>{t("designs.selectMode")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<div
|
||||
className="subtab-pill"
|
||||
role="group"
|
||||
|
|
@ -297,9 +488,17 @@ export function DesignsTab({
|
|||
className="design-card-thumb live-artifact-thumb"
|
||||
aria-hidden
|
||||
>
|
||||
<span className="live-artifact-thumb-glyph">●</span>
|
||||
<iframe
|
||||
className="thumb-iframe"
|
||||
src={liveArtifactPreviewUrl(p.id, artifact.id)}
|
||||
title=""
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
<div className="design-card-meta-block">
|
||||
<ProjectTag category="live-artifact" />
|
||||
<LiveArtifactBadges
|
||||
className="design-card-badges"
|
||||
status={artifact.status}
|
||||
|
|
@ -328,33 +527,105 @@ export function DesignsTab({
|
|||
|
||||
const liveCount = liveArtifactsByProject[p.id]?.length ?? 0;
|
||||
const status = p.status?.value ?? "not_started";
|
||||
const cover = projectCover(p, coverByProject[p.id] ?? null);
|
||||
const isSelected = selected.has(p.id);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="design-card"
|
||||
className={`design-card${isSelected ? " is-selected" : ""}${selectMode ? " select-mode" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(p.id)}
|
||||
onClick={() => {
|
||||
if (selectMode) toggleSelected(p.id);
|
||||
else onOpen(p.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen(p.id);
|
||||
if (selectMode) toggleSelected(p.id);
|
||||
else onOpen(p.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="design-card-close"
|
||||
title={t("designs.deleteTitle")}
|
||||
aria-label={t("designs.deleteAria", { name: p.name })}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(t("designs.deleteConfirm", { name: p.name })))
|
||||
onDelete(p.id);
|
||||
}}
|
||||
{selectMode ? (
|
||||
<span
|
||||
className={`design-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? <Icon name="check" size={12} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className="design-card-menu-anchor"
|
||||
ref={menuOpenId === p.id ? menuContainerRef : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="design-card-more"
|
||||
aria-label={t("designs.menuMore")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpenId === p.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpenId((cur) => (cur === p.id ? null : p.id));
|
||||
}}
|
||||
>
|
||||
<Icon name="more-horizontal" size={14} />
|
||||
</button>
|
||||
{menuOpenId === p.id ? (
|
||||
<div
|
||||
className="design-card-menu"
|
||||
role="menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpenId(null);
|
||||
handleRenameProject(p);
|
||||
}}
|
||||
>
|
||||
<Icon name="pencil" size={12} />
|
||||
<span>{t("designs.menuRename")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
setMenuOpenId(null);
|
||||
handleDeleteProject(p);
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
<span>{t("designs.menuDelete")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`design-card-thumb project-thumb project-thumb-${cover.kind}`}
|
||||
style={cover.style}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
<div className="design-card-thumb" aria-hidden>
|
||||
{cover.kind === "image" && cover.src ? (
|
||||
<img className="thumb-media" src={cover.src} alt="" loading="lazy" />
|
||||
) : cover.kind === "video" && cover.src ? (
|
||||
<video className="thumb-media" src={cover.src} muted preload="metadata" playsInline />
|
||||
) : cover.kind === "html" && cover.src ? (
|
||||
<iframe
|
||||
className="thumb-iframe"
|
||||
src={cover.src}
|
||||
title=""
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
) : (
|
||||
<span className="project-thumb-glyph">{cover.initial}</span>
|
||||
)}
|
||||
{liveCount > 0 ? (
|
||||
<span className="design-live-count">
|
||||
{t("designs.liveCount", { n: liveCount })}
|
||||
|
|
@ -362,6 +633,7 @@ export function DesignsTab({
|
|||
) : null}
|
||||
</div>
|
||||
<div className="design-card-meta-block">
|
||||
<ProjectTag category={projectCategory(p)} />
|
||||
<div className="design-card-name" title={p.name}>
|
||||
{p.name}
|
||||
</div>
|
||||
|
|
@ -436,12 +708,7 @@ export function DesignsTab({
|
|||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (
|
||||
confirm(
|
||||
t("designs.deleteConfirm", { name: p.name }),
|
||||
)
|
||||
)
|
||||
onDelete(p.id);
|
||||
handleDeleteProject(p);
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
|
|
@ -475,6 +742,80 @@ export function DesignsTab({
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
{renameTarget ? (
|
||||
<div className="modal-backdrop" onClick={cancelRename}>
|
||||
<form
|
||||
className="modal modal-rename"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
}}
|
||||
>
|
||||
<h2>{t("designs.renameTitle")}</h2>
|
||||
<label>
|
||||
{t("designs.renamePrompt", { name: renameTarget.original })}
|
||||
<input
|
||||
type="text"
|
||||
value={renameInput}
|
||||
autoFocus
|
||||
onChange={(e) => setRenameInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="row">
|
||||
<button type="button" onClick={cancelRename}>
|
||||
{t("designs.renameCancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="primary"
|
||||
disabled={
|
||||
!renameInput.trim() ||
|
||||
renameInput.trim() === renameTarget.original
|
||||
}
|
||||
>
|
||||
{t("designs.renameSave")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
{confirmTarget ? (
|
||||
<div className="modal-backdrop" onClick={() => setConfirmTarget(null)}>
|
||||
<div
|
||||
className="modal modal-confirm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<h2>{confirmTarget.title}</h2>
|
||||
<p className="modal-confirm-message">{confirmTarget.message}</p>
|
||||
<div className="row">
|
||||
<button type="button" onClick={() => setConfirmTarget(null)}>
|
||||
{t("designs.renameCancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary danger"
|
||||
autoFocus
|
||||
onClick={() => {
|
||||
const run = confirmTarget.onConfirm;
|
||||
setConfirmTarget(null);
|
||||
run();
|
||||
}}
|
||||
>
|
||||
{confirmTarget.confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -538,3 +879,71 @@ function isOrbitProject(project: Project): boolean {
|
|||
const metadata = project.metadata as { kind?: unknown } | undefined;
|
||||
return metadata?.kind === 'orbit';
|
||||
}
|
||||
|
||||
function projectCover(
|
||||
project: Project,
|
||||
override: { kind: "html" | "image" | "video"; name: string } | null,
|
||||
): {
|
||||
kind: "image" | "video" | "html" | "fallback";
|
||||
src?: string;
|
||||
style: CSSProperties;
|
||||
initial: string;
|
||||
} {
|
||||
let h = 0;
|
||||
for (let i = 0; i < project.id.length; i++) {
|
||||
h = (h * 31 + project.id.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
const hue = h % 360;
|
||||
const hue2 = (hue + 38) % 360;
|
||||
const style: CSSProperties = {
|
||||
background: `radial-gradient(circle at 30% 28%, hsl(${hue} 70% 78% / 0.55), transparent 42%), linear-gradient(135deg, hsl(${hue} 65% 88%), hsl(${hue2} 70% 90%))`,
|
||||
};
|
||||
const trimmed = project.name.trim();
|
||||
const initial = (trimmed ? Array.from(trimmed)[0]! : "?").toUpperCase();
|
||||
if (override) {
|
||||
return {
|
||||
kind: override.kind,
|
||||
src: projectFileUrl(project.id, override.name),
|
||||
style,
|
||||
initial,
|
||||
};
|
||||
}
|
||||
const meta = project.metadata;
|
||||
const entry = meta?.entryFile;
|
||||
if (entry) {
|
||||
const src = projectFileUrl(project.id, entry);
|
||||
if (meta?.kind === "image") return { kind: "image", src, style, initial };
|
||||
if (meta?.kind === "video") return { kind: "video", src, style, initial };
|
||||
if (/\.html?$/i.test(entry)) return { kind: "html", src, style, initial };
|
||||
}
|
||||
return { kind: "fallback", style, initial };
|
||||
}
|
||||
|
||||
type ProjectCategory = "prototype" | "live-artifact" | "slide" | "media";
|
||||
|
||||
function projectCategory(project: Project): ProjectCategory {
|
||||
const meta = project.metadata;
|
||||
if (meta?.intent === "live-artifact" || project.skillId === "live-artifact") {
|
||||
return "live-artifact";
|
||||
}
|
||||
if (meta?.kind === "deck") return "slide";
|
||||
if (meta?.kind === "image" || meta?.kind === "video" || meta?.kind === "audio") {
|
||||
return "media";
|
||||
}
|
||||
return "prototype";
|
||||
}
|
||||
|
||||
function ProjectTag({ category }: { category: ProjectCategory }) {
|
||||
const t = useT();
|
||||
const label =
|
||||
category === "live-artifact"
|
||||
? t("designs.tagLiveArtifact")
|
||||
: category === "slide"
|
||||
? t("designs.tagSlide")
|
||||
: category === "media"
|
||||
? t("designs.tagMedia")
|
||||
: t("designs.tagPrototype");
|
||||
return (
|
||||
<span className={`design-card-tag tag-${category}`}>{label}</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ interface Props {
|
|||
onOpenProject: (id: string) => void;
|
||||
onOpenLiveArtifact: (projectId: string, artifactId: string) => void;
|
||||
onDeleteProject: (id: string) => void;
|
||||
onRenameProject: (id: string, name: string) => void;
|
||||
onChangeDefaultDesignSystem: (id: string) => void;
|
||||
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'language' | 'appearance' | 'notifications' | 'pet' | 'about') => void;
|
||||
onAdoptPet: () => void;
|
||||
|
|
@ -240,6 +241,7 @@ export function EntryView({
|
|||
onOpenProject,
|
||||
onOpenLiveArtifact,
|
||||
onDeleteProject,
|
||||
onRenameProject,
|
||||
onChangeDefaultDesignSystem,
|
||||
onOpenSettings,
|
||||
onAdoptPet,
|
||||
|
|
@ -549,6 +551,7 @@ export function EntryView({
|
|||
onOpen={onOpenProject}
|
||||
onOpenLiveArtifact={onOpenLiveArtifact}
|
||||
onDelete={onDeleteProject}
|
||||
onRename={onRenameProject}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type IconName =
|
|||
| 'link'
|
||||
| 'mic'
|
||||
| 'minus'
|
||||
| 'more-horizontal'
|
||||
| 'orbit'
|
||||
| 'pencil'
|
||||
| 'plus'
|
||||
|
|
@ -290,6 +291,14 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) {
|
|||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
case 'more-horizontal':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="5" cy="12" r="1.4" />
|
||||
<circle cx="12" cy="12" r="1.4" />
|
||||
<circle cx="19" cy="12" r="1.4" />
|
||||
</svg>
|
||||
);
|
||||
case 'orbit':
|
||||
// Tilted elliptical orbit + central body + a small satellite riding the
|
||||
// path. Reads unmistakably as "orbit/automation" rather than the
|
||||
|
|
|
|||
|
|
@ -442,6 +442,22 @@ export const ar: Dict = {
|
|||
'designs.viewKanban': 'عرض اللوحة',
|
||||
'designs.kanbanEmptyColumn': 'لا توجد تصاميم',
|
||||
'designs.deleteAria': 'حذف المشروع {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'النوع',
|
||||
'examples.surfaceLabel': 'السطح',
|
||||
|
|
|
|||
|
|
@ -330,6 +330,22 @@ export const de: Dict = {
|
|||
'designs.viewKanban': 'Board-Ansicht',
|
||||
'designs.kanbanEmptyColumn': 'Keine Designs',
|
||||
'designs.deleteAria': 'Projekt {name} löschen',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'Typ',
|
||||
'examples.surfaceLabel': 'Oberfläche',
|
||||
|
|
|
|||
|
|
@ -518,6 +518,22 @@ export const en: Dict = {
|
|||
'designs.viewKanban': 'Board view',
|
||||
'designs.kanbanEmptyColumn': 'No designs',
|
||||
'designs.deleteAria': 'Delete project {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'Type',
|
||||
'examples.surfaceLabel': 'Surface',
|
||||
|
|
|
|||
|
|
@ -331,6 +331,22 @@ export const esES: Dict = {
|
|||
'designs.viewKanban': 'Vista en tablero',
|
||||
'designs.kanbanEmptyColumn': 'Sin diseños',
|
||||
'designs.deleteAria': 'Eliminar proyecto {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'Tipo',
|
||||
'examples.surfaceLabel': 'Superficie',
|
||||
|
|
|
|||
|
|
@ -464,6 +464,22 @@ export const fa: Dict = {
|
|||
'designs.viewKanban': 'نمای برد',
|
||||
'designs.kanbanEmptyColumn': 'هیچ طرحی نیست',
|
||||
'designs.deleteAria': 'حذف پروژه {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'نوع',
|
||||
'examples.surfaceLabel': 'سطح',
|
||||
|
|
|
|||
|
|
@ -442,6 +442,22 @@ export const fr: Dict = {
|
|||
'designs.viewKanban': 'Vue tableau',
|
||||
'designs.kanbanEmptyColumn': 'Aucun design',
|
||||
'designs.deleteAria': 'Supprimer le projet {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'Type',
|
||||
'examples.surfaceLabel': 'Surface',
|
||||
|
|
|
|||
|
|
@ -442,6 +442,22 @@ export const hu: Dict = {
|
|||
'designs.viewKanban': 'Tábla nézet',
|
||||
'designs.kanbanEmptyColumn': 'Nincs terv',
|
||||
'designs.deleteAria': '{name} projekt törlése',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'Típus',
|
||||
'examples.surfaceLabel': 'Felület',
|
||||
|
|
|
|||
|
|
@ -557,6 +557,22 @@ export const id: Dict = {
|
|||
'designs.viewKanban': 'Tampilan kanban',
|
||||
'designs.kanbanEmptyColumn': 'Tidak ada desain',
|
||||
'designs.deleteAria': 'Hapus proyek {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'Tipe',
|
||||
'examples.surfaceLabel': 'Surface',
|
||||
|
|
|
|||
|
|
@ -329,6 +329,22 @@ export const ja: Dict = {
|
|||
'designs.viewKanban': 'ボード表示',
|
||||
'designs.kanbanEmptyColumn': 'デザインなし',
|
||||
'designs.deleteAria': 'プロジェクト {name} を削除',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'タイプ',
|
||||
'examples.surfaceLabel': 'サーフェス',
|
||||
|
|
|
|||
|
|
@ -442,6 +442,22 @@ export const ko: Dict = {
|
|||
'designs.viewKanban': '보드 보기',
|
||||
'designs.kanbanEmptyColumn': '디자인 없음',
|
||||
'designs.deleteAria': '프로젝트 삭제 {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': '유형',
|
||||
'examples.surfaceLabel': '대상 표면 (Surface)',
|
||||
|
|
|
|||
|
|
@ -442,6 +442,22 @@ export const pl: Dict = {
|
|||
'designs.viewKanban': 'Widok tablicy',
|
||||
'designs.kanbanEmptyColumn': 'Brak projektów',
|
||||
'designs.deleteAria': 'Usuń projekt {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'Typ',
|
||||
'examples.surfaceLabel': 'Powierzchnia',
|
||||
|
|
|
|||
|
|
@ -463,6 +463,22 @@ export const ptBR: Dict = {
|
|||
'designs.viewKanban': 'Visualização em quadro',
|
||||
'designs.kanbanEmptyColumn': 'Sem designs',
|
||||
'designs.deleteAria': 'Excluir projeto {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'Tipo',
|
||||
'examples.surfaceLabel': 'Superfície',
|
||||
|
|
|
|||
|
|
@ -463,6 +463,22 @@ export const ru: Dict = {
|
|||
'designs.viewKanban': 'Вид доской',
|
||||
'designs.kanbanEmptyColumn': 'Нет дизайнов',
|
||||
'designs.deleteAria': 'Удалить проект {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'Тип',
|
||||
'examples.surfaceLabel': 'Поверхность',
|
||||
|
|
|
|||
|
|
@ -432,6 +432,22 @@ export const th: Dict = {
|
|||
'designs.viewKanban': 'บอร์ด',
|
||||
'designs.kanbanEmptyColumn': 'ไม่มีรายการ',
|
||||
'designs.deleteAria': 'ลบ {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'ประเภท',
|
||||
'examples.surfaceLabel': 'พื้นที่',
|
||||
|
|
|
|||
|
|
@ -431,6 +431,22 @@ export const tr: Dict = {
|
|||
'designs.viewKanban': 'Tahta görünümü',
|
||||
'designs.kanbanEmptyColumn': 'Tasarım yok',
|
||||
'designs.deleteAria': '{name} projesini sil',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
|
||||
'examples.typeLabel': 'Tip',
|
||||
'examples.surfaceLabel': 'Yüzey',
|
||||
|
|
|
|||
|
|
@ -455,6 +455,22 @@ export const uk: Dict = {
|
|||
'designs.viewKanban': 'Вигляд дошки',
|
||||
'designs.kanbanEmptyColumn': 'Немає дизайнів',
|
||||
'designs.deleteAria': 'Видалити проект {name}',
|
||||
'designs.menuMore': 'More actions',
|
||||
'designs.menuRename': 'Rename',
|
||||
'designs.menuDelete': 'Delete',
|
||||
'designs.renamePrompt': 'New name for "{name}"',
|
||||
'designs.selectMode': 'Select',
|
||||
'designs.cancelSelect': 'Cancel',
|
||||
'designs.deleteSelected': 'Delete selected',
|
||||
'designs.selectedCount': '{n} selected',
|
||||
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
|
||||
'designs.tagPrototype': 'Prototype',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': 'Rename project',
|
||||
'designs.renameSave': 'OK',
|
||||
'designs.renameCancel': 'Cancel',
|
||||
'designs.badgeLive': 'Live',
|
||||
'designs.liveArtifactBadgesAria': 'Мітки live-артефакту',
|
||||
'designs.liveCount': '{n} live-артефактів',
|
||||
|
|
|
|||
|
|
@ -512,6 +512,22 @@ export const zhCN: Dict = {
|
|||
'designs.viewKanban': '看板视图',
|
||||
'designs.kanbanEmptyColumn': '暂无设计',
|
||||
'designs.deleteAria': '删除项目 {name}',
|
||||
'designs.menuMore': '更多操作',
|
||||
'designs.menuRename': '重命名',
|
||||
'designs.menuDelete': '删除',
|
||||
'designs.renamePrompt': '为「{name}」输入新名称',
|
||||
'designs.selectMode': '选择',
|
||||
'designs.cancelSelect': '取消',
|
||||
'designs.deleteSelected': '删除所选',
|
||||
'designs.selectedCount': '已选择 {n} 项',
|
||||
'designs.deleteSelectedConfirm': '确定删除选中的 {n} 个项目?',
|
||||
'designs.tagPrototype': '原型',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': '重命名项目',
|
||||
'designs.renameSave': '确定',
|
||||
'designs.renameCancel': '取消',
|
||||
|
||||
'examples.typeLabel': '类型',
|
||||
'examples.surfaceLabel': '类型',
|
||||
|
|
|
|||
|
|
@ -505,6 +505,22 @@ export const zhTW: Dict = {
|
|||
'designs.viewKanban': '看板檢視',
|
||||
'designs.kanbanEmptyColumn': '暫無設計',
|
||||
'designs.deleteAria': '刪除專案 {name}',
|
||||
'designs.menuMore': '更多操作',
|
||||
'designs.menuRename': '重新命名',
|
||||
'designs.menuDelete': '刪除',
|
||||
'designs.renamePrompt': '為「{name}」輸入新名稱',
|
||||
'designs.selectMode': '選擇',
|
||||
'designs.cancelSelect': '取消',
|
||||
'designs.deleteSelected': '刪除所選',
|
||||
'designs.selectedCount': '已選擇 {n} 項',
|
||||
'designs.deleteSelectedConfirm': '確定刪除選取的 {n} 個專案?',
|
||||
'designs.tagPrototype': '原型',
|
||||
'designs.tagLiveArtifact': 'Live Artifact',
|
||||
'designs.tagSlide': 'Slide',
|
||||
'designs.tagMedia': 'Media',
|
||||
'designs.renameTitle': '重新命名專案',
|
||||
'designs.renameSave': '確定',
|
||||
'designs.renameCancel': '取消',
|
||||
|
||||
'examples.typeLabel': '類型',
|
||||
'examples.surfaceLabel': '類型',
|
||||
|
|
|
|||
|
|
@ -766,6 +766,22 @@ export interface Dict {
|
|||
'designs.viewKanban': string;
|
||||
'designs.kanbanEmptyColumn': string;
|
||||
'designs.deleteAria': string;
|
||||
'designs.menuMore': string;
|
||||
'designs.menuRename': string;
|
||||
'designs.menuDelete': string;
|
||||
'designs.renamePrompt': string;
|
||||
'designs.selectMode': string;
|
||||
'designs.cancelSelect': string;
|
||||
'designs.deleteSelected': string;
|
||||
'designs.selectedCount': string;
|
||||
'designs.deleteSelectedConfirm': string;
|
||||
'designs.tagPrototype': string;
|
||||
'designs.tagLiveArtifact': string;
|
||||
'designs.tagSlide': string;
|
||||
'designs.tagMedia': string;
|
||||
'designs.renameTitle': string;
|
||||
'designs.renameSave': string;
|
||||
'designs.renameCancel': string;
|
||||
|
||||
// Examples tab
|
||||
'examples.typeLabel': string;
|
||||
|
|
|
|||
|
|
@ -1436,6 +1436,113 @@ code {
|
|||
}
|
||||
.modal .row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||
|
||||
/* Compact rename modal */
|
||||
.modal-rename {
|
||||
width: 420px;
|
||||
gap: 14px;
|
||||
}
|
||||
.modal-rename input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.modal-rename input[type="text"]:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
.modal-rename .row button {
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.modal-rename .row button:hover { border-color: var(--border-strong); }
|
||||
.modal-rename .row button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.modal-rename .row button.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Confirm modal — same shell, danger primary */
|
||||
.modal-confirm {
|
||||
width: 420px;
|
||||
gap: 12px;
|
||||
}
|
||||
.modal-confirm-message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.modal-confirm .row { margin-top: 8px; }
|
||||
.modal-confirm .row button {
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.modal-confirm .row button:hover { border-color: var(--border-strong); }
|
||||
.modal-confirm .row button.primary.danger {
|
||||
background: var(--red);
|
||||
border-color: var(--red);
|
||||
color: #fff;
|
||||
}
|
||||
.modal-confirm .row button.primary.danger:hover { filter: brightness(0.95); }
|
||||
|
||||
/* Project category tags */
|
||||
.design-card-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.01em;
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.design-card-tag.tag-prototype {
|
||||
color: #2348b8;
|
||||
background: #e8efff;
|
||||
border-color: rgba(35, 72, 184, 0.18);
|
||||
}
|
||||
.design-card-tag.tag-live-artifact {
|
||||
color: #6d4ff5;
|
||||
background: rgba(116, 92, 255, 0.12);
|
||||
border-color: rgba(116, 92, 255, 0.28);
|
||||
}
|
||||
.design-card-tag.tag-slide {
|
||||
color: #b15e00;
|
||||
background: rgba(255, 159, 64, 0.14);
|
||||
border-color: rgba(255, 159, 64, 0.32);
|
||||
}
|
||||
.design-card-tag.tag-media {
|
||||
color: #1c8a73;
|
||||
background: rgba(28, 138, 115, 0.12);
|
||||
border-color: rgba(28, 138, 115, 0.28);
|
||||
}
|
||||
|
||||
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes pop-in {
|
||||
from { opacity: 0; transform: translateY(6px) scale(0.98); }
|
||||
|
|
@ -6698,6 +6805,142 @@ button.connector-action.is-loading {
|
|||
.design-kanban-card:focus-within .design-card-close,
|
||||
.design-card-close:focus-visible { opacity: 1; }
|
||||
.design-card-close:hover { color: var(--text-strong); border-color: var(--border-strong); }
|
||||
|
||||
/* Overflow menu trigger and dropdown on project cards */
|
||||
.design-card-menu-anchor {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
}
|
||||
.design-card-more {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.design-card:hover .design-card-more,
|
||||
.design-card:focus-within .design-card-more,
|
||||
.design-card-more[aria-expanded="true"],
|
||||
.design-card-more:focus-visible { opacity: 1; }
|
||||
.design-card-more:hover { color: var(--text-strong); border-color: var(--border-strong); }
|
||||
@media (hover: none) {
|
||||
.design-card .design-card-more { opacity: 1; }
|
||||
}
|
||||
.design-card-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 144px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 50;
|
||||
}
|
||||
.design-card-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-strong);
|
||||
font-size: 12.5px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.design-card-menu button:hover { background: var(--bg-subtle); }
|
||||
.design-card-menu button.danger { color: var(--red); }
|
||||
.design-card-menu button.danger:hover { background: color-mix(in srgb, var(--red) 12%, transparent); }
|
||||
|
||||
/* Multi-select checkbox + selected state */
|
||||
.design-card-checkbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bg-panel);
|
||||
z-index: 3;
|
||||
}
|
||||
.design-card-checkbox.checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.design-card.is-selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
}
|
||||
.design-card.select-mode { cursor: pointer; }
|
||||
|
||||
/* Select-mode toolbar controls */
|
||||
.designs-select-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-strong);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.designs-select-toggle:hover { border-color: var(--border-strong); }
|
||||
.designs-select-bar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px 4px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.designs-select-count {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.designs-select-delete {
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 0;
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.designs-select-delete:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.designs-select-cancel {
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.designs-select-cancel:hover { color: var(--text-strong); border-color: var(--border-strong); }
|
||||
/* Larger comfortable touch target on coarse pointers (tablets/touch laptops).
|
||||
On touch the hover reveal never fires, so keep the close button visible. */
|
||||
@media (hover: none) {
|
||||
|
|
@ -6721,6 +6964,55 @@ button.connector-action.is-loading {
|
|||
.live-artifact-card:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 48%, var(--border-strong));
|
||||
}
|
||||
.project-thumb::before,
|
||||
.project-thumb::after {
|
||||
display: none;
|
||||
}
|
||||
.design-card-thumb .thumb-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.design-card-thumb .thumb-iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 250%;
|
||||
height: 250%;
|
||||
transform: scale(0.4);
|
||||
transform-origin: top left;
|
||||
border: 0;
|
||||
background: var(--bg-panel);
|
||||
pointer-events: none;
|
||||
}
|
||||
.project-thumb-image,
|
||||
.project-thumb-video,
|
||||
.project-thumb-html {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.project-thumb-image .project-thumb-glyph,
|
||||
.project-thumb-video .project-thumb-glyph,
|
||||
.project-thumb-html .project-thumb-glyph {
|
||||
display: none;
|
||||
}
|
||||
.project-thumb-glyph {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-strong);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-xs);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.live-artifact-thumb {
|
||||
background:
|
||||
radial-gradient(circle at 32% 28%, rgba(116, 92, 255, 0.18), transparent 36%),
|
||||
|
|
@ -17591,21 +17883,21 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
.palette-tweaks {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 60;
|
||||
min-width: 240px; background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
border-radius: var(--radius-md, 10px);
|
||||
box-shadow: var(--shadow-md, 0 8px 24px rgba(0, 0, 0, 0.12));
|
||||
padding: 8px; display: flex; flex-direction: column; gap: 6px; user-select: none;
|
||||
}
|
||||
.palette-tweaks-header {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
padding: 4px 6px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border-subtle, var(--border));
|
||||
}
|
||||
.palette-tweaks-title { font-size: 12px; font-weight: 600; color: var(--text); }
|
||||
.palette-tweaks-sub { font-size: 11px; color: var(--text-muted); }
|
||||
.palette-tweaks-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.palette-tweaks-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 8px; border-radius: 6px;
|
||||
padding: 6px 8px; border-radius: var(--radius-sm, 6px);
|
||||
cursor: pointer; font-size: 12.5px; color: var(--text);
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
|
@ -17626,7 +17918,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
var(--text-muted) calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
);
|
||||
border: 1px dashed var(--border); border-radius: 4px;
|
||||
border: 1px dashed var(--border-strong, var(--border)); border-radius: 4px;
|
||||
}
|
||||
.palette-tweaks-label { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.palette-tweaks-check { display: inline-flex; align-items: center; color: var(--text-muted); }
|
||||
|
|
|
|||
80
apps/web/tests/components/DesignsTab.select-mode.test.tsx
Normal file
80
apps/web/tests/components/DesignsTab.select-mode.test.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DesignsTab } from '../../src/components/DesignsTab';
|
||||
import type { Project } from '../../src/types';
|
||||
|
||||
vi.mock('../../src/providers/registry', () => ({
|
||||
deleteLiveArtifact: vi.fn(),
|
||||
fetchLiveArtifacts: vi.fn(async () => []),
|
||||
fetchProjectFiles: vi.fn(async () => []),
|
||||
liveArtifactPreviewUrl: (projectId: string, artifactId: string) =>
|
||||
`/api/projects/${projectId}/live-artifacts/${artifactId}/preview`,
|
||||
projectFileUrl: (projectId: string, fileName: string) =>
|
||||
`/api/projects/${projectId}/files/${fileName}`,
|
||||
}));
|
||||
|
||||
const project: Project = {
|
||||
id: 'project-1',
|
||||
name: 'Landing refresh',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
status: { value: 'not_started' },
|
||||
};
|
||||
|
||||
describe('DesignsTab select mode', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('only exposes select mode in grid view', () => {
|
||||
render(
|
||||
<DesignsTab
|
||||
projects={[project]}
|
||||
skills={[]}
|
||||
designSystems={[]}
|
||||
onOpen={vi.fn()}
|
||||
onOpenLiveArtifact={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Select' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByTestId('designs-view-kanban'));
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Select' })).toBeNull();
|
||||
});
|
||||
|
||||
it('exits select mode when switching to kanban view', () => {
|
||||
render(
|
||||
<DesignsTab
|
||||
projects={[project]}
|
||||
skills={[]}
|
||||
designSystems={[]}
|
||||
onOpen={vi.fn()}
|
||||
onOpenLiveArtifact={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
|
||||
expect(screen.getByText('0 selected')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByTestId('designs-view-kanban'));
|
||||
fireEvent.click(screen.getByTestId('designs-view-grid'));
|
||||
|
||||
expect(screen.queryByText('0 selected')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Select' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -253,20 +253,25 @@ test('home design card deletion supports cancel and confirm flows', async ({ pag
|
|||
const designCard = homeDesignCard(page, projectName);
|
||||
await expect(designCard).toBeVisible();
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toContain(projectName);
|
||||
await dialog.dismiss();
|
||||
});
|
||||
// Cancel flow: open the overflow menu, choose Delete, then dismiss the confirm modal.
|
||||
await designCard.hover();
|
||||
await designCard.getByRole('button', { name: new RegExp(`delete project ${escapeRegExp(projectName)}`, 'i') }).click();
|
||||
await designCard.getByRole('button', { name: /more actions/i }).click();
|
||||
await page.getByRole('menuitem', { name: /^delete$/i }).click();
|
||||
const confirmDialog = page.locator('.modal-confirm');
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await expect(confirmDialog).toContainText(projectName);
|
||||
await confirmDialog.getByRole('button', { name: /^cancel$/i }).click();
|
||||
await expect(confirmDialog).toHaveCount(0);
|
||||
await expect(designCard).toBeVisible();
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toContain(projectName);
|
||||
await dialog.accept();
|
||||
});
|
||||
// Confirm flow: same trigger, this time accept the confirm modal.
|
||||
await designCard.hover();
|
||||
await designCard.getByRole('button', { name: new RegExp(`delete project ${escapeRegExp(projectName)}`, 'i') }).click();
|
||||
await designCard.getByRole('button', { name: /more actions/i }).click();
|
||||
await page.getByRole('menuitem', { name: /^delete$/i }).click();
|
||||
const confirmDialog2 = page.locator('.modal-confirm');
|
||||
await expect(confirmDialog2).toBeVisible();
|
||||
await expect(confirmDialog2).toContainText(projectName);
|
||||
await confirmDialog2.getByRole('button', { name: /^delete$/i }).click();
|
||||
await expect(homeDesignCard(page, projectName)).toHaveCount(0);
|
||||
|
||||
const response = await page.request.get(`/api/projects/${projectId}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue