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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user