Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
.workspace-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.moteva-mark {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.workspace-brand-mark {
|
||||
width: 0.79rem;
|
||||
height: 1.1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.workspace-title-name {
|
||||
min-width: 0;
|
||||
max-width: 170px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
color: #1f2329;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace-title-name:hover {
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.workspace-title-name strong {
|
||||
overflow: hidden;
|
||||
max-width: 160px;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-title-edit {
|
||||
width: min(220px, 28vw);
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace-title-edit input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #1f2329;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.title-ghost {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: #737780;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.title-ghost:hover {
|
||||
background: #f2f3f5;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ChevronDown, Copy, Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { BrandMark } from "@/ui/components/BrandMark";
|
||||
|
||||
type WorkspaceTitleProps = {
|
||||
title: string;
|
||||
untitledLabel: string;
|
||||
renameLabel: string;
|
||||
placeholder: string;
|
||||
backHomeLabel: string;
|
||||
copyLabel: string;
|
||||
moreLabel: string;
|
||||
onBackHome: () => void;
|
||||
onRename: (title: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function WorkspaceTitle({
|
||||
title,
|
||||
untitledLabel,
|
||||
renameLabel,
|
||||
placeholder,
|
||||
backHomeLabel,
|
||||
copyLabel,
|
||||
moreLabel,
|
||||
onBackHome,
|
||||
onRename
|
||||
}: WorkspaceTitleProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const committingRef = useRef(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState(title);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const trimmedTitle = title.trim();
|
||||
const displayTitle = trimmedTitle || untitledLabel;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraftTitle(title);
|
||||
}, [editing, title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.select();
|
||||
}, [editing]);
|
||||
|
||||
const startEditing = () => {
|
||||
if (renaming) return;
|
||||
setDraftTitle(trimmedTitle);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setDraftTitle(title);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const commitEditing = async () => {
|
||||
if (committingRef.current) return;
|
||||
const nextTitle = draftTitle.trim();
|
||||
if (!nextTitle || nextTitle === trimmedTitle) {
|
||||
cancelEditing();
|
||||
return;
|
||||
}
|
||||
committingRef.current = true;
|
||||
setRenaming(true);
|
||||
try {
|
||||
await onRename(nextTitle);
|
||||
setEditing(false);
|
||||
} catch {
|
||||
inputRef.current?.focus();
|
||||
} finally {
|
||||
committingRef.current = false;
|
||||
setRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workspace-title">
|
||||
<button className="moteva-mark" type="button" onClick={onBackHome} aria-label={backHomeLabel}>
|
||||
<BrandMark className="workspace-brand-mark" />
|
||||
</button>
|
||||
{editing ? (
|
||||
<span className="workspace-title-edit">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftTitle}
|
||||
placeholder={placeholder}
|
||||
readOnly={renaming}
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
onBlur={() => void commitEditing()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commitEditing();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelEditing();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{renaming && <Loader2 className="spin" size={13} />}
|
||||
</span>
|
||||
) : (
|
||||
<button className="workspace-title-name" type="button" title={displayTitle} aria-label={renameLabel} onClick={startEditing}>
|
||||
<strong>{displayTitle}</strong>
|
||||
</button>
|
||||
)}
|
||||
<button className="title-ghost" type="button" aria-label={copyLabel}>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button className="title-ghost" type="button" aria-label={moreLabel}>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user