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
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>
|
|
);
|
|
}
|
|
|