diff --git a/app/home/layout.tsx b/app/home/layout.tsx new file mode 100644 index 0000000..1e0e2fc --- /dev/null +++ b/app/home/layout.tsx @@ -0,0 +1,9 @@ +import { DashboardLayout } from '@/components/layout/dashboard-layout'; + +export default function HomeLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/app/home/page.tsx b/app/home/page.tsx new file mode 100644 index 0000000..8dca4bb --- /dev/null +++ b/app/home/page.tsx @@ -0,0 +1,15 @@ +'use client'; + +import HomeBanner from "@/components/HomeBanner"; +import FamousTemplate from "@/components/FamousTemplate"; +import MyMovies from "@/components/MyMovies"; + +export default function HomePage() { + return ( +
+ + + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 05e71c3..e4d85f5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -15,7 +15,7 @@ export default function Home() { useEffect(() => { if (isAuthenticated()) { - router.replace('/movies'); + router.replace('/home'); } }, [router]); return ( diff --git a/app/service/domain/Entities.ts b/app/service/domain/Entities.ts index f1f70eb..0a7c2bf 100644 --- a/app/service/domain/Entities.ts +++ b/app/service/domain/Entities.ts @@ -144,6 +144,8 @@ export interface StoryTemplateEntity { category: string; /** 故事模板ID */ template_id: string; + /** 故事模板视频URL */ + show_url: string; /** 故事角色 */ storyRole: { /** 角色名 */ diff --git a/components/FamousTemplate.tsx b/components/FamousTemplate.tsx new file mode 100644 index 0000000..1b5c166 --- /dev/null +++ b/components/FamousTemplate.tsx @@ -0,0 +1,190 @@ +"use client" + +import type React from "react" +import { useEffect, useState } from "react" +import Link from "next/link" +import { X } from "lucide-react" +import { PcTemplateModal } from "@/components/ChatInputBox/PcTemplateModal" +import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService" + +/** + * A compact template showcase with a header and link to all templates. + * Shows first 12 templates, in 3 columns, each with thumbnail, name and brief. + * @returns {JSX.Element} - FamousTemplate component + */ +const FamousTemplate: React.FC = () => { + const { templateStoryList, getTemplateStoryList, isLoading } = useTemplateStoryServiceHook() + + const [isModalOpen, setIsModalOpen] = useState(false) + const [initialTemplateId, setInitialTemplateId] = useState(undefined) + const [isTemplateCreating, setIsTemplateCreating] = useState(false) + const [isRoleGenerating, setIsRoleGenerating] = useState<{ [key: string]: boolean }>({}) + const [isItemGenerating, setIsItemGenerating] = useState<{ [key: string]: boolean }>({}) + const [activeTemplateId, setActiveTemplateId] = useState(null) + const [isPreviewReady, setIsPreviewReady] = useState(false) + + useEffect(() => { + void getTemplateStoryList() + }, [getTemplateStoryList]) + + const topTemplates = templateStoryList.slice(0, 10) + + return ( +
+
+

+ Make Movie +

+
+ + {isLoading ? ( +
+ Loading... +
+ ) : ( +
+ {topTemplates.map((t) => { + + return ( +
+
+
+ {t.name} + +
+
{t.name}
+
+
+
+ +
{ + const id = t.id || t.template_id + if (t.show_url) { + if (activeTemplateId === id) { + setActiveTemplateId(null) + } else { + setIsPreviewReady(false) + setActiveTemplateId(id) + } + } else { + setInitialTemplateId(id) + setIsModalOpen(true) + } + }} + /> +
+ ) + })} +
+ )} + + {/* Centered modal for active template preview */} + {activeTemplateId && (() => { + const active = topTemplates.find((x) => (x.id || x.template_id) === activeTemplateId) + if (!active || !active.show_url) return null + return ( +
setActiveTemplateId(null)} + > +
e.stopPropagation()} + > + <> +
+ ) + })()} + + setIsModalOpen(false)} + initialTemplateId={initialTemplateId} + configOptions={{ mode: "auto", resolution: "720p", language: "english", videoDuration: "auto" }} + /> +
+ ) +} + +export default FamousTemplate diff --git a/components/HomeBanner.tsx b/components/HomeBanner.tsx new file mode 100644 index 0000000..0943b0c --- /dev/null +++ b/components/HomeBanner.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { fetchSettingByCode } from "@/api/serversetting"; + +export const HOME_BANNER_CODE = "homeBanner"; + +/** CTA config for banner */ +export interface HomeBannerCTAConfig { + label?: string; + href?: string; + [key: string]: unknown; +} + +/** Configuration payload for `HomeBanner` */ +export interface HomeBannerConfig { + eyebrow?: string; + title?: string; + subtitle?: string; + description?: string; + backgroundImage?: string; + cta?: HomeBannerCTAConfig; + ctaText?: string; + ctaLink?: string; + [key: string]: unknown; +} + +/** + * Renders the home banner with optional background image and CTA. + * Pulls configuration from server settings keyed by `HOME_BANNER_CODE`. + * @returns React.ReactElement | null + */ +export default function HomeBanner() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + + async function load() { + setLoading(true); + setError(null); + try { + const value = await fetchSettingByCode(HOME_BANNER_CODE); + if (!active) return; + setConfig(value ?? null); + } catch (err) { + if (!active) return; + const message = err instanceof Error ? err.message : "Failed to load home banner"; + setError(message); + setConfig(null); + } finally { + if (active) { + setLoading(false); + } + } + } + + load(); + + return () => { + active = false; + }; + }, []); + + if (loading || error || !config) { + // Render nothing until configuration schema is finalized. + return null; + } + + const title = typeof config.title === "string" ? config.title : undefined; + const subtitle = typeof config.subtitle === "string" ? config.subtitle : undefined; + const description = typeof config.description === "string" ? config.description : undefined; + const eyebrow = typeof config.eyebrow === "string" ? config.eyebrow : undefined; + const backgroundImage = typeof config.backgroundImage === "string" ? config.backgroundImage : undefined; + + let ctaLabel: string | undefined; + let ctaHref: string | undefined; + + if (config.cta && typeof config.cta === "object") { + const { label, href } = config.cta as Record; + if (typeof label === "string") ctaLabel = label; + if (typeof href === "string") ctaHref = href; + } + + // Support legacy field names until the API contract is confirmed. + if (!ctaLabel && typeof config.ctaText === "string") { + ctaLabel = config.ctaText; + } + if (!ctaHref && typeof config.ctaLink === "string") { + ctaHref = config.ctaLink; + } + + const hasContent = Boolean(title || subtitle || description || ctaLabel || backgroundImage); + if (!hasContent) { + return null; + } + + return ( +
+ {backgroundImage ? ( + + ) : null} + {backgroundImage ? ( +
+ ) : null} + +
+ {eyebrow ? ( + + {eyebrow} + + ) : null} + {title ? ( +

+ {title} +

+ ) : null} + {subtitle ? ( +

{subtitle}

+ ) : null} + {description ? ( +

{description}

+ ) : null} + {ctaLabel ? ( + ctaHref ? ( + + {ctaLabel} + + ) : ( + + ) + ) : null} +
+
+ ); +} diff --git a/components/MyMovies.tsx b/components/MyMovies.tsx new file mode 100644 index 0000000..f6acb7b --- /dev/null +++ b/components/MyMovies.tsx @@ -0,0 +1,124 @@ +"use client"; + +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { getScriptEpisodeListNew, MovieProject } from '@/api/script_episode'; +import { getFirstFrame } from '@/utils/tools'; +import { useRouter } from 'next/navigation'; +import { motion } from 'framer-motion'; + +/** + * A lightweight horizontal list of user's movie projects. + * Shows first 9 items, clipped horizontally, with a right-side gradient more button. + */ +const MyMovies: React.FC = () => { + const [projects, setProjects] = useState([]); + const router = useRouter(); + + const StatusBadge = (status: string): JSX.Element => { + return ( + + {status === 'pending' && ( + <> + + + )} + {status === 'failed' && ( + <> + + + FAILED + + + )} + + ); + }; + + useEffect(() => { + const user = JSON.parse(localStorage.getItem('currentUser') || '{}'); + const userId = String(user.id || '0'); + getScriptEpisodeListNew({ user_id: userId, page: 1, per_page: 9 }) + .then((res) => { + if ((res as any)?.code === 0) { + setProjects((res as any).data.movie_projects?.slice(0, 9) || []); + } + }) + .catch(() => {}); + }, []); + + return ( +
+
+

My Movies

+ All movies → +
+ +
+
+
+ {projects.map((p) => ( + + ))} +
+
+
+ + +
+
+ ); +}; + +export default MyMovies; + + diff --git a/components/layout/type.ts b/components/layout/type.ts index 9660c8b..b6d332b 100644 --- a/components/layout/type.ts +++ b/components/layout/type.ts @@ -1,4 +1,4 @@ -import { BookHeart, Gift } from "lucide-react"; +import { BookHeart, Gift, Home } from "lucide-react"; interface NavigationItem { name: string; @@ -15,6 +15,7 @@ export const navigationItems: Navigations[] = [ { title: 'Main', items: [ + { name: 'Home', href: '/home', icon: Home }, { name: 'My Portfolio', href: '/movies', icon: BookHeart }, { name: 'Share', href: '/share', icon: Gift }, ], diff --git a/public/assets/home.mp4 b/public/assets/home.mp4 deleted file mode 100644 index 1f22552..0000000 Binary files a/public/assets/home.mp4 and /dev/null differ diff --git a/public/assets/module2 (1).mp4 b/public/assets/module2 (1).mp4 deleted file mode 100644 index d4fe2a5..0000000 Binary files a/public/assets/module2 (1).mp4 and /dev/null differ diff --git a/public/assets/module2 (2).mp4 b/public/assets/module2 (2).mp4 deleted file mode 100644 index 3055bd3..0000000 Binary files a/public/assets/module2 (2).mp4 and /dev/null differ diff --git a/public/assets/module2 (3).mp4 b/public/assets/module2 (3).mp4 deleted file mode 100644 index 070f1ea..0000000 Binary files a/public/assets/module2 (3).mp4 and /dev/null differ diff --git a/public/assets/module4 (1).mp4 b/public/assets/module4 (1).mp4 deleted file mode 100644 index cd969ab..0000000 Binary files a/public/assets/module4 (1).mp4 and /dev/null differ diff --git a/public/assets/module4 (2).mp4 b/public/assets/module4 (2).mp4 deleted file mode 100644 index 7864a15..0000000 Binary files a/public/assets/module4 (2).mp4 and /dev/null differ diff --git a/public/assets/module4 (3).mp4 b/public/assets/module4 (3).mp4 deleted file mode 100644 index 4de3e15..0000000 Binary files a/public/assets/module4 (3).mp4 and /dev/null differ diff --git a/public/assets/module4 (4).mp4 b/public/assets/module4 (4).mp4 deleted file mode 100644 index 01f3bcd..0000000 Binary files a/public/assets/module4 (4).mp4 and /dev/null differ diff --git a/utils/tools.ts b/utils/tools.ts index b75fac8..26249ff 100644 --- a/utils/tools.ts +++ b/utils/tools.ts @@ -1,4 +1,6 @@ import { ScriptSlice, ScriptSliceType } from "@/app/service/domain/valueObject"; +import cover_image1 from '@/public/assets/cover_image3.jpg'; +import cover_image2 from '@/public/assets/cover_image_shu.jpg'; export function parseScriptEntity(text: string): ScriptSlice { const scriptSlice = new ScriptSlice( @@ -118,7 +120,10 @@ export const downloadAllVideos = async (urls: string[]) => { * 发现 链接 包含 ‘aliyuncs.com’ 是阿里云地址 * @param url 视频URL */ -export const getFirstFrame = (url: string, width?: number) => { +export const getFirstFrame = (url: string, width?: number, aspectRatio?: 'VIDEO_ASPECT_RATIO_PORTRAIT' | 'VIDEO_ASPECT_RATIO_LANDSCAPE') => { + if (!url) { + return `${aspectRatio === 'VIDEO_ASPECT_RATIO_PORTRAIT' ? cover_image2.src : cover_image1.src}` + } if (url.includes('aliyuncs.com')) { return url + '?x-oss-process=video/snapshot,t_1000,f_jpg' + `${width ? ',w_'+width : ''}`; } else {