新增 home页

This commit is contained in:
moux1024 2025-10-14 17:53:46 +08:00
parent f680e7c002
commit 954774d1b1
17 changed files with 508 additions and 3 deletions

9
app/home/layout.tsx Normal file
View File

@ -0,0 +1,9 @@
import { DashboardLayout } from '@/components/layout/dashboard-layout';
export default function HomeLayout({
children,
}: {
children: React.ReactNode;
}) {
return <DashboardLayout>{children}</DashboardLayout>;
}

15
app/home/page.tsx Normal file
View File

@ -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 (
<div className="w-full h-full overflow-y-auto pb-4">
<HomeBanner />
<FamousTemplate />
<MyMovies />
</div>
);
}

View File

@ -15,7 +15,7 @@ export default function Home() {
useEffect(() => {
if (isAuthenticated()) {
router.replace('/movies');
router.replace('/home');
}
}, [router]);
return (

View File

@ -144,6 +144,8 @@ export interface StoryTemplateEntity {
category: string;
/** 故事模板ID */
template_id: string;
/** 故事模板视频URL */
show_url: string;
/** 故事角色 */
storyRole: {
/** 角色名 */

View File

@ -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<string | undefined>(undefined)
const [isTemplateCreating, setIsTemplateCreating] = useState(false)
const [isRoleGenerating, setIsRoleGenerating] = useState<{ [key: string]: boolean }>({})
const [isItemGenerating, setIsItemGenerating] = useState<{ [key: string]: boolean }>({})
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null)
const [isPreviewReady, setIsPreviewReady] = useState(false)
useEffect(() => {
void getTemplateStoryList()
}, [getTemplateStoryList])
const topTemplates = templateStoryList.slice(0, 10)
return (
<section data-alt="famous-template" className="w-full">
<div data-alt="famous-template-header" className="mb-4 flex items-center justify-between">
<h2 data-alt="famous-template-title" className="text-xl py-4 font-semibold text-white">
Make Movie
</h2>
</div>
{isLoading ? (
<div data-alt="loading" className="text-sm text-white/70">
Loading...
</div>
) : (
<div data-alt="template-grid" className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-8">
{topTemplates.map((t) => {
return (
<div data-alt="template-item" key={t.id} className="relative h-40 group">
<div
data-alt="template-item-placeholder"
className="flex items-center gap-3 rounded-lg border border-white/10 bg-white/0 h-full transition-transform duration-300 ease-out group-hover:scale-105"
>
<div
data-alt="template-thumb"
className="w-full h-full rounded-md overflow-hidden border border-white/10 flex-shrink-0"
>
<img
src={t.image_url?.[0] || ""}
alt={t.name}
className="w-full h-full object-cover object-center"
/>
<div data-alt="template-meta" className="flex-1 min-w-0 absolute bg-black/50 bottom-0 left-0 right-0 px-3 py-3">
<div data-alt="template-name" className="text-base font-bold text-white truncate">{t.name}</div>
</div>
</div>
</div>
<div
data-alt="template-item-click-layer"
className="absolute inset-0 cursor-pointer"
onClick={() => {
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)
}
}}
/>
</div>
)
})}
</div>
)}
{/* 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 (
<div
data-alt="template-preview-modal"
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={() => setActiveTemplateId(null)}
>
<div
data-alt="template-preview-modal-content"
className="relative w-[80vw] min-h-[40vw] rounded-lg overflow-hidden border border-white/30 bg-black shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<>
<video
src={active.show_url}
autoPlay
loop
muted
playsInline
onCanPlay={() => setIsPreviewReady(true)}
className={`w-full h-auto transition-opacity duration-200 ${isPreviewReady ? "opacity-100" : "opacity-0"}`}
/>
{!isPreviewReady && (
<div
data-alt="template-preview-loading"
className="absolute inset-0 flex items-center justify-center"
>
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white/80 animate-spin" />
</div>
)}
{/* template-preview-header */}
{isPreviewReady && (
<div
data-alt="template-preview-header"
className="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/90 via-black/80 to-transparent px-6 py-8 text-center"
>
<div className="text-bold text-2xl text-white/90 line-clamp-2 text-left mb-4">
{active.name}
</div>
</div>
)}
{isPreviewReady && (
<div
data-alt="template-preview-footer"
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/80 to-transparent px-6 py-8 text-center"
>
<div className="text-base text-white/90 line-clamp-2 text-left mb-4">
{active.generateText || active.name}
</div>
<button
type="button"
className="items-center text-bold justify-center rounded-full border border-white/30 bg-white/10 px-6 py-2 text-sm text-xl text-white transition hover:border-white hover:bg-white hover:text-slate-900"
onClick={() => {
setInitialTemplateId(active.id || active.template_id)
setIsModalOpen(true)
setActiveTemplateId(null)
}}
data-alt="template-preview-try-it"
>
Try it Free
</button>
</div>
)}
<button
type="button"
data-alt="template-preview-modal-close"
className="absolute top-3 right-3 inline-flex items-center justify-center rounded-full border border-white/30 bg-white/10 p-2 text-white hover:border-white hover:bg-white hover:text-slate-900"
onClick={() => setActiveTemplateId(null)}
>
<X className="h-4 w-4" />
</button>
</>
</div>
</div>
)
})()}
<PcTemplateModal
isTemplateCreating={isTemplateCreating}
setIsTemplateCreating={setIsTemplateCreating}
isRoleGenerating={isRoleGenerating}
setIsRoleGenerating={setIsRoleGenerating}
isItemGenerating={isItemGenerating}
setIsItemGenerating={setIsItemGenerating}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
initialTemplateId={initialTemplateId}
configOptions={{ mode: "auto", resolution: "720p", language: "english", videoDuration: "auto" }}
/>
</section>
)
}
export default FamousTemplate

