You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

1623 lines
50 KiB

"use client";
import { useRouter } from "next/navigation";
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
ProjectActionsDropdown,
type ProjectActionKey,
} from "@/components/ProjectActionsDropdown";
import {
CanvasTemplatePreviewModal,
type CanvasTemplatePreviewData,
} from "@/components/CanvasTemplatePreviewModal";
import {
ArrowIcon,
CloseIcon,
DefaultAvatar,
LeftOutlinedIcon,
SearchIcon,
StartCreation,
} from "@/components/icons";
import { authFetch } from "@/utils/authFetch";
import { useI18n, type TranslationKey } from "@/i18n";
import {
createCanvasWorkflow,
deleteCanvasWorkflow,
getCanvasWorkflowDetail,
listCanvasWorkflows,
type CanvasWorkflowListItem,
type CanvasWorkflowPageInfo,
updateCanvasWorkflow,
uploadCanvasWorkflowCover,
} from "@/lib/canvasWorkflowApi";
import { Form, Input, Modal, Tooltip, message } from "antd";
type Translate = (key: TranslationKey, values?: Record<string, string | number>) => string;
type ProjectData = {
id: number;
title: string;
date: string;
image: string;
uploadProgress?: number;
};
type ShowData = {
id: number;
title: string;
subtitle: string;
image: string;
avatar: string;
vip?: boolean;
data: string;
};
type TemplateCategoryData = {
id: number | null;
name: string;
};
type CanvasTemplateCategoryItem = {
id: number;
name: string;
sort: number;
deleted: boolean;
};
type CanvasTemplateItem = {
id: number;
categoryId: number;
type: number;
coverUrl: string;
properties: string;
name: string;
desp: string;
deleted: boolean;
categoryName: string;
userInfo: {
avatar: string;
code: string;
id: number;
name: string;
}
};
type ListResponse<T> = {
data?: {
pageInfo?: CanvasWorkflowPageInfo;
list?: T[];
};
status?: string;
};
type HomeProjectsContentProps = {
heroSection: ReactNode;
};
const DEFAULT_PROJECT_PAGE_INFO: CanvasWorkflowPageInfo = {
page: 1,
pageSize: 4,
pageCount: 1,
total: 0,
};
const ALL_PROJECTS_LOAD_MORE_OFFSET = 260;
const SHOWS_PAGE_SIZE = 20;
const SHOWS_LOAD_MORE_OFFSET = 260;
function formatProjectTime(value?: string): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString();
}
function mapWorkflowToProject(item: CanvasWorkflowListItem): ProjectData {
return {
id: item.id,
title: item.title?.trim() || item.code?.trim() || `#${item.id}`,
date: formatProjectTime(item.updateTime || item.createTime),
image: item.coverUrl || "",
};
}
function buildEmptyWorkflowProperties(title: string): string {
return JSON.stringify({
name: title,
nodes: [],
edges: [],
edgeStyle: "curved",
});
}
function ProjectCard({
id,
title,
date,
image,
uploadProgress,
t,
onOpen,
onAction,
}: ProjectData & {
t: Translate;
onOpen: () => void;
onAction: (id: number, action: ProjectActionKey) => void;
}) {
const isUploadingCover = typeof uploadProgress === "number";
return (
<article className="min-w-0">
<button
type="button"
onClick={onOpen}
disabled={isUploadingCover}
className="group relative block h-[139px] w-full overflow-hidden rounded-[24px] bg-[#ddd] text-left disabled:cursor-default"
>
<img
src={image || '/empty_project.png'}
alt=""
className="h-full w-full object-cover transition duration-500 group-hover:scale-105"
/>
{isUploadingCover ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/45 px-6 text-white">
<span className="text-[13px] font-medium leading-none">
{t("home.uploading", { progress: uploadProgress })}
</span>
<div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-white/35">
<div
className="h-full rounded-full bg-white transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
) : null}
</button>
<div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<button type="button" onClick={onOpen} className="block max-w-full text-left">
<h3 className="truncate text-[16px] font-medium leading-tight text-[#333]">
{title}
</h3>
</button>
<p className="mt-1 text-[13px] leading-tight text-[#666]">{date}</p>
</div>
<ProjectActionsDropdown
title={title}
label={t("home.moreActions")}
onAction={(action) => onAction(id, action)}
/>
</div>
</article>
);
}
function ShowCard({ id, title, subtitle, image, avatar, vip, data, onOpen }: ShowData & { onOpen: (data: ShowData) => void }) {
return (
<article className="group min-w-0" onClick={() => onOpen({ id, title, subtitle, image, avatar, vip, data })}>
<div className="h-[189px] overflow-hidden rounded-[24px] bg-[#ddd]">
<img
src={image || '/empty_project.png'}
alt=""
className="h-full w-full object-cover transition duration-500 group-hover:scale-105"
/>
</div>
<div className="mt-3">
<div className="flex items-center gap-[5px]">
{avatar ?
<img
src={avatar || '/empty_project.png'}
alt=""
className="grid h-5 w-5 place-items-center rounded-full bg-[#d6ceef] text-[11px]">
</img>
:
<div className="grid h-5 w-5 place-items-center rounded-full bg-[#d6ceef] text-[11px]">
<DefaultAvatar></DefaultAvatar>
</div>
}
<h3 className="truncate text-[16px] font-medium leading-tight text-[#333]">
{title}
</h3>
{/* {vip ? (
<span className="rounded-full bg-[linear-gradient(90deg,#d6ceef_45%,#f0f0f0_100%)] px-2.5 py-0.5 text-[12px] font-medium leading-none text-[#5c3bc7]">
VIP
</span>
) : null} */}
</div>
<Tooltip title={subtitle || '-'} placement="top">
<p className="mt-2 truncate text-[13px] leading-tight text-[#666]">
{subtitle || '-'}
</p>
</Tooltip>
</div>
</article>
);
}
function ProjectSectionGrid({
projects,
isLoadingProjects,
isCreating,
t,
showOverflowCue = false,
pageInfo,
onPrevPage,
onNextPage,
onStartCreation,
onProjectAction,
}: {
projects: ProjectData[];
isLoadingProjects: boolean;
isCreating: boolean;
t: Translate;
showOverflowCue?: boolean;
pageInfo?: CanvasWorkflowPageInfo;
onPrevPage?: () => void;
onNextPage?: () => void;
onStartCreation: () => void;
onProjectAction: (id: number, action: ProjectActionKey) => void;
}) {
const router = useRouter();
const canGoPrev = !!pageInfo && pageInfo.page > 1;
const canGoNext = !!pageInfo && pageInfo.page < pageInfo.pageCount;
const uniqueProjects = projects.filter(
(project, index, list) => list.findIndex((item) => item.id === project.id) === index
);
const [firstProject, ...remainingProjects] = uniqueProjects;
return (
<div className="relative grid gap-5 md:grid-cols-2 xl:grid-cols-5">
<button
type="button"
onClick={onStartCreation}
disabled={isCreating}
className="flex h-[139px] flex-col items-center justify-center rounded-[24px] border border-[#d6ceef] bg-[linear-gradient(123deg,#f0f0f0_20%,#d6ceef_100%)] transition hover:border-[#5c3bc7] disabled:cursor-not-allowed disabled:opacity-70"
>
<StartCreation />
<span className="mt-2 text-[18px] font-medium leading-none text-[#999]">
{t("home.startCreation")}
</span>
</button>
{isLoadingProjects && projects.length === 0 ? (
<div className="flex h-[139px] items-center justify-center rounded-[24px] bg-white text-[14px] text-[#999]">
{t("home.loading")}
</div>
) : null}
{firstProject ? (
<div key={firstProject.id} className="relative z-[15] min-w-0">
{pageInfo ? (
<>
<div className="pointer-events-none absolute left-0 top-0 z-[5] hidden h-[139px] w-[105px] bg-gradient-to-r from-[#f9f9f9] to-[#f9f9f9]/0 xl:block" />
<button
type="button"
aria-label={t("home.prevProject")}
title={t("home.prevProject")}
onClick={onPrevPage}
disabled={!canGoPrev || isLoadingProjects}
className="absolute left-[-19px] top-[50px] z-[20] hidden h-[39px] w-[39px] items-center justify-center rounded-full bg-white text-[#666] shadow-[0_2px_5px_rgba(0,0,0,0.1)] transition hover:bg-[#f0f0f0] disabled:cursor-not-allowed disabled:opacity-40 xl:flex"
>
<span className="rotate-180">
<ArrowIcon />
</span>
</button>
</>
) : null}
<ProjectCard
{...firstProject}
t={t}
onOpen={() => router.push(`/canvas?workflowId=${firstProject.id}`)}
onAction={onProjectAction}
/>
</div>
) : null}
{remainingProjects.map((project) => (
<ProjectCard
key={project.id}
{...project}
t={t}
onOpen={() => router.push(`/canvas?workflowId=${project.id}`)}
onAction={onProjectAction}
/>
))}
{showOverflowCue && pageInfo && (
<>
<div className="pointer-events-none absolute right-0 top-0 hidden h-[139px] w-[105px] bg-gradient-to-r from-[#f9f9f9]/0 to-[#f9f9f9] xl:block" />
<button
type="button"
aria-label={t("home.nextProject")}
title={t("home.nextProject")}
onClick={onNextPage}
disabled={!canGoNext || isLoadingProjects}
className="absolute right-[-25px] top-[52px] hidden h-[39px] w-[39px] items-center justify-center rounded-full bg-white text-[#666] shadow-[0_2px_5px_rgba(0,0,0,0.1)] transition hover:bg-[#f0f0f0] disabled:cursor-not-allowed disabled:opacity-40 xl:flex"
>
<ArrowIcon />
</button>
</>
)}
</div>
);
}
function ProjectActionModals({
t,
isActionLoading,
renameOpen,
renameTitle,
onRenameTitleChange,
onCancelRename,
onConfirmRename,
deleteTitle,
onCancelDelete,
onConfirmDelete,
}: {
t: Translate;
isActionLoading: boolean;
renameOpen: boolean;
renameTitle: string;
onRenameTitleChange: (value: string) => void;
onCancelRename: () => void;
onConfirmRename: () => void;
deleteTitle: string | null;
onCancelDelete: () => void;
onConfirmDelete: () => void;
}) {
const modalClassName =
"[&_.ant-modal-content]:!rounded-[9px] [&_.ant-modal-content]:!p-[30px] [&_.ant-modal-header]:!mb-0 [&_.ant-modal-body]:!pt-2 [&_.ant-modal-footer]:!mt-6 [&_.ant-modal-footer]:!flex [&_.ant-modal-footer]:!justify-end [&_.ant-modal-footer]:!gap-2.5 [&_.ant-modal-footer_.ant-btn+_.ant-btn]:!ml-0 [&_.ant-modal-close]:!right-2.5 [&_.ant-modal-close]:!top-2.5 [&_.ant-modal-close]:!h-5 [&_.ant-modal-close]:!w-5 [&_.ant-modal-close]:!text-[#999] hover:[&_.ant-modal-close]:!text-[#666]";
const modalTitleClassName = "text-[24px] font-medium leading-[22px] text-[#333]";
const cancelButtonClassName =
"!h-[37px] !w-[109px] !rounded-[24px] !border-0 !bg-[#f0f0f0] !p-0 !text-[16px] !font-medium !text-[#666] !shadow-none hover:!bg-[#e6e6e6] hover:!text-[#666]";
const confirmButtonClassName =
"!h-[37px] !w-[109px] !rounded-[24px] !border-0 !bg-[#5c3bc7] !p-0 !text-[16px] !font-medium !text-white !shadow-none hover:!bg-[#6b4bd4]";
const deleteConfirmButtonClassName =
"!h-[37px] !w-[109px] !rounded-[24px] !border-0 !bg-[#b7301e] !p-0 !text-[16px] !font-medium !text-white !shadow-none hover:!bg-[#a62b1b]";
return (
<>
<Modal
title={<span className={modalTitleClassName}>{t("home.renameProject")}</span>}
open={renameOpen}
closeIcon={<CloseIcon />}
centered
width={491}
className={modalClassName}
confirmLoading={isActionLoading}
okText={t("home.confirm")}
cancelText={t("home.cancelEsc")}
okButtonProps={{
className: confirmButtonClassName,
disabled: !renameTitle.trim(),
}}
cancelButtonProps={{ className: cancelButtonClassName }}
onOk={onConfirmRename}
onCancel={onCancelRename}
destroyOnHidden
>
<Form layout="vertical" preserve={false} className="!mt-[7px]">
<Form.Item
label={<span className="text-[14px] leading-[22px] text-[#666]">{t("home.projectName")}</span>}
className="!mb-0"
>
<Input
value={renameTitle}
onChange={(event) => onRenameTitleChange(event.target.value)}
placeholder={t("home.projectNamePlaceholder")}
onPressEnter={onConfirmRename}
autoFocus
className="!h-[37px] !rounded-[9px] !border-[#e6e6e6] !text-[14px] !shadow-none hover:!border-[#d6ceef] focus:!border-[#5c3bc7]"
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={<span className={modalTitleClassName}>{t("home.confirmDelete")}</span>}
open={deleteTitle !== null}
closeIcon={<CloseIcon />}
centered
width={491}
className={modalClassName}
confirmLoading={isActionLoading}
okText={t("home.confirm")}
okButtonProps={{
danger: true,
className: deleteConfirmButtonClassName,
}}
cancelText={t("home.cancelEsc")}
cancelButtonProps={{ className: cancelButtonClassName }}
onOk={onConfirmDelete}
onCancel={onCancelDelete}
destroyOnHidden
>
<p className="m-0 text-[14px] leading-[22px] text-[#666]">
{t("home.deleteCannotRecover")}
</p>
</Modal>
</>
);
}
function useProjectSectionState(pageSize: number, t: Translate, initialPage = 1) {
const router = useRouter();
const coverInputRef = useRef<HTMLInputElement>(null);
const coverProjectIdRef = useRef<number | null>(null);
const coverProgressTimerRef = useRef<number | null>(null);
const [projects, setProjects] = useState<ProjectData[]>([]);
const [page, setPage] = useState(initialPage);
const [pageInfo, setPageInfo] = useState<CanvasWorkflowPageInfo>({
...DEFAULT_PROJECT_PAGE_INFO,
page: initialPage,
pageSize,
});
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [actionProjectId, setActionProjectId] = useState<number | null>(null);
const [renamePayload, setRenamePayload] = useState<{
id: number;
title: string;
desp: string;
properties: string;
coverUrl: string;
} | null>(null);
const [renameTitle, setRenameTitle] = useState("");
const [coverProjectId, setCoverProjectId] = useState<number | null>(null);
const [deletePayload, setDeletePayload] = useState<{
id: number;
title: string;
} | null>(null);
const stopCoverProgressTimer = useCallback(() => {
if (coverProgressTimerRef.current) {
window.clearInterval(coverProgressTimerRef.current);
coverProgressTimerRef.current = null;
}
}, []);
const setProjectCoverProgress = useCallback((id: number, progress?: number) => {
setProjects((currentProjects) =>
currentProjects.map((project) =>
project.id === id ? { ...project, uploadProgress: progress } : project
)
);
}, []);
const startFakeCoverProgress = useCallback((id: number) => {
stopCoverProgressTimer();
setProjectCoverProgress(id, 1);
coverProgressTimerRef.current = window.setInterval(() => {
setProjects((currentProjects) =>
currentProjects.map((project) => {
if (project.id !== id || typeof project.uploadProgress !== "number") {
return project;
}
const nextProgress = Math.min(
90,
project.uploadProgress + Math.max(1, Math.round((90 - project.uploadProgress) * 0.18))
);
return { ...project, uploadProgress: nextProgress };
})
);
}, 300);
}, [setProjectCoverProgress, stopCoverProgressTimer]);
const finishCoverProgress = useCallback((id: number, coverUrl: string) => {
stopCoverProgressTimer();
setProjects((currentProjects) =>
currentProjects.map((project) =>
project.id === id
? { ...project, image: coverUrl, uploadProgress: 100 }
: project
)
);
window.setTimeout(() => setProjectCoverProgress(id, undefined), 450);
}, [setProjectCoverProgress, stopCoverProgressTimer]);
const loadProjects = useCallback(async (nextPage: number, options?: { append?: boolean }) => {
setIsLoadingProjects(true);
try {
const data = await listCanvasWorkflows({ page: nextPage, pageSize });
const nextProjects = data.list
.filter((item) => !item.deleted)
.map((item) => mapWorkflowToProject(item));
setProjects((currentProjects) =>
options?.append ? [...currentProjects, ...nextProjects] : nextProjects
);
setPageInfo(data.pageInfo);
} catch {
if (!options?.append) {
setProjects([]);
}
setPageInfo({
...DEFAULT_PROJECT_PAGE_INFO,
page: nextPage,
pageSize,
});
} finally {
setIsLoadingProjects(false);
}
}, [pageSize]);
useEffect(() => {
void loadProjects(page);
return undefined;
}, [loadProjects, page]);
useEffect(() => {
return () => {
stopCoverProgressTimer();
};
}, [stopCoverProgressTimer]);
const handleStartCreation = async () => {
if (isCreating) {
message.warning(t("home.createInProgress"));
return;
}
setIsCreating(true);
const title = t("home.projectFallback");
try {
const result = await createCanvasWorkflow({
title,
desp: "",
properties: buildEmptyWorkflowProperties(title),
});
const workflowId = (result.data as any)?.id;
router.push(workflowId ? `/canvas?workflowId=${workflowId}` : "/canvas");
message.success(t("home.createSuccess"));
} catch (error) {
message.error(error instanceof Error ? error.message : t("home.createFailed"));
setIsCreating(false);
}
};
const buildFullUpdatePayload = async (id: number) => {
const detail = await getCanvasWorkflowDetail(id);
return {
id,
title: detail.title ?? "",
desp: detail.desp ?? "",
properties: detail.properties ?? "",
coverUrl: detail.coverUrl ?? "",
};
};
const handleProjectAction = async (id: number, action: ProjectActionKey) => {
if (actionProjectId !== null) return;
setActionProjectId(id);
try {
if (action === "rename") {
const payload = await buildFullUpdatePayload(id);
setRenamePayload(payload);
setRenameTitle(payload.title);
return;
}
if (action === "change-cover") {
coverProjectIdRef.current = id;
setCoverProjectId(id);
coverInputRef.current?.click();
return;
}
if (action === "duplicate") {
const detail = await getCanvasWorkflowDetail(id);
const title = detail.title
? `${detail.title}${t("home.copySuffix")}`
: t("home.projectCopyFallback");
await createCanvasWorkflow({
title,
desp: detail.desp ?? "",
properties: detail.properties ?? "",
coverUrl: detail.coverUrl ?? "",
});
message.success(t("home.copyCreated"));
await loadProjects(page);
// const workflowId = (result.data as any)?.id;
// if (workflowId) {
// router.push(`/canvas?workflowId=${workflowId}`);
// }
return;
}
if (action === "delete") {
const detail = await getCanvasWorkflowDetail(id);
setDeletePayload({
id,
title: detail.title ?? detail.code ?? `#${id}`,
});
}
} catch (error) {
message.error(error instanceof Error ? error.message : t("home.actionFailed"));
} finally {
setActionProjectId(null);
}
};
const closeRenameModal = () => {
setRenamePayload(null);
setRenameTitle("");
};
const confirmRenameProject = async () => {
if (!renamePayload || actionProjectId !== null) return;
setActionProjectId(renamePayload.id);
try {
await updateCanvasWorkflow({
...renamePayload,
title: renameTitle,
});
message.success(t("home.renameSuccess"));
setProjects((currentProjects) =>
currentProjects.map((project) =>
project.id === renamePayload.id
? { ...project, title: renameTitle.trim() || project.title }
: project
)
);
closeRenameModal();
} catch (error) {
message.error(error instanceof Error ? error.message : t("home.renameFailed"));
} finally {
setActionProjectId(null);
}
};
const closeDeleteModal = () => {
setDeletePayload(null);
};
const closeCoverModal = () => {
coverProjectIdRef.current = null;
setCoverProjectId(null);
};
const handleCoverFileChange = async (file: File | undefined) => {
const selectedCoverProjectId = coverProjectIdRef.current ?? coverProjectId;
if (!file || selectedCoverProjectId === null || actionProjectId !== null) return;
if (!file.type.startsWith("image/")) {
message.error(t("home.selectImageFile"));
closeCoverModal();
return;
}
setActionProjectId(selectedCoverProjectId);
startFakeCoverProgress(selectedCoverProjectId);
try {
const coverPayload = await buildFullUpdatePayload(selectedCoverProjectId);
const uploadedCoverUrl = await uploadCanvasWorkflowCover(file);
await updateCanvasWorkflow({
...coverPayload,
coverUrl: uploadedCoverUrl,
});
message.success(t("home.coverUpdated"));
finishCoverProgress(selectedCoverProjectId, uploadedCoverUrl);
closeCoverModal();
} catch (error) {
stopCoverProgressTimer();
setProjectCoverProgress(selectedCoverProjectId, undefined);
message.error(error instanceof Error ? error.message : t("home.coverUpdateFailed"));
} finally {
setActionProjectId(null);
if (coverInputRef.current) {
coverInputRef.current.value = "";
}
}
};
const confirmDeleteProject = async () => {
if (!deletePayload || actionProjectId !== null) return;
setActionProjectId(deletePayload.id);
try {
await deleteCanvasWorkflow(deletePayload.id);
message.success(t("home.deleteSuccess"));
closeDeleteModal();
await loadProjects(page);
} catch (error) {
message.error(error instanceof Error ? error.message : t("home.deleteFailed"));
} finally {
setActionProjectId(null);
}
};
return {
projects,
pageInfo,
isLoadingProjects,
isCreating,
isActionLoading: actionProjectId !== null,
renamePayload,
renameTitle,
setRenameTitle,
closeRenameModal,
confirmRenameProject,
coverInputRef,
closeCoverModal,
handleCoverFileChange,
deletePayload,
closeDeleteModal,
confirmDeleteProject,
handleStartCreation,
handleProjectAction,
goPrevPage: () => setPage((current) => Math.max(1, current - 1)),
goNextPage: () => setPage((current) => Math.min(pageInfo.pageCount, current + 1)),
};
}
function useAllProjectsState(t: Translate) {
const pageSize = 20;
const router = useRouter();
const coverInputRef = useRef<HTMLInputElement>(null);
const coverProjectIdRef = useRef<number | null>(null);
const coverProgressTimerRef = useRef<number | null>(null);
const loadingMorePageRef = useRef<number | null>(null);
const [projects, setProjects] = useState<ProjectData[]>([]);
const [page, setPage] = useState(1);
const [pageInfo, setPageInfo] = useState<CanvasWorkflowPageInfo>({
...DEFAULT_PROJECT_PAGE_INFO,
page: 1,
pageSize,
});
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [actionProjectId, setActionProjectId] = useState<number | null>(null);
const [renamePayload, setRenamePayload] = useState<{
id: number;
title: string;
desp: string;
properties: string;
coverUrl: string;
} | null>(null);
const [renameTitle, setRenameTitle] = useState("");
const [coverProjectId, setCoverProjectId] = useState<number | null>(null);
const [deletePayload, setDeletePayload] = useState<{
id: number;
title: string;
} | null>(null);
const stopCoverProgressTimer = useCallback(() => {
if (coverProgressTimerRef.current) {
window.clearInterval(coverProgressTimerRef.current);
coverProgressTimerRef.current = null;
}
}, []);
const setProjectCoverProgress = useCallback((id: number, progress?: number) => {
setProjects((currentProjects) =>
currentProjects.map((project) =>
project.id === id ? { ...project, uploadProgress: progress } : project
)
);
}, []);
const startFakeCoverProgress = useCallback((id: number) => {
stopCoverProgressTimer();
setProjectCoverProgress(id, 1);
coverProgressTimerRef.current = window.setInterval(() => {
setProjects((currentProjects) =>
currentProjects.map((project) => {
if (project.id !== id || typeof project.uploadProgress !== "number") return project;
const nextProgress = Math.min(
90,
project.uploadProgress + Math.max(1, Math.round((90 - project.uploadProgress) * 0.18))
);
return { ...project, uploadProgress: nextProgress };
})
);
}, 300);
}, [setProjectCoverProgress, stopCoverProgressTimer]);
const finishCoverProgress = useCallback((id: number, coverUrl: string) => {
stopCoverProgressTimer();
setProjects((currentProjects) =>
currentProjects.map((project) =>
project.id === id ? { ...project, image: coverUrl, uploadProgress: 100 } : project
)
);
window.setTimeout(() => setProjectCoverProgress(id, undefined), 450);
}, [setProjectCoverProgress, stopCoverProgressTimer]);
const loadProjectsPage = useCallback(async (
nextPage: number,
options?: { append?: boolean }
) => {
setIsLoadingProjects(true);
try {
const data = await listCanvasWorkflows({ page: nextPage, pageSize });
const nextProjects = data.list
.filter((item) => !item.deleted)
.map((item) => mapWorkflowToProject(item));
setProjects((currentProjects) => {
if (!options?.append) return nextProjects;
const existingIds = new Set(currentProjects.map((project) => project.id));
const uniqueNextProjects = nextProjects.filter((project) => !existingIds.has(project.id));
return [...currentProjects, ...uniqueNextProjects];
});
setPageInfo(data.pageInfo);
setPage(nextPage);
} catch {
if (!options?.append) {
setProjects([]);
}
setPageInfo({
...DEFAULT_PROJECT_PAGE_INFO,
page: nextPage,
pageSize,
});
} finally {
setIsLoadingProjects(false);
}
}, []);
useEffect(() => {
void loadProjectsPage(1);
}, [loadProjectsPage]);
useEffect(() => {
return () => {
stopCoverProgressTimer();
};
}, [stopCoverProgressTimer]);
const handleStartCreation = async () => {
if (isCreating) {
message.warning(t("home.createInProgress"));
return;
}
setIsCreating(true);
const title = t("home.projectFallback");
try {
const result = await createCanvasWorkflow({
title,
desp: "",
properties: buildEmptyWorkflowProperties(title),
});
const workflowId = (result.data as any)?.id;
router.push(workflowId ? `/canvas?workflowId=${workflowId}` : "/canvas");
message.success(t("home.createSuccess"));
} catch (error) {
message.error(error instanceof Error ? error.message : t("home.createFailed"));
setIsCreating(false);
}
};
const buildFullUpdatePayload = async (id: number) => {
const detail = await getCanvasWorkflowDetail(id);
return {
id,
title: detail.title ?? "",
desp: detail.desp ?? "",
properties: detail.properties ?? "",
coverUrl: detail.coverUrl ?? "",
};
};
const handleProjectAction = async (id: number, action: ProjectActionKey) => {
if (actionProjectId !== null) return;
setActionProjectId(id);
try {
if (action === "rename") {
const payload = await buildFullUpdatePayload(id);
setRenamePayload(payload);
setRenameTitle(payload.title);
return;
}
if (action === "change-cover") {
coverProjectIdRef.current = id;
setCoverProjectId(id);
coverInputRef.current?.click();
return;
}
if (action === "duplicate") {
const detail = await getCanvasWorkflowDetail(id);
const title = detail.title
? `${detail.title}${t("home.copySuffix")}`
: t("home.projectCopyFallback");
await createCanvasWorkflow({
title,
desp: detail.desp ?? "",
properties: detail.properties ?? "",
coverUrl: detail.coverUrl ?? "",
});
message.success(t("home.copyCreated"));
await loadProjectsPage(1);
return;
}
if (action === "delete") {
const detail = await getCanvasWorkflowDetail(id);
setDeletePayload({
id,
title: detail.title ?? detail.code ?? `#${id}`,
});
}
} catch (error) {
message.error(error instanceof Error ? error.message : t("home.actionFailed"));
} finally {
setActionProjectId(null);
}
};
const closeRenameModal = () => {
setRenamePayload(null);
setRenameTitle("");
};
const confirmRenameProject = async () => {
if (!renamePayload || actionProjectId !== null) return;
setActionProjectId(renamePayload.id);
try {
await updateCanvasWorkflow({
...renamePayload,
title: renameTitle,
});
message.success(t("home.renameSuccess"));
setProjects((currentProjects) =>
currentProjects.map((project) =>
project.id === renamePayload.id
? { ...project, title: renameTitle.trim() || project.title }
: project
)
);
closeRenameModal();
} catch (error) {
message.error(error instanceof Error ? error.message : t("home.renameFailed"));
} finally {
setActionProjectId(null);
}
};
const closeDeleteModal = () => {
setDeletePayload(null);
};
const closeCoverModal = () => {
coverProjectIdRef.current = null;
setCoverProjectId(null);
};
const handleCoverFileChange = async (file: File | undefined) => {
const selectedCoverProjectId = coverProjectIdRef.current ?? coverProjectId;
if (!file || selectedCoverProjectId === null || actionProjectId !== null) return;
if (!file.type.startsWith("image/")) {
message.error(t("home.selectImageFile"));
closeCoverModal();
return;
}
setActionProjectId(selectedCoverProjectId);
startFakeCoverProgress(selectedCoverProjectId);
try {
const coverPayload = await buildFullUpdatePayload(selectedCoverProjectId);
const uploadedCoverUrl = await uploadCanvasWorkflowCover(file);
await updateCanvasWorkflow({
...coverPayload,
coverUrl: uploadedCoverUrl,
});
message.success(t("home.coverUpdated"));
finishCoverProgress(selectedCoverProjectId, uploadedCoverUrl);
closeCoverModal();
} catch (error) {
stopCoverProgressTimer();
setProjectCoverProgress(selectedCoverProjectId, undefined);
message.error(error instanceof Error ? error.message : t("home.coverUpdateFailed"));
} finally {
setActionProjectId(null);
if (coverInputRef.current) {
coverInputRef.current.value = "";
}
}
};
const confirmDeleteProject = async () => {
if (!deletePayload || actionProjectId !== null) return;
setActionProjectId(deletePayload.id);
try {
await deleteCanvasWorkflow(deletePayload.id);
message.success(t("home.deleteSuccess"));
closeDeleteModal();
await loadProjectsPage(1);
} catch (error) {
message.error(error instanceof Error ? error.message : t("home.deleteFailed"));
} finally {
setActionProjectId(null);
}
};
const loadNextPage = useCallback(() => {
const nextPage = page + 1;
if (isLoadingProjects || nextPage > pageInfo.pageCount) return;
if (loadingMorePageRef.current === nextPage) return;
loadingMorePageRef.current = nextPage;
void loadProjectsPage(nextPage, { append: true }).finally(() => {
loadingMorePageRef.current = null;
});
}, [isLoadingProjects, loadProjectsPage, page, pageInfo.pageCount]);
return {
projects,
pageInfo,
isLoadingProjects,
isCreating,
isActionLoading: actionProjectId !== null,
renamePayload,
renameTitle,
setRenameTitle,
closeRenameModal,
confirmRenameProject,
coverInputRef,
closeCoverModal,
handleCoverFileChange,
deletePayload,
closeDeleteModal,
confirmDeleteProject,
handleStartCreation,
handleProjectAction,
loadNextPage,
};
}
function RecentProjectsSection({
className = "",
onAllProjectsClick,
}: {
className?: string;
onAllProjectsClick: () => void;
}) {
const { t } = useI18n();
const {
projects,
pageInfo,
isLoadingProjects,
isCreating,
isActionLoading,
renamePayload,
renameTitle,
setRenameTitle,
closeRenameModal,
confirmRenameProject,
coverInputRef,
closeCoverModal,
handleCoverFileChange,
deletePayload,
closeDeleteModal,
confirmDeleteProject,
handleStartCreation,
handleProjectAction,
goPrevPage,
goNextPage,
} = useProjectSectionState(4, t);
return (
<section className={`${className}`}>
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center justify-center gap-x-1 overflow-hidden whitespace-nowrap">
<h2 className="text-[20px] font-bold leading-none text-[#333]">
{t("home.recentProjects")}
</h2>
</div>
<button
type="button"
onClick={onAllProjectsClick}
className="flex h-8 cursor-pointer items-center justify-center gap-1 rounded-[8px] px-2 text-[16px] leading-none !text-[#666] transition hover:bg-[#f0f0f0] hover:text-[#333]"
>
<span>{t("home.allProjects")}</span>
<ArrowIcon />
</button>
</div>
<ProjectSectionGrid
projects={projects}
isLoadingProjects={isLoadingProjects}
isCreating={isCreating}
t={t}
showOverflowCue
pageInfo={pageInfo}
onPrevPage={goPrevPage}
onNextPage={goNextPage}
onStartCreation={handleStartCreation}
onProjectAction={handleProjectAction}
/>
<ProjectActionModals
t={t}
isActionLoading={isActionLoading}
renameOpen={!!renamePayload}
renameTitle={renameTitle}
onRenameTitleChange={setRenameTitle}
onCancelRename={closeRenameModal}
onConfirmRename={confirmRenameProject}
deleteTitle={deletePayload?.title ?? null}
onCancelDelete={closeDeleteModal}
onConfirmDelete={confirmDeleteProject}
/>
<input
ref={coverInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) {
closeCoverModal();
return;
}
void handleCoverFileChange(file);
}}
/>
</section>
);
}
function AllProjectsSection({ onBackClick }: { onBackClick: () => void }) {
const { t } = useI18n();
const [stickyTop, setStickyTop] = useState(90);
const {
projects,
pageInfo,
isLoadingProjects,
isCreating,
isActionLoading,
renamePayload,
renameTitle,
setRenameTitle,
closeRenameModal,
confirmRenameProject,
coverInputRef,
closeCoverModal,
handleCoverFileChange,
deletePayload,
closeDeleteModal,
confirmDeleteProject,
handleStartCreation,
handleProjectAction,
loadNextPage,
} = useAllProjectsState(t);
const canLoadMoreProjects = pageInfo.page < pageInfo.pageCount;
useEffect(() => {
const updateStickyTop = () => {
const header = document.querySelector("header");
setStickyTop(header?.getBoundingClientRect().height ?? 0);
};
updateStickyTop();
window.addEventListener("resize", updateStickyTop);
return () => {
window.removeEventListener("resize", updateStickyTop);
};
}, []);
useEffect(() => {
const handleScroll = () => {
if (!canLoadMoreProjects || isLoadingProjects) return;
const distanceToBottom =
document.documentElement.scrollHeight - window.innerHeight - window.scrollY;
if (distanceToBottom <= ALL_PROJECTS_LOAD_MORE_OFFSET) {
loadNextPage();
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [canLoadMoreProjects, isLoadingProjects, loadNextPage]);
return (
<section>
<div
className="sticky z-20 -mx-5 mb-5 flex items-center justify-between border-b border-[#f0f0f0] bg-[#f9f9f9]/95 px-5 py-3 backdrop-blur"
style={{ top: stickyTop }}
>
<div className="flex items-center justify-center gap-x-1 overflow-hidden whitespace-nowrap">
<button
type="button"
onClick={onBackClick}
className="flex h-8 items-center justify-center gap-1 rounded-[8px] px-2 text-[16px] leading-none text-[#666] transition hover:bg-[#f0f0f0] hover:text-[#333]"
>
<LeftOutlinedIcon />
<span>{t("home.back")}</span>
</button>
<h2 className="text-[20px] font-bold leading-none text-[#333]">
{t("home.allProjects")}
</h2>
</div>
</div>
<ProjectSectionGrid
projects={projects}
isLoadingProjects={isLoadingProjects}
isCreating={isCreating}
t={t}
onStartCreation={handleStartCreation}
onProjectAction={handleProjectAction}
/>
<div className="flex h-14 items-center justify-center text-[13px] font-medium text-[#999]">
{isLoadingProjects && projects.length > 0
? t("home.loading")
: canLoadMoreProjects
? t("home.loadMorePull")
: projects.length > 0
? t("home.noMoreProjects")
: null}
</div>
<ProjectActionModals
t={t}
isActionLoading={isActionLoading}
renameOpen={!!renamePayload}
renameTitle={renameTitle}
onRenameTitleChange={setRenameTitle}
onCancelRename={closeRenameModal}
onConfirmRename={confirmRenameProject}
deleteTitle={deletePayload?.title ?? null}
onCancelDelete={closeDeleteModal}
onConfirmDelete={confirmDeleteProject}
/>
<input
ref={coverInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) {
closeCoverModal();
return;
}
void handleCoverFileChange(file);
}}
/>
</section>
);
}
function mapTemplateToShow(item: CanvasTemplateItem, t: Translate): ShowData {
return {
id: item.id,
title: item.name || '',
subtitle: item.desp || '',
image: item.coverUrl || '',
avatar: item?.userInfo?.avatar || '',
vip: item.type === 1,
data: item.properties || '',
};
}
function ShowsSection() {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState(0);
const [categoryList, setCategoryList] = useState<TemplateCategoryData[]>([
{ id: null, name: t("home.allCategory") },
]);
const [dataList, setDataList] = useState<ShowData[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [submittedSearchQuery, setSubmittedSearchQuery] = useState("");
const selectedCategoryId = categoryList[activeTab]?.id ?? null;
const [previewShow, setPreviewShow] = useState<ShowData | null>(null);
const [isCopyingShow, setIsCopyingShow] = useState(false);
const [isLoadingShows, setIsLoadingShows] = useState(false);
const [showsPage, setShowsPage] = useState(1);
const [showsPageInfo, setShowsPageInfo] = useState<CanvasWorkflowPageInfo>({
...DEFAULT_PROJECT_PAGE_INFO,
page: 1,
pageSize: SHOWS_PAGE_SIZE,
});
const [stickyTop, setStickyTop] = useState(90);
const loadingMoreShowsPageRef = useRef<number | null>(null);
const router = useRouter();
useEffect(() => {
setCategoryList((currentCategories) =>
currentCategories.map((category) =>
category.id === null ? { ...category, name: t("home.allCategory") } : category
)
);
}, [t]);
useEffect(() => {
let canceled = false;
async function loadCategories() {
try {
const params = new URLSearchParams({ keyword: "" });
const response = await authFetch(`/api/canvas-templates/categories?${params.toString()}`);
if (!response.ok) return;
const result = (await response.json()) as ListResponse<CanvasTemplateCategoryItem>;
const list = result.data?.list;
if (!Array.isArray(list)) return;
const categories = list
.filter((item) => !item.deleted)
.sort((a, b) => a.sort - b.sort)
.map((item) => ({ id: item.id, name: item.name }))
.filter((item) => item.name);
if (!canceled && categories.length > 0) {
setCategoryList([{ id: null, name: t("home.allCategory") }, ...categories]);
}
} catch {
// Keep fallback category.
}
}
loadCategories();
return () => {
canceled = true;
};
}, []);
useEffect(() => {
const updateStickyTop = () => {
const header = document.querySelector("header");
setStickyTop(header?.getBoundingClientRect().height ?? 0);
};
updateStickyTop();
window.addEventListener("resize", updateStickyTop);
return () => {
window.removeEventListener("resize", updateStickyTop);
};
}, []);
const loadShowsPage = useCallback(async (
nextPage: number,
options?: { append?: boolean; signal?: AbortSignal }
) => {
setIsLoadingShows(true);
try {
const params = new URLSearchParams({
keyword: submittedSearchQuery,
categoryId: selectedCategoryId ? String(selectedCategoryId) : "",
type: "",
userId: "",
page: String(nextPage),
pageSize: String(SHOWS_PAGE_SIZE),
});
const response = await authFetch(`/api/canvas-templates?${params.toString()}`, {
signal: options?.signal,
});
if (!response.ok) {
if (!options?.append) {
setDataList([]);
}
return;
}
const result = (await response.json()) as ListResponse<CanvasTemplateItem>;
const list = result.data?.list;
if (!Array.isArray(list)) {
if (!options?.append) {
setDataList([]);
}
return;
}
const nextShows = list
.filter((item) => !item.deleted)
.map((item) => mapTemplateToShow(item, t));
setDataList((currentShows) => {
if (!options?.append) return nextShows;
const existingIds = new Set(currentShows.map((show) => show.id));
return [...currentShows, ...nextShows.filter((show) => !existingIds.has(show.id))];
});
setShowsPageInfo(
result.data?.pageInfo ?? {
page: nextPage,
pageSize: SHOWS_PAGE_SIZE,
pageCount: nextShows.length < SHOWS_PAGE_SIZE ? nextPage : nextPage + 1,
total: nextShows.length,
}
);
setShowsPage(nextPage);
} catch {
if (!options?.signal?.aborted && !options?.append) {
setDataList([]);
}
} finally {
if (!options?.signal?.aborted) {
setIsLoadingShows(false);
}
}
}, [submittedSearchQuery, selectedCategoryId, t]);
useEffect(() => {
const controller = new AbortController();
setShowsPage(1);
setShowsPageInfo({
...DEFAULT_PROJECT_PAGE_INFO,
page: 1,
pageSize: SHOWS_PAGE_SIZE,
});
void loadShowsPage(1, { signal: controller.signal });
return () => {
controller.abort();
loadingMoreShowsPageRef.current = null;
};
}, [loadShowsPage]);
const canLoadMoreShows = showsPage < showsPageInfo.pageCount;
const loadNextShowsPage = useCallback(() => {
const nextPage = showsPage + 1;
if (isLoadingShows || nextPage > showsPageInfo.pageCount) return;
if (loadingMoreShowsPageRef.current === nextPage) return;
loadingMoreShowsPageRef.current = nextPage;
void loadShowsPage(nextPage, { append: true }).finally(() => {
loadingMoreShowsPageRef.current = null;
});
}, [isLoadingShows, loadShowsPage, showsPage, showsPageInfo.pageCount]);
const handleSubmitSearch = useCallback(() => {
setSubmittedSearchQuery(searchQuery.trim());
}, [searchQuery]);
const handleClearSearch = useCallback(() => {
setSearchQuery("");
setSubmittedSearchQuery("");
}, []);
useEffect(() => {
const handleScroll = () => {
if (!canLoadMoreShows || isLoadingShows) return;
const distanceToBottom =
document.documentElement.scrollHeight - window.innerHeight - window.scrollY;
if (distanceToBottom <= SHOWS_LOAD_MORE_OFFSET) {
loadNextShowsPage();
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [canLoadMoreShows, isLoadingShows, loadNextShowsPage]);
const handleCopyShow = useCallback(async (data: CanvasTemplatePreviewData) => {
if (isCopyingShow) return;
setIsCopyingShow(true);
const title = data.title || t("home.projectFallback");
try {
const result = await createCanvasWorkflow({
title,
desp: data.subtitle || "",
properties: data.data || buildEmptyWorkflowProperties(title),
coverUrl: data.image || "",
});
const workflowId = (result.data as any)?.id;
router.push(workflowId ? `/canvas?workflowId=${workflowId}` : "/canvas");
} catch (error) {
message.error(error instanceof Error ? error.message : t("home.createFailed"));
setIsCopyingShow(false);
}
}, [isCopyingShow, router, t]);
return (
<section className="mt-[64px]">
<div
className="sticky z-20 -mx-5 mb-5 flex flex-col gap-4 border-b border-[#f0f0f0] bg-[#f9f9f9]/95 px-5 py-3 backdrop-blur lg:flex-row lg:items-end lg:justify-between"
style={{ top: stickyTop }}
>
<div>
<h2 className="text-[20px] font-bold leading-none text-[#333]">
{t("home.tvShow")}
</h2>
<div className="mt-5 flex flex-wrap items-center gap-1 text-[16px]">
{categoryList.map((tab, index) => (
<button
key={`${tab.id ?? "all"}-${tab.name}`}
type="button"
onClick={() => setActiveTab(index)}
className={
index === activeTab
? "min-w-[54px] rounded-[9px] bg-[#f0f0f0] px-[15px] py-[5px] text-center text-[#333] transition hover:bg-[#f0f0f0]"
: "min-w-[54px] rounded-[9px] bg-[#f9f9f9] px-[15px] py-[5px] text-center text-[#666] transition hover:bg-[#f0f0f0] hover:text-[#333]"
}
>
{tab.name}
</button>
))}
</div>
</div>
<div className="flex h-[35px] w-full items-center justify-between rounded-full border border-transparent bg-white px-[15px] text-[#999] shadow-[0_2px_5px_rgba(0,0,0,0.1)] transition focus-within:border-[#5c3bc7] lg:w-[295px]">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleSubmitSearch();
}
}}
placeholder={t("home.searchPlaceholder")}
className="min-w-0 flex-1 bg-transparent text-[14px] outline-none placeholder:text-[#999]"
/>
{searchQuery ? (
<button
type="button"
onClick={handleClearSearch}
aria-label={t("common.clear")}
title={t("common.clear")}
className="mr-1 grid h-7 w-7 shrink-0 place-items-center rounded-full text-[#999] transition hover:bg-[#f0f0f0] hover:text-[#666]"
>
<CloseIcon />
</button>
) : null}
<button
type="button"
onClick={handleSubmitSearch}
aria-label={t("home.searchPlaceholder")}
title={t("home.searchPlaceholder")}
className="grid h-7 w-7 shrink-0 place-items-center rounded-full text-[#999] transition hover:bg-[#f0f0f0] hover:text-[#5c3bc7]"
>
<SearchIcon />
</button>
</div>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{dataList.map((show) => (
<ShowCard key={show.id} {...show} onOpen={setPreviewShow} />
))}
</div>
<div className="flex h-14 items-center justify-center text-[13px] font-medium text-[#999]">
{isLoadingShows && dataList.length > 0
? t("home.loading")
: canLoadMoreShows
? t("home.loadMorePullUp")
: dataList.length > 0
? t("home.noMoreContent")
: isLoadingShows
? t("home.loading")
: null}
</div>
<CanvasTemplatePreviewModal
open={previewShow !== null}
template={previewShow}
copying={isCopyingShow}
onClose={() => setPreviewShow(null)}
onCopy={handleCopyShow}
/>
</section>
);
}
export function HomeProjectsContent({
heroSection,
}: HomeProjectsContentProps) {
const [isShowAll, setIsShowAll] = useState(false);
return (
<div className="mx-auto w-full max-w-[1456px] px-5 pt-[30px]">
{isShowAll ? (
<AllProjectsSection onBackClick={() => setIsShowAll(false)} />
) : (
<>
{heroSection}
<RecentProjectsSection
className="mt-[64px]"
onAllProjectsClick={() => setIsShowAll(true)}
/>
<ShowsSection />
</>
)}
</div>
);
}