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.
163 lines
6.1 KiB
163 lines
6.1 KiB
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { getTemplateList } from "@/api/api";
|
|
import { EXPLORE_TEMPLATE_FEED_PANEL_ID } from "@/constants";
|
|
import { useComposerStore } from "@/store/useComposerStore";
|
|
import { useTemplateSelectStore } from "@/store/useMakeSameStore";
|
|
import { useImageDragTransfer, useWarmupImageDragUrls } from "@/hooks/useImageDragTransfer";
|
|
import type { ExploreTemplateItem } from "../types";
|
|
import { useTranslation } from "react-i18next";
|
|
import "./ExploreTemplateFeed.scss";
|
|
|
|
const FEED_PAGE_SIZE = 50;
|
|
const TEMPLATE_LOGO_SRC = new URL("/src/assets/images/popi_avatar.png", import.meta.url).href;
|
|
|
|
type TemplateListResponse = {
|
|
data?: {
|
|
list?: ExploreTemplateItem[];
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 模板 Tab 内容区:请求、分页、加载态均内置,无 props。
|
|
*/
|
|
export default function ExploreTemplateFeed() {
|
|
const { t } = useTranslation();
|
|
const requestTemplateSelect = useTemplateSelectStore((s) => s.requestTemplateSelect);
|
|
const requestComposerExpand = useComposerStore((s) => s.requestComposerExpand);
|
|
|
|
const [items, setItems] = useState<ExploreTemplateItem[]>([]);
|
|
const [page, setPage] = useState(1);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [initialLoading, setInitialLoading] = useState(false);
|
|
const [moreLoading, setMoreLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const bootstrappedRef = useRef(false);
|
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
|
const loadingMoreLockRef = useRef(false);
|
|
const loadedPageSetRef = useRef(new Set<number>());
|
|
const { getDragProps, warmupPreviewUrls } = useImageDragTransfer();
|
|
const previewUrls = useMemo(() => items.map((item) => item.coverUrl), [items]);
|
|
useWarmupImageDragUrls(warmupPreviewUrls, previewUrls);
|
|
|
|
const fetchFeed = useCallback(
|
|
async (nextPage: number, append: boolean) => {
|
|
if (append && (loadingMoreLockRef.current || loadedPageSetRef.current.has(nextPage))) return;
|
|
|
|
if (append) {
|
|
loadingMoreLockRef.current = true;
|
|
setMoreLoading(true);
|
|
} else {
|
|
loadedPageSetRef.current.clear();
|
|
setInitialLoading(true);
|
|
}
|
|
setError(null);
|
|
try {
|
|
const res = (await getTemplateList({ page: nextPage, pageSize: FEED_PAGE_SIZE })) as TemplateListResponse;
|
|
const list = Array.isArray(res?.data?.list) ? res.data.list : [];
|
|
setItems((prev) => {
|
|
if (!append) return list;
|
|
const knownIds = new Set(prev.map((item) => String(item.id)));
|
|
const added = list.filter((item) => !knownIds.has(String(item.id)));
|
|
return added.length === 0 ? prev : [...prev, ...added];
|
|
});
|
|
setHasMore(list.length >= FEED_PAGE_SIZE);
|
|
setPage(nextPage);
|
|
loadedPageSetRef.current.add(nextPage);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : t("pages.explore.feed.loadFailed");
|
|
setError(msg);
|
|
if (!append) setItems([]);
|
|
setHasMore(false);
|
|
} finally {
|
|
if (append) {
|
|
loadingMoreLockRef.current = false;
|
|
setMoreLoading(false);
|
|
} else {
|
|
setInitialLoading(false);
|
|
}
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (bootstrappedRef.current) return;
|
|
bootstrappedRef.current = true;
|
|
void fetchFeed(1, false);
|
|
}, [fetchFeed]);
|
|
|
|
const loadingAny = initialLoading || moreLoading;
|
|
|
|
const handleUseTemplate = useCallback(
|
|
(item: ExploreTemplateItem) => {
|
|
const templateId = item?.id;
|
|
if (templateId == null || templateId === "") return;
|
|
requestTemplateSelect(templateId);
|
|
requestComposerExpand();
|
|
},
|
|
[requestComposerExpand, requestTemplateSelect],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!hasMore || loadingAny) return;
|
|
const el = loadMoreRef.current;
|
|
if (el == null) return;
|
|
|
|
const obs = new IntersectionObserver(
|
|
(entries) => {
|
|
if (!entries.some((e) => e.isIntersecting)) return;
|
|
if (loadingMoreLockRef.current) return;
|
|
void fetchFeed(page + 1, true);
|
|
},
|
|
{ root: null, rootMargin: "240px", threshold: 0 },
|
|
);
|
|
obs.observe(el);
|
|
return () => {
|
|
obs.disconnect();
|
|
};
|
|
}, [hasMore, loadingAny, page, fetchFeed]);
|
|
|
|
return (
|
|
<div className="explorePage__feedPanel" id={EXPLORE_TEMPLATE_FEED_PANEL_ID} role="tabpanel" aria-labelledby="explore-feed-tab-template">
|
|
{error != null ? (
|
|
<p className="explorePage__feedError" role="alert">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
{initialLoading && items.length === 0 ? <p className="explorePage__feedLoading">{t("common.loading")}</p> : null}
|
|
<div className="explorePage__feedGrid">
|
|
{items.map((item) => {
|
|
return (
|
|
<article className="explorePage__tplCard" key={item.id} {...getDragProps(item.coverUrl)}>
|
|
<div className="explorePage__tplCardMedia">
|
|
<img src={item.coverUrl} alt="" />
|
|
</div>
|
|
<button
|
|
className="explorePage__tplCardUse"
|
|
type="button"
|
|
aria-label={t("pages.create.inspiration.templateLibrary.useTemplateAria")}
|
|
onClick={(evt) => {
|
|
evt.stopPropagation();
|
|
handleUseTemplate(item);
|
|
}}
|
|
>
|
|
<span className="explorePage__tplCardUseIcon" aria-hidden>
|
|
+
|
|
</span>
|
|
<span>{t("pages.explore.template.use")}</span>
|
|
</button>
|
|
<div className="explorePage__tplCardInfo">
|
|
<div className="explorePage__tplCardAvatar">
|
|
<img src={TEMPLATE_LOGO_SRC} alt="" />
|
|
</div>
|
|
<p className="explorePage__tplCardTitle">{item.title}</p>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
{hasMore && items.length > 0 ? <div className="explorePage__feedSentinel" ref={loadMoreRef} aria-hidden /> : null}
|
|
{moreLoading ? <p className="explorePage__feedLoading explorePage__feedLoading--more">{t("pages.explore.feed.loadingMore")}</p> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|