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,87 @@
|
||||
.canvas-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 48px;
|
||||
z-index: 120;
|
||||
width: min(1040px, calc(100vw - 140px));
|
||||
min-height: 126px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 0 44px;
|
||||
border: 1px solid rgba(224, 229, 238, 0.96);
|
||||
border-radius: 24px;
|
||||
color: #1f2329;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 38px 90px rgba(17, 24, 39, 0.16);
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
animation: canvas-toast-enter 180ms cubic-bezier(0.2, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.canvas-toast-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.canvas-toast.success .canvas-toast-icon {
|
||||
background: #45d27c;
|
||||
}
|
||||
|
||||
.canvas-toast.error .canvas-toast-icon {
|
||||
background: #e5484d;
|
||||
}
|
||||
|
||||
.canvas-toast strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 32px;
|
||||
font-weight: 850;
|
||||
line-height: 1.18;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@keyframes canvas-toast-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.canvas-toast {
|
||||
top: 18px;
|
||||
width: calc(100vw - 32px);
|
||||
min-height: 76px;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.canvas-toast-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.canvas-toast-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.canvas-toast strong {
|
||||
font-size: 18px;
|
||||
line-height: 1.28;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { Check, X } from "lucide-react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import "./index.css";
|
||||
|
||||
type ToastTone = "success" | "error";
|
||||
|
||||
type GlobalToastState = {
|
||||
message: string;
|
||||
tone: ToastTone;
|
||||
};
|
||||
|
||||
type GlobalToastContextValue = {
|
||||
showToast: (message: string, tone?: ToastTone) => void;
|
||||
showSuccess: (message: string) => void;
|
||||
showError: (message: string) => void;
|
||||
hideToast: () => void;
|
||||
};
|
||||
|
||||
const GlobalToastContext = createContext<GlobalToastContextValue | null>(null);
|
||||
|
||||
export function GlobalToastProvider({ children }: { children: ReactNode }) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [toast, setToast] = useState<GlobalToastState | null>(null);
|
||||
|
||||
const hideToast = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setToast(null);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((message: string, tone: ToastTone = "success") => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setToast({ message, tone });
|
||||
timerRef.current = setTimeout(() => {
|
||||
setToast(null);
|
||||
timerRef.current = null;
|
||||
}, tone === "error" ? 3800 : 2600);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
showToast,
|
||||
showSuccess: (message: string) => showToast(message, "success"),
|
||||
showError: (message: string) => showToast(message, "error"),
|
||||
hideToast
|
||||
}),
|
||||
[hideToast, showToast]
|
||||
);
|
||||
|
||||
return (
|
||||
<GlobalToastContext.Provider value={value}>
|
||||
{children}
|
||||
<CanvasToast message={toast?.message ?? ""} tone={toast?.tone ?? "success"} visible={Boolean(toast)} />
|
||||
</GlobalToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useGlobalToast() {
|
||||
const context = useContext(GlobalToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useGlobalToast must be used inside GlobalToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function CanvasToast({ message, visible, tone = "success" }: { message: string; visible: boolean; tone?: ToastTone }) {
|
||||
if (!visible) return null;
|
||||
const Icon = tone === "error" ? X : Check;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`canvas-toast ${tone === "error" ? "error" : "success"}`}
|
||||
role={tone === "error" ? "alert" : "status"}
|
||||
aria-live={tone === "error" ? "assertive" : "polite"}
|
||||
>
|
||||
<span className="canvas-toast-icon" aria-hidden="true">
|
||||
<Icon size={26} />
|
||||
</span>
|
||||
<strong>{message}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user