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:
2026-07-07 23:15:37 +08:00
commit 44406b72db
349 changed files with 73265 additions and 0 deletions
@@ -0,0 +1,227 @@
.projects-main {
width: min(1180px, calc(100% - 168px));
margin: 0 auto;
padding: 112px 0 150px 86px;
transition: filter 180ms ease, transform 180ms ease;
}
.projects-shell.is-auth-locked .projects-main {
filter: blur(18px);
pointer-events: none;
user-select: none;
transform: scale(0.985);
}
.projects-page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 34px;
}
.projects-title-lockup {
min-width: 0;
display: flex;
align-items: center;
gap: 16px;
}
.projects-title-lockup > span {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 8px;
color: #111827;
background: #fff;
box-shadow: var(--shadow-sm);
}
.projects-title-lockup h1,
.projects-title-lockup p {
margin: 0;
}
.projects-title-lockup h1 {
color: #050505;
font-size: 30px;
line-height: 1.2;
}
.projects-title-lockup p {
margin-top: 7px;
color: #7d8492;
font-size: 14px;
font-weight: 700;
}
.projects-edit-button {
height: 38px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 15px;
border: 1px solid var(--border);
border-radius: 999px;
color: #111827;
background: #fff;
box-shadow: var(--shadow-sm);
font-size: 13px;
font-weight: 850;
}
.projects-edit-button.active,
.projects-edit-button:hover {
color: #fff;
border-color: #050505;
background: #050505;
}
.projects-edit-button:disabled {
opacity: 0.72;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 28px;
}
.project-placeholder {
display: grid;
gap: 12px;
}
.project-placeholder-thumb {
aspect-ratio: 4 / 3;
border: 1px solid rgba(203, 210, 220, 0.74);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.58), rgba(226, 232, 240, 0.82)),
linear-gradient(90deg, rgba(17, 24, 39, 0.04), rgba(17, 24, 39, 0));
}
.project-placeholder-title,
.project-placeholder-meta {
display: block;
height: 16px;
border-radius: 999px;
background: rgba(203, 210, 220, 0.68);
}
.project-placeholder-title {
width: 78%;
}
.project-placeholder-meta {
width: 46%;
height: 13px;
}
.projects-empty {
margin: 30px 0 0;
color: #7d8492;
font-size: 14px;
font-weight: 750;
}
.projects-sentinel {
min-height: 72px;
display: grid;
place-items: center;
color: #7d8492;
font-size: 13px;
font-weight: 800;
}
.projects-sentinel span,
.projects-sentinel button {
display: inline-flex;
align-items: center;
gap: 8px;
}
.projects-sentinel button {
height: 36px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: 999px;
color: #111827;
background: #fff;
font-size: 13px;
font-weight: 850;
}
.projects-bulk-bar {
position: fixed;
left: 50%;
bottom: 28px;
z-index: 60;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px;
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(14px);
transform: translateX(-50%);
}
.projects-bulk-bar span {
padding: 0 10px;
color: #111827;
font-size: 13px;
font-weight: 850;
white-space: nowrap;
}
.projects-bulk-bar button {
height: 34px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 13px;
border-radius: 999px;
color: #111827;
background: #f4f5f7;
font-size: 13px;
font-weight: 850;
white-space: nowrap;
}
.projects-bulk-bar button.danger {
color: #fff;
background: #b42318;
}
@media (max-width: 980px) {
.projects-main {
width: min(100% - 32px, 760px);
padding-left: 0;
}
.projects-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.projects-grid {
grid-template-columns: 1fr;
}
.projects-page-header {
align-items: flex-start;
flex-direction: column;
}
.projects-bulk-bar {
width: calc(100% - 28px);
justify-content: space-between;
border-radius: 8px;
}
}
@@ -0,0 +1,296 @@
"use client";
import { Check, Folder, LayoutGrid, Loader2, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ProjectSummary } from "@/domain/design";
import { useI18n } from "@/i18n/i18n";
import { designGateway } from "@/infrastructure/designGateway";
import { openProject } from "@/application/route";
import { useGlobalToast } from "@/ui/components/CanvasToast";
import { DeleteProjectDialog } from "@/ui/components/DeleteProjectDialog";
import { ProjectCard } from "@/ui/components/ProjectCard";
import { useUserMessage } from "@/ui/hooks/useUserMessage";
const projectPageSize = 20;
const lockedProjectPlaceholders = Array.from({ length: 8 }, (_, index) => `locked-project-${index}`);
type ProjectsPageProps = {
locked?: boolean;
creating: boolean;
onCreateProject: () => void;
};
export function ProjectsPage({ locked = false, creating, onCreateProject }: ProjectsPageProps) {
const { t } = useI18n();
const toUserMessageText = useUserMessage();
const { showError } = useGlobalToast();
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [nextOffset, setNextOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [projectPendingDelete, setProjectPendingDelete] = useState<ProjectSummary | null>(null);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const selectedCount = selectedIds.size;
const selectedIdList = useMemo(() => Array.from(selectedIds), [selectedIds]);
const loadProjects = useCallback(
async (reset = false) => {
if (locked || loading || (!reset && !hasMore)) return;
setLoading(true);
try {
const page = await designGateway.listProjects(projectPageSize, reset ? 0 : nextOffset);
setProjects((current) => mergeProjects(reset ? [] : current, page.projects));
setNextOffset(page.nextOffset);
setHasMore(page.hasMore);
} catch (err) {
showError(toUserMessageText(err, "createError"));
} finally {
setLoading(false);
}
},
[hasMore, loading, locked, nextOffset, showError, toUserMessageText]
);
useEffect(() => {
if (locked) {
setProjects([]);
setNextOffset(0);
setHasMore(false);
setLoading(false);
setEditMode(false);
setSelectedIds(new Set<string>());
setProjectPendingDelete(null);
setBulkDeleteOpen(false);
return;
}
void loadProjects(true);
}, [locked]);
useEffect(() => {
if (locked) return;
const node = sentinelRef.current;
if (!node || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) void loadProjects();
},
{ rootMargin: "420px" }
);
observer.observe(node);
return () => observer.disconnect();
}, [hasMore, loadProjects, locked]);
const toggleEditMode = () => {
if (locked) return;
setEditMode((current) => {
if (current) setSelectedIds(new Set());
return !current;
});
};
const toggleProject = (project: ProjectSummary) => {
if (locked) return;
if (!editMode) {
openProject(project.id);
return;
}
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(project.id)) {
next.delete(project.id);
} else {
next.add(project.id);
}
return next;
});
};
const requestDeleteProject = (project: ProjectSummary) => {
if (locked) return;
setProjectPendingDelete(project);
};
const cancelDelete = () => {
if (deleting) return;
setProjectPendingDelete(null);
setBulkDeleteOpen(false);
};
const deleteSingleProject = async () => {
const projectId = projectPendingDelete?.id;
if (!projectId) return;
setDeleting(true);
try {
await designGateway.deleteProject(projectId);
setProjects((current) => current.filter((project) => project.id !== projectId));
setSelectedIds((current) => {
const next = new Set(current);
next.delete(projectId);
return next;
});
setProjectPendingDelete(null);
} catch (err) {
showError(toUserMessageText(err, "generateError"));
} finally {
setDeleting(false);
}
};
const deleteSelectedProjects = async () => {
if (selectedIdList.length === 0) return;
setDeleting(true);
try {
await designGateway.deleteProjects(selectedIdList);
const deleted = new Set(selectedIdList);
setProjects((current) => current.filter((project) => !deleted.has(project.id)));
setSelectedIds(new Set());
setBulkDeleteOpen(false);
setEditMode(false);
} catch (err) {
showError(toUserMessageText(err, "generateError"));
} finally {
setDeleting(false);
}
};
return (
<main className={`projects-main ${locked ? "is-locked" : ""}`} aria-hidden={locked}>
<header className="projects-page-header">
<div className="projects-title-lockup">
<span>
<Folder size={18} />
</span>
<div>
<h1>{t("personalProjects")}</h1>
<p>{t("projectLibrarySubtitle")}</p>
</div>
</div>
<button
className={`projects-edit-button ${editMode ? "active" : ""}`}
type="button"
onClick={toggleEditMode}
aria-pressed={editMode}
disabled={locked}
>
{editMode ? <Check size={16} /> : <LayoutGrid size={16} />}
{editMode ? t("doneEditing") : t("editProjects")}
</button>
</header>
<section className="projects-grid" aria-label={t("personalProjects")}>
<button className="new-project-card" onClick={onCreateProject} disabled={creating || locked}>
<span>{creating ? <Loader2 className="spin" size={24} /> : <Plus size={26} />}</span>
{t("newProject")}
</button>
{locked
? lockedProjectPlaceholders.map((id) => <ProjectPlaceholder key={id} />)
: projects.map((project) => {
const selected = selectedIds.has(project.id);
return (
<ProjectCard
key={project.id}
project={project}
editMode={editMode}
selected={selected}
onActivate={() => toggleProject(project)}
onDelete={() => requestDeleteProject(project)}
/>
);
})}
</section>
{!locked && !loading && projects.length === 0 && <p className="projects-empty">{t("projectsEmpty")}</p>}
{!locked && (
<div ref={sentinelRef} className="projects-sentinel">
{loading && (
<span>
<Loader2 className="spin" size={16} />
{t("loadingProjects")}
</span>
)}
{!loading && hasMore && <button onClick={() => void loadProjects()}>{t("loadMoreProjects")}</button>}
</div>
)}
{!locked && editMode && selectedCount > 0 && (
<div className="projects-bulk-bar">
<span>{t("selectedProjectsCount", { count: selectedCount })}</span>
<button type="button" className="danger" onClick={() => setBulkDeleteOpen(true)}>
<Trash2 size={15} />
{t("deleteSelected")}
</button>
<button
type="button"
onClick={() => {
setSelectedIds(new Set());
setEditMode(false);
}}
>
{t("cancel")}
</button>
</div>
)}
{!locked && (
<>
<DeleteProjectDialog
project={projectPendingDelete}
busy={deleting}
title={t("deleteProjectTitle")}
warning={t("deleteProjectWarning")}
details={t("deleteProjectDetails")}
cancelLabel={t("deleteProjectCancel")}
confirmLabel={t("deleteProjectConfirm")}
deletingLabel={t("deletingProject")}
closeLabel={t("close")}
onCancel={cancelDelete}
onConfirm={() => void deleteSingleProject()}
/>
<DeleteProjectDialog
project={null}
targetLabel={bulkDeleteOpen ? t("selectedProjectsCount", { count: selectedCount }) : ""}
busy={deleting}
title={t("deleteProjectsTitle")}
warning={t("deleteProjectsWarning")}
details={t("deleteProjectsDetails")}
cancelLabel={t("deleteProjectCancel")}
confirmLabel={t("deleteProjectsConfirm")}
deletingLabel={t("deletingProject")}
closeLabel={t("close")}
onCancel={cancelDelete}
onConfirm={() => void deleteSelectedProjects()}
/>
</>
)}
</main>
);
}
function ProjectPlaceholder() {
return (
<div className="project-card-shell project-placeholder" aria-hidden="true">
<span className="project-placeholder-thumb" />
<span className="project-placeholder-title" />
<span className="project-placeholder-meta" />
</div>
);
}
function mergeProjects(current: ProjectSummary[], incoming: ProjectSummary[]) {
const seen = new Set(current.map((project) => project.id));
const next = [...current];
incoming.forEach((project) => {
if (!seen.has(project.id)) {
seen.add(project.id);
next.push(project);
}
});
return next;
}