159
components/HomeBanner.tsx Normal file
View File

@ -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<HomeBannerConfig | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
async function load() {
setLoading(true);
setError(null);
try {
const value = await fetchSettingByCode<HomeBannerConfig>(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<string, unknown>;
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 (
<section
data-alt="home-banner"
className="relative isolate overflow-hidden rounded-3xl px-6 py-16 text-white border-2 border-transparent hover:border-custom-blue/50 transition-all duration-300"
aria-label="Home banner"
>
{backgroundImage ? (
<img
data-alt="background-image"
src={backgroundImage}
className="absolute inset-0 h-full w-full object-cover"
/>
) : null}
{backgroundImage ? (
<div data-alt="background-overlay" className="absolute inset-0 bg-black/40" />
) : null}
<div
data-alt="banner-content"
className="relative flex max-w-4xl flex-col items-center gap-4 text-center md:items-start md:text-left"
>
{eyebrow ? (
<span data-alt="banner-eyebrow" className="text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
{eyebrow}
</span>
) : null}
{title ? (
<h1 data-alt="banner-title" className="text-3xl font-semibold leading-tight md:text-5xl">
{title}
</h1>
) : null}
{subtitle ? (
<p data-alt="banner-subtitle" className="text-lg text-white/80 md:text-xl">{subtitle}</p>
) : null}
{description ? (
<p data-alt="banner-description" className="max-w-2xl text-base text-white/70">{description}</p>
) : null}
{ctaLabel ? (
ctaHref ? (
<a
data-alt="cta-link"
href={ctaHref}
className="inline-flex items-center justify-center rounded-full border border-white/30 bg-white/10 px-6 py-2 text-sm font-medium text-white transition hover:border-white hover:bg-white hover:text-slate-900"
>
{ctaLabel}
</a>
) : (
<button
data-alt="cta-button"
type="button"
className="inline-flex items-center justify-center rounded-full border border-white/30 bg-white/10 px-6 py-2 text-sm font-medium text-white transition hover:border-white hover:bg-white hover:text-slate-900"
>
{ctaLabel}
</button>
)
) : null}
</div>
</section>
);
}

124
components/MyMovies.tsx Normal file
View File

@ -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<MovieProject[]>([]);
const router = useRouter();
const StatusBadge = (status: string): JSX.Element => {
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="flex items-center"
data-alt="status-badge"
>
{status === 'pending' && (
<>
<motion.span
className="w-2 h-2 rounded-full bg-yellow-400 shadow-[0_0_8px_rgba(255,220,100,0.9)]"
animate={{ scale: [1, 1.4, 1] }}
transition={{ repeat: Infinity, duration: 1.5 }}
/>
</>
)}
{status === 'failed' && (
<>
<motion.span
className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_8px_rgba(255,0,80,0.9)]"
/>
<span className="ml-1 text-xs tracking-widest text-red-400 font-medium drop-shadow-[0_0_6px_rgba(255,0,80,0.6)]">
FAILED
</span>
</>
)}
</motion.div>
);
};
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 (
<section data-alt="my-movies" className="w-full">
<div data-alt="my-movies-header" className="mb-4 flex items-center justify-between">
<h2 data-alt="my-movies-title" className="text-xl py-4 font-semibold text-white">My Movies</h2>
<Link data-alt="all-movies-link" href="/movies" className="text-sm px-2 border rounded-full text-blue-400 hover:text-blue-300">All movies </Link>
</div>
<div data-alt="my-movies-row" className="w-full flex items-stretch gap-4">
<div data-alt="movies-scroll" className="flex-1 overflow-hidden relative">
<div data-alt="movies-list" className="flex gap-4">
{projects.map((p) => (
<button
type="button"
key={p.project_id}
data-alt="movie-item"
className="w-48 flex-shrink-0 text-left"
onMouseEnter={(e) => {
const v = e.currentTarget.querySelector('video');
if (v) {
// @ts-ignore
v.play?.();
}
}}
onMouseLeave={(e) => {
const v = e.currentTarget.querySelector('video');
if (v) {
// @ts-ignore
v.pause?.();
// @ts-ignore
v.currentTime = 0;
}
}}
onClick={() => router.push(`/movies/work-flow?episodeId=${p.project_id}`)}
>
<div data-alt="movie-thumb" className="w-48 h-28 rounded-lg overflow-hidden border border-white/10 bg-white/5 relative">
<video
src={p.final_video_url || p.final_simple_video_url || p.video_urls || ''}
className="w-full h-full object-cover object-center"
playsInline
muted
preload="none"
poster={p.video_snapshot_url || getFirstFrame(p.final_video_url || p.final_simple_video_url || p.video_urls || '', 300, p.aspect_ratio)}
/>
<div data-alt="status-overlay" className="absolute top-2 left-2">
{StatusBadge((p.status === 'COMPLETED' || p.final_simple_video_url) ? 'completed' : p.status === 'FAILED' ? 'failed' : 'pending')}
</div>
</div>
<div data-alt="movie-name" className="mt-2 text-sm text-white/90 truncate">{p.name}</div>
</button>
))}
</div>
<div data-alt="mask" className="absolute right-0 top-0 w-20 h-full bg-gradient-to-r from-transparent to-black"></div>
</div>
</div>
</section>
);
};
export default MyMovies;

View File

@ -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 },
],

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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 {