forked from 77media/video-flow
merge new home
This commit is contained in:
commit
f69e17193e
9
app/home/layout.tsx
Normal file
9
app/home/layout.tsx
Normal 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
15
app/home/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,7 +15,7 @@ export default function Home() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated()) {
|
if (isAuthenticated()) {
|
||||||
router.replace('/movies');
|
router.replace('/home');
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -144,6 +144,8 @@ export interface StoryTemplateEntity {
|
|||||||
category: string;
|
category: string;
|
||||||
/** 故事模板ID */
|
/** 故事模板ID */
|
||||||
template_id: string;
|
template_id: string;
|
||||||
|
/** 故事模板视频URL */
|
||||||
|
show_url: string;
|
||||||
/** 故事角色 */
|
/** 故事角色 */
|
||||||
storyRole: {
|
storyRole: {
|
||||||
/** 角色名 */
|
/** 角色名 */
|
||||||
|
|||||||
213
components/FamousTemplate.tsx
Normal file
213
components/FamousTemplate.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
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)
|
||||||
|
const [activeTab, setActiveTab] = useState<"all" | "music" | "animation" | "thriller">("all")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void getTemplateStoryList()
|
||||||
|
}, [getTemplateStoryList])
|
||||||
|
|
||||||
|
const filteredTemplates = templateStoryList.filter((t) => {
|
||||||
|
if (activeTab === "all") return true
|
||||||
|
const categories = (t.category || "").split(",").map((s) => s.trim().toLowerCase())
|
||||||
|
return categories.includes(activeTab)
|
||||||
|
})
|
||||||
|
|
||||||
|
const topTemplates = filteredTemplates
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section data-alt="famous-template" className="w-full">
|
||||||
|
<div data-alt="famous-template-header" className="mb-4 flex items-center">
|
||||||
|
<h2 data-alt="famous-template-title" className="text-xl py-4 font-semibold text-white">
|
||||||
|
Hot Templates
|
||||||
|
</h2>
|
||||||
|
<div data-alt="template-tabs" className="flex items-center gap-2 ml-4">
|
||||||
|
{(["all", "music", "animation", "thriller"] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
data-alt={`template-tab-${tab}`}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`px-3 py-1 italic rounded-none text-sm transition-colors border ${
|
||||||
|
activeTab === tab
|
||||||
|
? "border-white/60 bg-white/60 text-slate-900"
|
||||||
|
: "border-white/20 text-white/80 hover:border-white/40 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
216
components/HomeBanner.tsx
Normal file
216
components/HomeBanner.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { fetchSettingByCode } from "@/api/serversetting";
|
||||||
|
import { X, Eclipse } from "lucide-react";
|
||||||
|
import { ChatInputBox } from "@/components/ChatInputBox/ChatInputBox";
|
||||||
|
|
||||||
|
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);
|
||||||
|
const [isFlying, setIsFlying] = useState<boolean>(false);
|
||||||
|
const autoCollapseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setIsFlying(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBannerClick = () => {
|
||||||
|
if (isFlying) {
|
||||||
|
setIsFlying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto collapse after mount (3s)
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoCollapseTimerRef.current) return;
|
||||||
|
autoCollapseTimerRef.current = setTimeout(() => {
|
||||||
|
setIsFlying(true);
|
||||||
|
}, 3000);
|
||||||
|
return () => {
|
||||||
|
if (autoCollapseTimerRef.current) {
|
||||||
|
clearTimeout(autoCollapseTimerRef.current);
|
||||||
|
autoCollapseTimerRef.current = 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 (
|
||||||
|
<div data-alt="home-banner-wrapper" className="relative w-full mx-auto p-0 overflow-hidden">
|
||||||
|
{/* Banner overlay - stacked above */}
|
||||||
|
<section
|
||||||
|
data-alt="home-banner"
|
||||||
|
className={`absolute inset-0 z-10 isolate overflow-hidden rounded-3xl px-6 py-6 text-white border-2 border-transparent hover:border-custom-blue/50 transition-all duration-500 ease-in-out ${
|
||||||
|
isFlying
|
||||||
|
? "cursor-pointer translate-x-[90%] -translate-y-[70%] scale-[0.85] opacity-95 rotate-3"
|
||||||
|
: "translate-x-0 translate-y-0 scale-100 opacity-100 rotate-0"
|
||||||
|
}`}
|
||||||
|
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 flex-col items-center gap-4 text-center md:items-start md:text-left"
|
||||||
|
>
|
||||||
|
{/* Dismiss button */}
|
||||||
|
<div className="absolute right-0 top-0">
|
||||||
|
<button
|
||||||
|
data-alt="banner-dismiss"
|
||||||
|
type="button"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="text-white hover:bg-white/20 rounded-full p-2"
|
||||||
|
aria-label="Dismiss banner"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isFlying ? (
|
||||||
|
<button type="button" onClick={handleBannerClick} className="absolute left-[-15px] bottom-[-20px] h-5 w-5">
|
||||||
|
<Eclipse className="inset-0 h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Base content - always present under the banner */}
|
||||||
|
<div data-alt="home-banner-base" className="relative bg-transparent py-6 min-h-[300px]">
|
||||||
|
<ChatInputBox noData={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
components/MyMovies.tsx
Normal file
124
components/MyMovies.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { BookHeart, Gift, Plus } from "lucide-react";
|
import { BookHeart, Gift, Home, Plus } from "lucide-react";
|
||||||
|
|
||||||
interface NavigationItem {
|
interface NavigationItem {
|
||||||
name: string;
|
name: string;
|
||||||
href: string;
|
href: string;
|
||||||
@ -15,6 +14,7 @@ export const navigationItems: Navigations[] = [
|
|||||||
{
|
{
|
||||||
title: 'Main',
|
title: 'Main',
|
||||||
items: [
|
items: [
|
||||||
|
{ name: 'Home', href: '/home', icon: Home },
|
||||||
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
||||||
{ name: 'Share', href: '/share', icon: Gift },
|
{ name: 'Share', href: '/share', icon: Gift },
|
||||||
{ name: 'Create', href: '/create', icon: Plus },
|
{ name: 'Create', href: '/create', icon: Plus },
|
||||||
|
|||||||
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.
@ -1,4 +1,6 @@
|
|||||||
import { ScriptSlice, ScriptSliceType } from "@/app/service/domain/valueObject";
|
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 {
|
export function parseScriptEntity(text: string): ScriptSlice {
|
||||||
const scriptSlice = new ScriptSlice(
|
const scriptSlice = new ScriptSlice(
|
||||||
@ -118,7 +120,10 @@ export const downloadAllVideos = async (urls: string[]) => {
|
|||||||
* 发现 链接 包含 ‘aliyuncs.com’ 是阿里云地址
|
* 发现 链接 包含 ‘aliyuncs.com’ 是阿里云地址
|
||||||
* @param url 视频URL
|
* @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')) {
|
if (url.includes('aliyuncs.com')) {
|
||||||
return url + '?x-oss-process=video/snapshot,t_1000,f_jpg' + `${width ? ',w_'+width : ''}`;
|
return url + '?x-oss-process=video/snapshot,t_1000,f_jpg' + `${width ? ',w_'+width : ''}`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user