diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index 2192e3f142..23de790817 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -485,6 +485,8 @@ export type I18nTranslations = { "addCategory": string; "selectCategory": string; }; + "relatedTemplates": string; + "noImage": string; "baseSelectPanel": { "title": string; "description": string; diff --git a/apps/nextjs-app/src/features/app/components/space/template/RecommendTemplate.tsx b/apps/nextjs-app/src/features/app/components/space/template/RecommendTemplate.tsx new file mode 100644 index 0000000000..1fbd4373c0 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/space/template/RecommendTemplate.tsx @@ -0,0 +1,93 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPublishedTemplateList } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config/react-query-keys'; +import { Spin } from '@teable/ui-lib/base'; +import { cn } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; + +interface IRecommendTemplateProps { + filterTemplateIds?: string[]; + onTemplateClick?: (templateId: string) => void; + className?: string; +} + +export const RecommendTemplate = (props: IRecommendTemplateProps) => { + const { onTemplateClick, className, filterTemplateIds } = props; + const { t } = useTranslation('common'); + + const { data: templates, isLoading } = useQuery({ + queryKey: [...ReactQueryKeys.publishedTemplateList(null, '', true), 'recommend'], + queryFn: () => getPublishedTemplateList({ featured: true, take: 4 }).then((res) => res.data), + }); + + const filteredTemplates = useMemo(() => { + return templates?.filter((template) => !filterTemplateIds?.includes(template.id))?.slice(0, 3); + }, [templates, filterTemplateIds]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!templates || templates.length === 0) { + return null; + } + + const handleTemplateClick = (templateId: string) => { + onTemplateClick?.(templateId); + }; + + const handleKeyDown = (e: React.KeyboardEvent, templateId: string) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleTemplateClick(templateId); + } + }; + + return ( +
+

+ {t('settings.templateAdmin.relatedTemplates')} +

+
+ {filteredTemplates?.map((template) => ( +
handleTemplateClick(template.id)} + onKeyDown={(e) => handleKeyDown(e, template.id)} + > +
+ {template.cover?.presignedUrl ? ( + {template.name} + ) : ( +
+ + {t('settings.templateAdmin.noImage')} + +
+ )} +
+
+
+

+ {template.name} +

+
+
+
+ ))} +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/components/space/template/TemplateDetail.tsx b/apps/nextjs-app/src/features/app/components/space/template/TemplateDetail.tsx index d1b074b088..ced7a6585e 100644 --- a/apps/nextjs-app/src/features/app/components/space/template/TemplateDetail.tsx +++ b/apps/nextjs-app/src/features/app/components/space/template/TemplateDetail.tsx @@ -12,24 +12,29 @@ import { Badge, Button, cn } from '@teable/ui-lib/shadcn'; import { ArrowUpRight, ChevronLeft } from 'lucide-react'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useSpaceId } from './hooks/use-space-id'; +import { RecommendTemplate } from './RecommendTemplate'; import { TemplatePreview } from './TemplatePreview'; import { TemplatePreviewSheet } from './TemplatePreviewSheet'; interface ITemplateDetailProps { templateId: string; onBackToTemplateList?: () => void; + onTemplateClick?: (templateId: string) => void; } export const TemplateDetail = (props: ITemplateDetailProps) => { - const { templateId, onBackToTemplateList } = props; + const { templateId, onBackToTemplateList, onTemplateClick } = props; const { t } = useTranslation(['common']); + const detailRef = useRef(null); const isMobile = useIsMobile(); - const { data: templateDetail } = useQuery({ + const { data: _templateDetail } = useQuery({ queryKey: ReactQueryKeys.templateDetail(templateId), queryFn: () => getTemplateDetail(templateId).then((res) => res.data), }); + const templateDetail = _templateDetail?.id === templateId ? _templateDetail : undefined; + const { name, description, categoryId, markdownDescription, cover } = templateDetail || {}; const { data: categoryList } = useQuery({ @@ -71,6 +76,19 @@ export const TemplateDetail = (props: ITemplateDetailProps) => { }, }); + const filterTemplateIds = useMemo(() => { + return [templateId]; + }, [templateId]); + + useEffect(() => { + if (detailRef.current) { + detailRef.current.scrollTo({ + top: 0, + behavior: 'smooth', + }); + } + }, [templateId]); + if (isMobile) { return (
@@ -86,7 +104,7 @@ export const TemplateDetail = (props: ITemplateDetailProps) => { )}

{name}

-
+
{categoryNames.length > 0 && (
{categoryNames.map((categoryName) => ( @@ -131,10 +149,11 @@ export const TemplateDetail = (props: ITemplateDetailProps) => { {markdownDescription && ( {markdownDescription} )} - {/* {!markdownDescription && ( - {t('common:noDescription')} - )} */}
+
); @@ -187,16 +206,17 @@ export const TemplateDetail = (props: ITemplateDetailProps) => { {isLoading && } -
- -
+
+ +
{markdownDescription && ( {markdownDescription} )} - {/* {!markdownDescription && ( - {t('common:noDescription')} - )} */}
+
); diff --git a/apps/nextjs-app/src/features/app/components/space/template/TemplateModal.tsx b/apps/nextjs-app/src/features/app/components/space/template/TemplateModal.tsx index 782aa11c91..4120db4465 100644 --- a/apps/nextjs-app/src/features/app/components/space/template/TemplateModal.tsx +++ b/apps/nextjs-app/src/features/app/components/space/template/TemplateModal.tsx @@ -47,7 +47,14 @@ export const TemplateModal = (props: TemplateModalProps) => { ) : ( {children} - +
@@ -69,6 +76,7 @@ export const TemplateModal = (props: TemplateModalProps) => { setCurrentTemplateId(null)} + onTemplateClick={(templateId) => setCurrentTemplateId(templateId)} /> ) : ( (); const isHydrated = useIsHydrated(); + const url = + defaultUrl || (snapshot?.baseId ? `${window.location.origin}/base/${snapshot.baseId}` : ''); + useEffect(() => { + if (url) { + setIsLoading(true); + } + }, [url]); if (!isHydrated) { return ( @@ -30,8 +37,6 @@ export const TemplatePreview = (props: { } const height = width * (640 / 1240); - const url = - defaultUrl || (snapshot?.baseId ? `${window.location.origin}/base/${snapshot.baseId}` : ''); return (
@@ -46,7 +51,7 @@ export const TemplatePreview = (props: { onLoad={() => requestAnimationFrame(() => setIsLoading(false))} /> )} - {isLoading && ( + {(isLoading || !url) && (
{ setCurrentTemplateId(null)} + onTemplateClick={(templateId) => setCurrentTemplateId(templateId)} /> ) : (