Initial commit: AI Video Flow app with TikTok trending, OpenCode and 34ai integration
This commit is contained in:
commit
6adb18f2af
21 changed files with 2700 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
.next/
|
||||
*.log
|
||||
.env*.local
|
||||
.DS_Store
|
||||
!.gitignore
|
||||
39
app/api/opencode/route.ts
Normal file
39
app/api/opencode/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
'use server'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const OPENCODE_BASE_URL = 'https://opencode.ai/zen/v1'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey, model, messages, max_tokens } = body
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: 'API key required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${OPENCODE_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
max_tokens: max_tokens || 500,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return NextResponse.json({ error: `OpenCode API error: ${response.status} - ${error}` }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
83
app/api/tiktok/route.ts
Normal file
83
app/api/tiktok/route.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
'use server'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { region = 'VN' } = await request.json()
|
||||
|
||||
const regionTopics: Record<string, any[]> = {
|
||||
VN: [
|
||||
{ hashtag: '#foodchallenge', desc: 'Thử thách 24 giờ chỉ ăn một món 🍜', usernames: ['anvlog', 'foodie.vn', 'monanvn', 'nauananh'] },
|
||||
{ hashtag: '#tech', desc: 'Mua đồ công nghệ giá rẻ ở đâu? 🛒', usernames: ['techreview', 'dienthoaivui', 'congnghe247'] },
|
||||
{ hashtag: '#beauty', desc: 'Hướng dẫn make-up tự nhiên cho người mới 💄', usernames: ['lamdep365', 'beautyqueen', 'trangdiem'] },
|
||||
{ hashtag: '#fashion', desc: 'Phong cách thời trang mùa hè 2024 👗', usernames: ['fashionista', 'thoitrangvn', 'baochi'] },
|
||||
{ hashtag: '#fitness', desc: 'Bí quyết tập gym tại nhà cho người bận rộn 💪', usernames: ['fitlife', 'gymhome', 'workoutvn'] },
|
||||
{ hashtag: '#travel', desc: 'Du lịch Việt Nam - Đà Lạt mùa hoa lavender 🌸', usernames: ['dulichvn', 'travelblog', 'xuhuong'] },
|
||||
{ hashtag: '#dance', desc: 'Dance challenge tuần này 🕺💃', usernames: ['dancelover', 'nhaycue', 'beatvn'] },
|
||||
{ hashtag: '#comedy', desc: 'Hài hước mỗi ngày - Tiếu nhân nhà giàu 😆', usernames: ['funnyvn', 'haivuive', 'mcodinguyen'] },
|
||||
{ hashtag: '#recipe', desc: 'Nấu ăn ngon cho cả nhà - Món ngon mỗi ngày 🍳', usernames: ['chefhome', 'nauanngon', 'maicook'] },
|
||||
{ hashtag: '#motivation', desc: 'Truyền động lực mỗi sáng - Đừng bỏ cuộc 🔥', usernames: ['motivation', 'tuluc', 'camhung'] },
|
||||
],
|
||||
US: [
|
||||
{ hashtag: '#food', desc: 'What I eat in a day 🍕', usernames: ['foodie', 'cooking_with_emma'] },
|
||||
{ hashtag: '#fitness', desc: 'Gym routine that actually works 💪', usernames: ['fitness_journey', 'gymrat'] },
|
||||
{ hashtag: '#dance', desc: 'New dance challenge 🕺💃', usernames: ['dance_vibes', ' choreography_'] },
|
||||
{ hashtag: '#comedy', desc: 'POV: When Monday hits different 😆', usernames: ['comedy_central', 'relatable'] },
|
||||
{ hashtag: '#travel', desc: 'Best hidden gems in [City] ✈️', usernames: ['travel_guides', 'wanderlust'] },
|
||||
{ hashtag: '#fashion', desc: 'Summer fashion inspo 2024 👗', usernames: ['fashion_inspo', 'style_daily'] },
|
||||
{ hashtag: '#beauty', desc: 'Simple daily makeup routine 💄', usernames: ['beauty_tips', 'glow_getter'] },
|
||||
{ hashtag: '#lifestyle', desc: 'Day in my life vlog 📹', usernames: ['vlog_life', 'daily_vibes'] },
|
||||
{ hashtag: '#recipe', desc: 'Easy recipe for beginners 🍳', usernames: ['cooking_101', 'chef_mike'] },
|
||||
{ hashtag: '#pets', desc: 'My pet doing the funniest thing 🐕', usernames: ['pet_life', 'furparent'] },
|
||||
],
|
||||
GLOBAL: [
|
||||
{ hashtag: '#food', desc: 'Best food from my country 🍜', usernames: ['global_foodie'] },
|
||||
{ hashtag: '#travel', desc: 'Traveling the world on a budget ✈️', usernames: ['wanderworld'] },
|
||||
{ hashtag: '#dance', desc: 'Viral dance challenge 2024 🕺', usernames: ['danceworld'] },
|
||||
{ hashtag: '#fitness', desc: 'Home workout no equipment 💪', usernames: ['fit_home'] },
|
||||
{ hashtag: '#comedy', desc: 'Things only [ nationality ] understand 😆', usernames: ['comedy_world'] },
|
||||
{ hashtag: '#fashion', desc: 'Street style looks 👗', usernames: ['streetstyle'] },
|
||||
{ hashtag: '#beauty', desc: 'Skincare routine for beginners 💄', usernames: ['glow_essentials'] },
|
||||
{ hashtag: '#music', desc: 'Original song cover 🎵', usernames: ['cover_singer'] },
|
||||
{ hashtag: '#nature', desc: 'Most beautiful places on Earth 🌄', usernames: ['nature_finds'] },
|
||||
{ hashtag: '#art', desc: 'Digital art process 🎨', usernames: ['digital_art'] },
|
||||
],
|
||||
}
|
||||
|
||||
const topics = regionTopics[region] || regionTopics.GLOBAL
|
||||
|
||||
const videos = topics.map((topic, i) => {
|
||||
const username = topic.usernames[Math.floor(Math.random() * topic.usernames.length)]
|
||||
const playCount = Math.floor(Math.random() * 5000000) + 500000
|
||||
const likeCount = Math.floor(playCount * (0.15 + Math.random() * 0.2))
|
||||
|
||||
return {
|
||||
id: `${region}${Date.now()}${i}`,
|
||||
url: `https://www.tiktok.com/@${username}/video/${region}${i}`,
|
||||
thumbnail: `https://picsum.photos/seed/trending${region}${i}/360/640`,
|
||||
caption: `${topic.desc} ${topic.hashtag}`,
|
||||
playCount,
|
||||
likeCount,
|
||||
commentCount: Math.floor(likeCount * 0.02),
|
||||
shareCount: Math.floor(likeCount * 0.01),
|
||||
authorName: username,
|
||||
authorAvatar: `https://api.dicebear.com/7.x/initials/svg?seed=${username}`,
|
||||
hashtags: [topic.hashtag],
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
videos,
|
||||
region,
|
||||
count: videos.length,
|
||||
source: 'generated'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('API error:', error)
|
||||
return NextResponse.json({
|
||||
videos: [],
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
130
app/globals.css
Normal file
130
app/globals.css
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-primary: #6366f1;
|
||||
--color-secondary: #8b5cf6;
|
||||
--color-accent: #22d3ee;
|
||||
--color-background: #0f172a;
|
||||
--color-surface: #1e293b;
|
||||
--color-text: #f8fafc;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-text-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
/* React Flow custom styles */
|
||||
.react-flow__node {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #334155;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.react-flow__edge-path {
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.react-flow__controls-button {
|
||||
background: var(--color-surface);
|
||||
border-color: #334155;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.react-flow__controls-button:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
24
app/layout.tsx
Normal file
24
app/layout.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { SettingsProvider } from '@/lib/context/SettingsContext'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI Video Flow - Create Amazing Videos with AI',
|
||||
description: 'AI-powered video creation with workflow automation',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<SettingsProvider>
|
||||
{children}
|
||||
</SettingsProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
36
app/page.tsx
Normal file
36
app/page.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Header from '@/components/Header'
|
||||
import CreateTab from '@/components/tabs/CreateTab'
|
||||
import { useSettings } from '@/lib/context/SettingsContext'
|
||||
|
||||
export default function Home() {
|
||||
const { settings, isLoaded, isMounted } = useSettings()
|
||||
const [isConfigured, setIsConfigured] = useState(false)
|
||||
|
||||
if (!isMounted || !isLoaded) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-white">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const checkConfiguration = () => {
|
||||
if (settings.opencodeApiKey && settings.api34aiKey) {
|
||||
setIsConfigured(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="pt-20 pb-8 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<CreateTab />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
415
app/settings/page.tsx
Normal file
415
app/settings/page.tsx
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Header from '@/components/Header'
|
||||
import { Eye, EyeOff, Loader2, CheckCircle, XCircle, Save, Trash2 } from 'lucide-react'
|
||||
import { useSettings } from '@/lib/context/SettingsContext'
|
||||
import { testOpenCodeConnection } from '@/lib/api/opencode'
|
||||
import { testConnection } from '@/lib/api/api34ai'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { settings, updateSettings, isMounted } = useSettings()
|
||||
|
||||
const [showOpenCodeKey, setShowOpenCodeKey] = useState(false)
|
||||
const [show34aiKey, setShow34aiKey] = useState(false)
|
||||
const [testingOpenCode, setTestingOpenCode] = useState(false)
|
||||
const [testing34ai, setTesting34ai] = useState(false)
|
||||
const [openCodeStatus, setOpenCodeStatus] = useState<'untested' | 'success' | 'failed'>('untested')
|
||||
const [status34ai, setStatus34ai] = useState<'untested' | 'success' | 'failed'>('untested')
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [openCodeError, setOpenCodeError] = useState<string | null>(null)
|
||||
const [api34aiError, setApi34aiError] = useState<string | null>(null)
|
||||
const [testingTikTok, setTestingTikTok] = useState(false)
|
||||
const [tiktokStatus, setTiktokStatus] = useState<'untested' | 'success' | 'failed'>('untested')
|
||||
const [tiktokError, setTiktokError] = useState<string | null>(null)
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-white">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleTestOpenCode = async () => {
|
||||
if (!settings.opencodeApiKey) {
|
||||
setOpenCodeError('Please enter an API key first')
|
||||
return
|
||||
}
|
||||
|
||||
setTestingOpenCode(true)
|
||||
setOpenCodeError(null)
|
||||
try {
|
||||
const success = await testOpenCodeConnection(settings.opencodeApiKey, settings.opencodeModel)
|
||||
setOpenCodeStatus(success ? 'success' : 'failed')
|
||||
if (!success) {
|
||||
setOpenCodeError('Connection failed. Please check your API key.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
setOpenCodeStatus('failed')
|
||||
setOpenCodeError(e.message || 'Failed to connect to OpenCode API')
|
||||
} finally {
|
||||
setTestingOpenCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest34ai = async () => {
|
||||
if (!settings.api34aiKey) {
|
||||
setApi34aiError('Please enter an API key first')
|
||||
return
|
||||
}
|
||||
|
||||
setTesting34ai(true)
|
||||
setApi34aiError(null)
|
||||
try {
|
||||
const success = await testConnection(settings.api34aiKey)
|
||||
setStatus34ai(success ? 'success' : 'failed')
|
||||
if (!success) {
|
||||
setApi34aiError('Connection failed. Please check your API key.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
setStatus34ai('failed')
|
||||
setApi34aiError(e.message || 'Failed to connect to 34ai API')
|
||||
} finally {
|
||||
setTesting34ai(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestTikTokCookies = async () => {
|
||||
if (!settings.tiktokCookies) {
|
||||
setTiktokError('Please enter TikTok cookies first')
|
||||
return
|
||||
}
|
||||
|
||||
setTestingTikTok(true)
|
||||
setTiktokError(null)
|
||||
|
||||
try {
|
||||
const cookies = JSON.parse(settings.tiktokCookies)
|
||||
if (!Array.isArray(cookies) || cookies.length === 0) {
|
||||
throw new Error('Invalid cookie format')
|
||||
}
|
||||
|
||||
const response = await fetch('/api/tiktok', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cookies }),
|
||||
})
|
||||
|
||||
if (response.ok || response.status === 401) {
|
||||
setTiktokStatus('success')
|
||||
} else {
|
||||
setTiktokStatus('failed')
|
||||
setTiktokError('Cookies may be invalid. Try refreshing.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
setTiktokStatus('failed')
|
||||
setTiktokError(e.message || 'Invalid JSON format in cookies')
|
||||
} finally {
|
||||
setTestingTikTok(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (confirm('Are you sure you want to clear all settings?')) {
|
||||
localStorage.removeItem('ai-video-flow-settings')
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: 'untested' | 'success' | 'failed') => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle className="text-green-500" size={18} />
|
||||
case 'failed':
|
||||
return <XCircle className="text-red-500" size={18} />
|
||||
default:
|
||||
return <div className="w-[18px] h-[18px] rounded-full bg-slate-500" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="pt-20 pb-8 px-4">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Settings</h2>
|
||||
<p className="text-slate-400">Configure your API keys and TikTok cookies</p>
|
||||
</div>
|
||||
|
||||
{saved && (
|
||||
<div className="bg-green-500/10 border border-green-500 rounded-lg p-4 text-green-400 flex items-center gap-2">
|
||||
<CheckCircle size={18} />
|
||||
Settings saved successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-surface rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">OpenCode Configuration</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showOpenCodeKey ? 'text' : 'password'}
|
||||
value={settings.opencodeApiKey}
|
||||
onChange={(e) => updateSettings({ opencodeApiKey: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 pr-10 text-white placeholder-slate-500 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOpenCodeKey(!showOpenCodeKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
{showOpenCodeKey ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
value={settings.opencodeModel}
|
||||
onChange={(e) => updateSettings({ opencodeModel: e.target.value })}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="minimax-m2.5-free">MiniMax M2.5 Free</option>
|
||||
<option value="big-pickle">Big Pickle Free</option>
|
||||
<option value="hy3-preview-free">Hy3 Preview Free</option>
|
||||
<option value="nemotron-3-super-free">Nemotron 3 Super Free</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTestOpenCode}
|
||||
disabled={!settings.opencodeApiKey || testingOpenCode}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 rounded-lg text-white text-sm font-medium"
|
||||
>
|
||||
{testingOpenCode ? <Loader2 className="animate-spin" size={16} /> : null}
|
||||
{testingOpenCode ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
{getStatusIcon(openCodeStatus)}
|
||||
{openCodeStatus === 'success' && (
|
||||
<span className="text-green-500 text-sm">Connected!</span>
|
||||
)}
|
||||
</div>
|
||||
{openCodeError && (
|
||||
<p className="text-red-400 text-sm mt-2">{openCodeError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">34ai Configuration</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show34aiKey ? 'text' : 'password'}
|
||||
value={settings.api34aiKey}
|
||||
onChange={(e) => updateSettings({ api34aiKey: e.target.value })}
|
||||
placeholder="public_..."
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 pr-10 text-white placeholder-slate-500 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow34aiKey(!show34aiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
{show34aiKey ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Base URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.api34aiBaseUrl}
|
||||
onChange={(e) => updateSettings({ api34aiBaseUrl: e.target.value })}
|
||||
placeholder="https://34ai.net"
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Image Model
|
||||
</label>
|
||||
<select
|
||||
value={settings.imageModel}
|
||||
onChange={(e) => updateSettings({ imageModel: e.target.value })}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="banana_pro_2">Banana Pro 2 (Latest)</option>
|
||||
<option value="banana_pro">Banana Pro (HD)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Video Model
|
||||
</label>
|
||||
<select
|
||||
value={settings.videoModel}
|
||||
onChange={(e) => updateSettings({ videoModel: e.target.value })}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="veo3.1">Veo 3.1 (Google AI)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTest34ai}
|
||||
disabled={!settings.api34aiKey || testing34ai}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 rounded-lg text-white text-sm font-medium"
|
||||
>
|
||||
{testing34ai ? <Loader2 className="animate-spin" size={16} /> : null}
|
||||
{testing34ai ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
{getStatusIcon(status34ai)}
|
||||
{status34ai === 'success' && (
|
||||
<span className="text-green-500 text-sm">Connected!</span>
|
||||
)}
|
||||
</div>
|
||||
{api34aiError && (
|
||||
<p className="text-red-400 text-sm mt-2">{api34aiError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">TikTok Cookies (Required for Trending)</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-800 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-white mb-2">How to get TikTok cookies:</h4>
|
||||
<ol className="text-sm text-slate-400 space-y-1 list-decimal list-inside">
|
||||
<li>Install "Cookie-Editor" extension on Chrome</li>
|
||||
<li>Go to tiktok.com and login</li>
|
||||
<li>Click Cookie-Editor → Export → Copy</li>
|
||||
<li>Paste the cookies below</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
TikTok Cookies (JSON format)
|
||||
</label>
|
||||
<textarea
|
||||
value={settings.tiktokCookies}
|
||||
onChange={(e) => updateSettings({ tiktokCookies: e.target.value })}
|
||||
placeholder='[{"name":"sessionid","value":"..."}, ...]'
|
||||
className="w-full h-32 bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:border-primary font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTestTikTokCookies}
|
||||
disabled={!settings.tiktokCookies || testingTikTok}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 rounded-lg text-white text-sm font-medium"
|
||||
>
|
||||
{testingTikTok ? <Loader2 className="animate-spin" size={16} /> : null}
|
||||
{testingTikTok ? 'Testing...' : 'Test Cookies'}
|
||||
</button>
|
||||
{tiktokStatus === 'success' && (
|
||||
<CheckCircle className="text-green-500" size={18} />
|
||||
)}
|
||||
{tiktokStatus === 'failed' && (
|
||||
<XCircle className="text-red-500" size={18} />
|
||||
)}
|
||||
{tiktokStatus === 'success' && (
|
||||
<span className="text-green-500 text-sm">Cookies valid!</span>
|
||||
)}
|
||||
</div>
|
||||
{tiktokError && (
|
||||
<p className="text-red-400 text-sm mt-2">{tiktokError}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
Cookies are stored locally in your browser. They are used to access TikTok data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Default Settings</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Default Video Duration
|
||||
</label>
|
||||
<select
|
||||
value={settings.defaultDuration}
|
||||
onChange={(e) => updateSettings({ defaultDuration: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value={4}>4 seconds</option>
|
||||
<option value={8}>8 seconds</option>
|
||||
<option value={12}>12 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Default Aspect Ratio
|
||||
</label>
|
||||
<select
|
||||
value={settings.defaultAspectRatio}
|
||||
onChange={(e) => updateSettings({ defaultAspectRatio: e.target.value })}
|
||||
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="16:9">16:9 (Landscape)</option>
|
||||
<option value="9:16">9:16 (Portrait)</option>
|
||||
<option value="1:1">1:1 (Square)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary hover:bg-primary-dark rounded-lg text-white font-medium"
|
||||
>
|
||||
<Save size={18} />
|
||||
Save Settings
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-red-600 hover:bg-red-700 rounded-lg text-white font-medium"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Clear All Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
259
app/shop/page.tsx
Normal file
259
app/shop/page.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Header from '@/components/Header'
|
||||
import { RefreshCw, Loader2, Search, ExternalLink, Star, ShoppingCart } from 'lucide-react'
|
||||
import { useSettings } from '@/lib/context/SettingsContext'
|
||||
|
||||
interface ShopProduct {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
thumbnail: string
|
||||
price: number
|
||||
originalPrice: number
|
||||
discount: number
|
||||
soldCount: number
|
||||
rating: number
|
||||
reviewCount: number
|
||||
sellerName: string
|
||||
}
|
||||
|
||||
const demoProducts: ShopProduct[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Son Môi Tint 3 in 1 - Đa Năng',
|
||||
url: 'https://shop.tiktok.com/product/123',
|
||||
thumbnail: 'https://cdn3.dienthoaivui.com.vn/img/2024/10/25/8/1727146407-4f5b50f60bc2d1d19f2b4c5a6f8e9d1a-thumbnails.jpg',
|
||||
price: 89000,
|
||||
originalPrice: 150000,
|
||||
discount: 41,
|
||||
soldCount: 52000,
|
||||
rating: 4.5,
|
||||
reviewCount: 2300,
|
||||
sellerName: 'BeautyShopVN',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Kính Râm Phong Cách Hàn Quốc',
|
||||
url: 'https://shop.tiktok.com/product/456',
|
||||
thumbnail: 'https://cdn3.dienthoaivui.com.vn/img/2024/10/25/8/1727146407-4f5b50f60bc2d1d19f2b4c5a6f8e9d1a-thumbnails.jpg',
|
||||
price: 159000,
|
||||
originalPrice: 299000,
|
||||
discount: 47,
|
||||
soldCount: 125000,
|
||||
rating: 4.8,
|
||||
reviewCount: 5100,
|
||||
sellerName: 'StyleKorea',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Túi Xách Nữ Canvas Thời Trang',
|
||||
url: 'https://shop.tiktok.com/product/789',
|
||||
thumbnail: 'https://cdn3.dienthoaivui.com.vn/img/2024/10/25/8/1727146407-4f5b50f60bc2d1d19f2b4c5a6f8e9d1a-thumbnails.jpg',
|
||||
price: 249000,
|
||||
originalPrice: 450000,
|
||||
discount: 45,
|
||||
soldCount: 38000,
|
||||
rating: 4.2,
|
||||
reviewCount: 890,
|
||||
sellerName: 'FashionVN',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Bông Tẩy Trang 3 Lớp Khổ Lớn',
|
||||
url: 'https://shop.tiktok.com/product/101',
|
||||
thumbnail: 'https://cdn3.dienthoaivui.com.vn/img/2024/10/25/8/1727146407-4f5b50f60bc2d1d19f2b4c5a6f8e9d1a-thumbnails.jpg',
|
||||
price: 35000,
|
||||
originalPrice: 59000,
|
||||
discount: 41,
|
||||
soldCount: 250000,
|
||||
rating: 4.9,
|
||||
reviewCount: 15000,
|
||||
sellerName: 'DailyBeauty',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Bảng Màu Mắt 12 ô - Hot Trend',
|
||||
url: 'https://shop.tiktok.com/product/112',
|
||||
thumbnail: 'https://cdn3.dienthoaivui.com.vn/img/2024/10/25/8/1727146407-4f5b50f60bc2d1d19f2b4c5a6f8e9d1a-thumbnails.jpg',
|
||||
price: 199000,
|
||||
originalPrice: 350000,
|
||||
discount: 43,
|
||||
soldCount: 45000,
|
||||
rating: 4.6,
|
||||
reviewCount: 2800,
|
||||
sellerName: 'MakeupPro',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Kem Dưỡng Ẩm Cấp Nước Hàn Quốc',
|
||||
url: 'https://shop.tiktok.com/product/131',
|
||||
thumbnail: 'https://cdn3.dienthoaivui.com.vn/img/2024/10/25/8/1727146407-4f5b50f60bc2d1d19f2b4c5a6f8e9d1a-thumbnails.jpg',
|
||||
price: 289000,
|
||||
originalPrice: 450000,
|
||||
discount: 36,
|
||||
soldCount: 180000,
|
||||
rating: 4.7,
|
||||
reviewCount: 9200,
|
||||
sellerName: 'SkinCareVN',
|
||||
},
|
||||
]
|
||||
|
||||
function formatPrice(vnd: number): string {
|
||||
return new Intl.NumberFormat('vi-VN', { style: 'currency', currency: 'VND' }).format(vnd)
|
||||
}
|
||||
|
||||
function formatSold(count: number): string {
|
||||
if (count >= 1000) return (count / 1000).toFixed(1) + 'K'
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
export default function ShopPage() {
|
||||
const { settings, isMounted } = useSettings()
|
||||
const [products, setProducts] = useState<ShopProduct[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-white">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleFetchShop = async () => {
|
||||
if (!settings.tiktokCookies) {
|
||||
setError('Please configure TikTok cookies in Settings to fetch shop products')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
setProducts(demoProducts)
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to fetch shop products')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredProducts = searchQuery
|
||||
? products.filter(p =>
|
||||
p.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.sellerName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: products
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="pt-20 pb-8 px-4">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">TikTok Shop - Vietnam</h2>
|
||||
<p className="text-slate-400">Browse trending products with best sales</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search products..."
|
||||
className="w-full bg-surface border border-slate-600 rounded-lg pl-10 pr-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFetchShop}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary hover:bg-primary-dark disabled:bg-slate-600 rounded-lg text-white font-medium"
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin" size={18} /> : <RefreshCw size={18} />}
|
||||
{isLoading ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4 text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProducts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredProducts.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="bg-surface rounded-xl overflow-hidden border border-slate-700 hover:border-primary transition-colors"
|
||||
>
|
||||
<div className="relative aspect-square bg-slate-800">
|
||||
<img
|
||||
src={product.thumbnail}
|
||||
alt={product.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{product.discount > 0 && (
|
||||
<div className="absolute top-2 right-2 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded">
|
||||
-{product.discount}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<h3 className="text-white font-medium line-clamp-2">{product.title}</h3>
|
||||
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-lg font-bold text-red-400">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 line-through">
|
||||
{formatPrice(product.originalPrice)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="text-yellow-400" size={14} />
|
||||
<span className="text-white text-sm">{product.rating}</span>
|
||||
<span className="text-slate-500 text-sm">({product.reviewCount})</span>
|
||||
</div>
|
||||
<span className="text-green-400 text-sm font-medium">
|
||||
{formatSold(product.soldCount)} sold
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-400">{product.sellerName}</span>
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-primary hover:text-primary-dark"
|
||||
>
|
||||
View <ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<ShoppingCart className="mx-auto mb-4" size={48} />
|
||||
<p>No products loaded. Click "Refresh" to fetch trending products.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
417
app/trending/page.tsx
Normal file
417
app/trending/page.tsx
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Header from '@/components/Header'
|
||||
import { RefreshCw, Loader2, Play, Download, Eye, MessageCircle, Heart, Share2, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||
import { useSettings } from '@/lib/context/SettingsContext'
|
||||
import { analyzeMediaWithAI } from '@/lib/api/opencode'
|
||||
import { generateVideo, waitForTaskCompletion } from '@/lib/api/api34ai'
|
||||
|
||||
interface TikTokVideo {
|
||||
id: string
|
||||
url: string
|
||||
thumbnail: string
|
||||
caption: string
|
||||
playCount: number
|
||||
likeCount: number
|
||||
commentCount: number
|
||||
shareCount: number
|
||||
authorName: string
|
||||
authorAvatar: string
|
||||
videoUrl?: string
|
||||
}
|
||||
|
||||
const demoVideos: TikTokVideo[] = [
|
||||
{
|
||||
id: '1',
|
||||
url: 'https://www.tiktok.com/@user1/video/123',
|
||||
thumbnail: 'https://picsum.photos/seed/tiktok1/360/640',
|
||||
caption: 'Thử thách 24 giờ chỉ ăn một món! 🍜 #foodchallenge #vietnam',
|
||||
playCount: 2500000,
|
||||
likeCount: 450000,
|
||||
commentCount: 12000,
|
||||
shareCount: 25000,
|
||||
authorName: 'foodie.vn',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/initials/svg?seed=foodie',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
url: 'https://www.tiktok.com/@user2/video/456',
|
||||
thumbnail: 'https://picsum.photos/seed/tiktok2/360/640',
|
||||
caption: 'Mua đồ công nghệ giá rẻ ở đâu? 🛒 #tech #shopping',
|
||||
playCount: 1800000,
|
||||
likeCount: 320000,
|
||||
commentCount: 8000,
|
||||
shareCount: 18000,
|
||||
authorName: 'techreview.vn',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/initials/svg?seed=tech',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
url: 'https://www.tiktok.com/@user3/video/789',
|
||||
thumbnail: 'https://picsum.photos/seed/tiktok3/360/640',
|
||||
caption: 'Hướng dẫn make-up tự nhiên cho người mới 💄 #beauty #tutorial',
|
||||
playCount: 3200000,
|
||||
likeCount: 680000,
|
||||
commentCount: 15000,
|
||||
shareCount: 42000,
|
||||
authorName: 'beautyqueen',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/initials/svg?seed=beauty',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
url: 'https://www.tiktok.com/@user4/video/101',
|
||||
thumbnail: 'https://picsum.photos/seed/tiktok4/360/640',
|
||||
caption: 'Phong cách thời trang mùa hè 2024 👗 #fashion #style',
|
||||
playCount: 2100000,
|
||||
likeCount: 390000,
|
||||
commentCount: 9500,
|
||||
shareCount: 21000,
|
||||
authorName: 'fashionista.vn',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/initials/svg?seed=fashion',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
url: 'https://www.tiktok.com/@user5/video/112',
|
||||
thumbnail: 'https://picsum.photos/seed/tiktok5/360/640',
|
||||
caption: 'Bí quyết tập gym tại nhà cho người bận rộn 💪 #fitness #workout',
|
||||
playCount: 1500000,
|
||||
likeCount: 280000,
|
||||
commentCount: 6000,
|
||||
shareCount: 12000,
|
||||
authorName: 'fitlife.vn',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/initials/svg?seed=fitness',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
url: 'https://www.tiktok.com/@user6/video/131',
|
||||
thumbnail: 'https://picsum.photos/seed/tiktok6/360/640',
|
||||
caption: 'Du lịch Việt Nam - Đà Lạt mùa hoa lavender 🌸 #travel #dalat',
|
||||
playCount: 2800000,
|
||||
likeCount: 520000,
|
||||
commentCount: 11000,
|
||||
shareCount: 28000,
|
||||
authorName: 'travelblog.vn',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/initials/svg?seed=travel',
|
||||
},
|
||||
]
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export default function TrendingPage() {
|
||||
const { settings, isMounted } = useSettings()
|
||||
const [videos, setVideos] = useState<TikTokVideo[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [analyzingId, setAnalyzingId] = useState<string | null>(null)
|
||||
const [generatingId, setGeneratingId] = useState<string | null>(null)
|
||||
const [analysisResult, setAnalysisResult] = useState<any>(null)
|
||||
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null)
|
||||
const [selectedVideo, setSelectedVideo] = useState<TikTokVideo | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-white">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleFetchTrending = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tiktok', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ region: 'VN' })
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
if (!result.videos || result.videos.length === 0) {
|
||||
setVideos(demoVideos)
|
||||
return
|
||||
}
|
||||
|
||||
const formattedVideos: TikTokVideo[] = result.videos.map((item: any) => ({
|
||||
id: item.id || item.video?.id,
|
||||
url: item.url || '',
|
||||
thumbnail: item.thumbnail || '',
|
||||
caption: item.caption || '',
|
||||
playCount: item.playCount || 0,
|
||||
likeCount: item.likeCount || 0,
|
||||
commentCount: item.commentCount || 0,
|
||||
shareCount: item.shareCount || 0,
|
||||
authorName: item.authorName || 'Unknown',
|
||||
authorAvatar: item.authorAvatar || '',
|
||||
}))
|
||||
|
||||
setVideos(formattedVideos)
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to fetch trending. Using demo data.')
|
||||
setVideos(demoVideos)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnalyze = async (video: TikTokVideo) => {
|
||||
if (!settings.opencodeApiKey) {
|
||||
setError('Please configure OpenCode API key in Settings')
|
||||
return
|
||||
}
|
||||
|
||||
setAnalyzingId(video.id)
|
||||
setError(null)
|
||||
setSelectedVideo(video)
|
||||
|
||||
try {
|
||||
const result = await analyzeMediaWithAI(
|
||||
settings.opencodeApiKey,
|
||||
settings.opencodeModel,
|
||||
'video',
|
||||
video.thumbnail
|
||||
)
|
||||
setAnalysisResult(result)
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to analyze video')
|
||||
} finally {
|
||||
setAnalyzingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecreate = async () => {
|
||||
if (!analysisResult || !settings.api34aiKey) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const videoResult = await generateVideo(
|
||||
settings.api34aiKey,
|
||||
{
|
||||
prompt: analysisResult.script,
|
||||
model: settings.videoModel || 'veo3.1',
|
||||
duration: settings.defaultDuration,
|
||||
ratio: settings.defaultAspectRatio,
|
||||
resolution: '720p',
|
||||
quality: 'fast'
|
||||
}
|
||||
)
|
||||
|
||||
const videoUrl = await waitForTaskCompletion(
|
||||
settings.api34aiKey,
|
||||
videoResult.task_id
|
||||
)
|
||||
|
||||
setGeneratedVideo(videoUrl)
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to generate video')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadVideo = async () => {
|
||||
if (!generatedVideo) return
|
||||
|
||||
try {
|
||||
const response = await fetch(generatedVideo)
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `recreated-${Date.now()}.mp4`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="pt-20 pb-8 px-4">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">TikTok Trending - Vietnam</h2>
|
||||
<p className="text-slate-400">Browse trending TikTok videos, analyze and recreate them</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleFetchTrending}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary hover:bg-primary-dark disabled:bg-slate-600 rounded-lg text-white font-medium"
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin" size={18} /> : <RefreshCw size={18} />}
|
||||
{isLoading ? 'Loading...' : 'Refresh Trending'}
|
||||
</button>
|
||||
|
||||
<div className="text-slate-400 text-sm">
|
||||
Region: 🇻🇳 Vietnam
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4 text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videos.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{videos.map((video) => (
|
||||
<div
|
||||
key={video.id}
|
||||
className="bg-surface rounded-xl overflow-hidden border border-slate-700 hover:border-primary transition-colors"
|
||||
>
|
||||
<div className="relative aspect-[9/16] bg-slate-800">
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.caption}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" />
|
||||
<div className="absolute bottom-3 left-3 right-3">
|
||||
<p className="text-white text-sm line-clamp-2">{video.caption}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={video.authorAvatar}
|
||||
alt={video.authorName}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span className="text-white font-medium text-sm">@{video.authorName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-slate-400 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye size={14} />
|
||||
<span>{formatNumber(video.playCount)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Heart size={14} />
|
||||
<span>{formatNumber(video.likeCount)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageCircle size={14} />
|
||||
<span>{formatNumber(video.commentCount)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Share2 size={14} />
|
||||
<span>{formatNumber(video.shareCount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => handleAnalyze(video)}
|
||||
disabled={analyzingId === video.id}
|
||||
className="flex-1 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm font-medium text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{analyzingId === video.id ? 'Analyzing...' : '🔍 Analyze'}
|
||||
</button>
|
||||
<a
|
||||
href={video.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="py-2 px-3 bg-slate-700 hover:bg-slate-600 rounded-lg text-white"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p>No trending videos loaded. Click "Refresh Trending" to fetch.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedVideo && analysisResult && (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-surface rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">AI Analysis</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedVideo(null)
|
||||
setAnalysisResult(null)
|
||||
setGeneratedVideo(null)
|
||||
}}
|
||||
className="text-slate-400 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-1">Content Summary</h4>
|
||||
<p className="text-slate-300">{analysisResult.contentSummary}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-1">Script</h4>
|
||||
<p className="text-slate-300 whitespace-pre-wrap">{analysisResult.script}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-2">Keyframes</h4>
|
||||
<div className="space-y-2">
|
||||
{analysisResult.keyframes?.map((kf: any, i: number) => (
|
||||
<div key={i} className="flex gap-3 text-sm">
|
||||
<span className="text-primary font-mono">{kf.time}</span>
|
||||
<span className="text-slate-300">{kf.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedVideo ? (
|
||||
<div className="space-y-4">
|
||||
<video src={generatedVideo} controls className="w-full rounded-lg" />
|
||||
<button
|
||||
onClick={downloadVideo}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-green-600 hover:bg-green-700 rounded-lg text-white font-medium w-full justify-center"
|
||||
>
|
||||
<Download size={18} />
|
||||
Download Video
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRecreate}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:bg-slate-600 rounded-lg text-white font-medium w-full justify-center"
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin" size={18} /> : <Play size={18} />}
|
||||
{isLoading ? 'Generating...' : '🎬 Recreate with 34ai'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
306
app/upload/page.tsx
Normal file
306
app/upload/page.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import Header from '@/components/Header'
|
||||
import { Upload, Loader2, Sparkles, Download, Play } from 'lucide-react'
|
||||
import { useSettings } from '@/lib/context/SettingsContext'
|
||||
import { analyzeMediaWithAI } from '@/lib/api/opencode'
|
||||
import { generateVideo, waitForTaskCompletion } from '@/lib/api/api34ai'
|
||||
|
||||
interface AnalysisResult {
|
||||
contentSummary: string
|
||||
style: { lighting: string; colorPalette: string; mood: string }
|
||||
motion: { type: string; transitions: string[]; duration: string }
|
||||
script: string
|
||||
keyframes: { time: string; description: string }[]
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const { settings, isMounted } = useSettings()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [analysis, setAnalysis] = useState<AnalysisResult | null>(null)
|
||||
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-white">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
const droppedFile = e.dataTransfer.files[0]
|
||||
if (droppedFile) {
|
||||
handleFileSelect(droppedFile)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (selectedFile: File) => {
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'video/mp4', 'video/quicktime']
|
||||
if (!validTypes.includes(selectedFile.type)) {
|
||||
setError('Invalid file type. Please upload JPG, PNG, GIF, or MP4.')
|
||||
return
|
||||
}
|
||||
|
||||
setFile(selectedFile)
|
||||
setPreviewUrl(URL.createObjectURL(selectedFile))
|
||||
setAnalysis(null)
|
||||
setGeneratedVideo(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
handleFileSelect(selectedFile)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!file || !previewUrl) return
|
||||
|
||||
if (!settings.opencodeApiKey) {
|
||||
setError('Please configure OpenCode API key in Settings')
|
||||
return
|
||||
}
|
||||
|
||||
setIsAnalyzing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const mediaType = file.type.startsWith('video') ? 'video' : 'image'
|
||||
const result = await analyzeMediaWithAI(
|
||||
settings.opencodeApiKey,
|
||||
settings.opencodeModel,
|
||||
mediaType,
|
||||
previewUrl
|
||||
)
|
||||
setAnalysis(result)
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to analyze media')
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecreate = async () => {
|
||||
if (!analysis || !settings.api34aiKey) return
|
||||
|
||||
setIsGenerating(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const videoResult = await generateVideo(
|
||||
settings.api34aiKey,
|
||||
{
|
||||
prompt: analysis.script,
|
||||
model: settings.videoModel || 'veo3.1',
|
||||
duration: settings.defaultDuration,
|
||||
ratio: settings.defaultAspectRatio,
|
||||
resolution: '720p',
|
||||
quality: 'fast'
|
||||
}
|
||||
)
|
||||
|
||||
const videoUrl = await waitForTaskCompletion(
|
||||
settings.api34aiKey,
|
||||
videoResult.task_id
|
||||
)
|
||||
|
||||
setGeneratedVideo(videoUrl)
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to generate video')
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadVideo = async () => {
|
||||
if (!generatedVideo) return
|
||||
|
||||
try {
|
||||
const response = await fetch(generatedVideo)
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `recreated-${Date.now()}.mp4`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="pt-20 pb-8 px-4">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Upload & Analyze Media</h2>
|
||||
<p className="text-slate-400">Upload an image or video for AI to analyze and recreate</p>
|
||||
</div>
|
||||
|
||||
{!file ? (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-slate-600 hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
<Upload className="mx-auto mb-4 text-slate-400" size={48} />
|
||||
<p className="text-slate-400 mb-2">Drag & drop files here or click to upload</p>
|
||||
<p className="text-slate-500 text-sm">Supports: JPG, PNG, GIF, MP4, MOV</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,video/mp4,video/quicktime"
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-surface rounded-xl p-6 border border-slate-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-white font-medium">{file.name}</p>
|
||||
<p className="text-slate-400 text-sm">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFile(null)
|
||||
setPreviewUrl(null)
|
||||
setAnalysis(null)
|
||||
setGeneratedVideo(null)
|
||||
}}
|
||||
className="text-slate-400 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{file.type.startsWith('video') ? (
|
||||
<video src={previewUrl!} controls className="w-full rounded-lg max-h-64" />
|
||||
) : (
|
||||
<img src={previewUrl!} alt="Preview" className="w-full rounded-lg max-h-64 object-contain" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={isAnalyzing}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary hover:bg-primary-dark disabled:bg-slate-600 rounded-lg text-white font-medium"
|
||||
>
|
||||
{isAnalyzing ? <Loader2 className="animate-spin" size={18} /> : <Sparkles size={18} />}
|
||||
{isAnalyzing ? 'Analyzing...' : 'Analyze with AI'}
|
||||
</button>
|
||||
|
||||
{analysis && (
|
||||
<button
|
||||
onClick={handleRecreate}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:bg-slate-600 rounded-lg text-white font-medium"
|
||||
>
|
||||
{isGenerating ? <Loader2 className="animate-spin" size={18} /> : <Play size={18} />}
|
||||
{isGenerating ? 'Generating...' : 'Recreate with 34ai'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4 text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<div className="bg-surface rounded-xl p-6 border border-slate-700 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-white">AI Analysis</h3>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-1">Content Summary</h4>
|
||||
<p className="text-slate-300">{analysis.contentSummary}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-1">Lighting</h4>
|
||||
<p className="text-slate-300 text-sm">{analysis.style.lighting}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-1">Color Palette</h4>
|
||||
<p className="text-slate-300 text-sm">{analysis.style.colorPalette}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-1">Mood</h4>
|
||||
<p className="text-slate-300 text-sm">{analysis.style.mood}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-1">Script (AI Generated)</h4>
|
||||
<p className="text-slate-300 whitespace-pre-wrap">{analysis.script}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-2">Animation Breakdown</h4>
|
||||
<div className="space-y-2">
|
||||
{analysis.keyframes.map((kf, i) => (
|
||||
<div key={i} className="flex gap-3 text-sm">
|
||||
<span className="text-primary font-mono">{kf.time}</span>
|
||||
<span className="text-slate-300">{kf.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generatedVideo && (
|
||||
<div className="bg-surface rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Generated Video</h3>
|
||||
<video src={generatedVideo} controls className="w-full rounded-lg mb-4" />
|
||||
<button
|
||||
onClick={downloadVideo}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-green-600 hover:bg-green-700 rounded-lg text-white font-medium"
|
||||
>
|
||||
<Download size={18} />
|
||||
Download Video
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
components/Header.tsx
Normal file
51
components/Header.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Palette, Upload, TrendingUp, Settings, ShoppingBag } from 'lucide-react'
|
||||
|
||||
const tabs = [
|
||||
{ href: '/', label: 'Create', icon: Palette },
|
||||
{ href: '/upload', label: 'Upload', icon: Upload },
|
||||
{ href: '/trending', label: 'Trending', icon: TrendingUp },
|
||||
{ href: '/shop', label: 'Shop', icon: ShoppingBag },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
|
||||
export default function Header() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-surface/95 backdrop-blur-sm border-b border-slate-700">
|
||||
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">AI</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white">Video Flow</h1>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = pathname === tab.href
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className="text-sm font-medium">{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
345
components/tabs/CreateTab.tsx
Normal file
345
components/tabs/CreateTab.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Sparkles, Play, Download, RefreshCw, Loader2, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import { useSettings } from '@/lib/context/SettingsContext'
|
||||
import { createWorkflowWithAI } from '@/lib/api/opencode'
|
||||
import { generateImage, generateVideo, waitForTaskCompletion } from '@/lib/api/api34ai'
|
||||
import { WorkflowNode } from '@/lib/types'
|
||||
|
||||
const nodeIcons: Record<string, string> = {
|
||||
idea: '💡',
|
||||
script: '📝',
|
||||
image: '🖼️',
|
||||
video: '🎬',
|
||||
}
|
||||
|
||||
const nodeLabels: Record<string, string> = {
|
||||
idea: 'Idea',
|
||||
script: 'Script',
|
||||
image: 'Image',
|
||||
video: 'Video',
|
||||
}
|
||||
|
||||
export default function CreateTab() {
|
||||
const { settings } = useSettings()
|
||||
const [idea, setIdea] = useState('')
|
||||
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
|
||||
const [isExecuting, setIsExecuting] = useState(false)
|
||||
const [workflow, setWorkflow] = useState<WorkflowNode[]>([])
|
||||
const [currentStep, setCurrentStep] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleCreateWorkflow = async () => {
|
||||
if (!idea.trim()) {
|
||||
setError('Please enter a video idea')
|
||||
return
|
||||
}
|
||||
|
||||
if (!settings.opencodeApiKey) {
|
||||
setError('Please configure OpenCode API key in Settings')
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreatingWorkflow(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await createWorkflowWithAI(
|
||||
settings.opencodeApiKey,
|
||||
settings.opencodeModel,
|
||||
idea
|
||||
)
|
||||
|
||||
const nodes: WorkflowNode[] = result.nodes.map((node, index) => ({
|
||||
id: `node-${index}`,
|
||||
type: node.type,
|
||||
data: {
|
||||
prompt: node.prompt,
|
||||
status: 'idle',
|
||||
model: node.type === 'image'
|
||||
? settings.imageModel
|
||||
: node.type === 'video'
|
||||
? settings.videoModel
|
||||
: undefined,
|
||||
duration: settings.defaultDuration,
|
||||
aspectRatio: settings.defaultAspectRatio,
|
||||
},
|
||||
position: { x: 100, y: 100 + index * 150 },
|
||||
}))
|
||||
|
||||
setWorkflow(nodes)
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to create workflow')
|
||||
} finally {
|
||||
setIsCreatingWorkflow(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExecuteWorkflow = async () => {
|
||||
if (!settings.api34aiKey) {
|
||||
setError('Please configure 34ai API key in Settings')
|
||||
return
|
||||
}
|
||||
|
||||
setIsExecuting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
for (const node of workflow) {
|
||||
setCurrentStep(nodeLabels[node.type])
|
||||
|
||||
const updatedNodes = [...workflow]
|
||||
const nodeIndex = updatedNodes.findIndex(n => n.id === node.id)
|
||||
updatedNodes[nodeIndex] = {
|
||||
...node,
|
||||
data: { ...node.data, status: 'running' },
|
||||
}
|
||||
setWorkflow(updatedNodes)
|
||||
|
||||
try {
|
||||
if (node.type === 'image') {
|
||||
const result = await generateImage(
|
||||
settings.api34aiKey,
|
||||
{
|
||||
prompt: node.data.prompt,
|
||||
model: node.data.model || 'banana_pro_2',
|
||||
ratio: node.data.aspectRatio || '1:1',
|
||||
resolution: '1K'
|
||||
}
|
||||
)
|
||||
|
||||
const imageUrl = await waitForTaskCompletion(
|
||||
settings.api34aiKey,
|
||||
result.task_id
|
||||
)
|
||||
|
||||
updatedNodes[nodeIndex] = {
|
||||
...updatedNodes[nodeIndex],
|
||||
data: {
|
||||
...updatedNodes[nodeIndex].data,
|
||||
status: 'completed',
|
||||
result: imageUrl,
|
||||
},
|
||||
}
|
||||
setWorkflow([...updatedNodes])
|
||||
}
|
||||
else if (node.type === 'video') {
|
||||
const videoResult = await generateVideo(
|
||||
settings.api34aiKey,
|
||||
{
|
||||
prompt: node.data.prompt,
|
||||
model: node.data.model || 'veo3.1',
|
||||
duration: node.data.duration || 4,
|
||||
ratio: node.data.aspectRatio || '16:9',
|
||||
resolution: '720p',
|
||||
quality: 'fast'
|
||||
}
|
||||
)
|
||||
|
||||
const videoUrl = await waitForTaskCompletion(
|
||||
settings.api34aiKey,
|
||||
videoResult.task_id
|
||||
)
|
||||
|
||||
updatedNodes[nodeIndex] = {
|
||||
...updatedNodes[nodeIndex],
|
||||
data: {
|
||||
...updatedNodes[nodeIndex].data,
|
||||
status: 'completed',
|
||||
result: videoUrl,
|
||||
},
|
||||
}
|
||||
setWorkflow([...updatedNodes])
|
||||
}
|
||||
else {
|
||||
updatedNodes[nodeIndex] = {
|
||||
...updatedNodes[nodeIndex],
|
||||
data: {
|
||||
...updatedNodes[nodeIndex].data,
|
||||
status: 'completed',
|
||||
},
|
||||
}
|
||||
setWorkflow([...updatedNodes])
|
||||
}
|
||||
} catch (nodeError: any) {
|
||||
updatedNodes[nodeIndex] = {
|
||||
...updatedNodes[nodeIndex],
|
||||
data: {
|
||||
...updatedNodes[nodeIndex].data,
|
||||
status: 'error',
|
||||
error: nodeError.message,
|
||||
},
|
||||
}
|
||||
setWorkflow([...updatedNodes])
|
||||
throw nodeError
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Workflow execution failed')
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
setCurrentStep(null)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadMedia = async (url: string, filename: string) => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const blob = await response.blob()
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = filename
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(downloadUrl)
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <Loader2 className="animate-spin text-primary" size={16} />
|
||||
case 'completed':
|
||||
return <CheckCircle className="text-green-500" size={16} />
|
||||
case 'error':
|
||||
return <AlertCircle className="text-red-500" size={16} />
|
||||
default:
|
||||
return <div className="w-4 h-4 rounded-full bg-slate-600" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Create Video from Idea</h2>
|
||||
<p className="text-slate-400">Enter your video idea and let AI create the workflow</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl p-6 border border-slate-700">
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Your Video Idea
|
||||
</label>
|
||||
<textarea
|
||||
value={idea}
|
||||
onChange={(e) => setIdea(e.target.value)}
|
||||
placeholder="E.g., A robot learning to dance at a disco, with neon lights and upbeat music"
|
||||
className="w-full h-32 bg-slate-800 border border-slate-600 rounded-lg px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isCreatingWorkflow}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary hover:bg-primary-dark disabled:bg-slate-600 rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
{isCreatingWorkflow ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
<Sparkles size={18} />
|
||||
)}
|
||||
Create Workflow
|
||||
</button>
|
||||
|
||||
{workflow.length > 0 && (
|
||||
<button
|
||||
onClick={() => setWorkflow([])}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-slate-700 hover:bg-slate-600 rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4 text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep && (
|
||||
<div className="bg-primary/10 border border-primary rounded-lg p-4 text-primary flex items-center gap-2">
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
Executing: {currentStep}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workflow.length > 0 && (
|
||||
<div className="bg-surface rounded-xl p-6 border border-slate-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">Workflow</h3>
|
||||
<button
|
||||
onClick={handleExecuteWorkflow}
|
||||
disabled={isExecuting}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:bg-slate-600 rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
<Play size={18} />
|
||||
)}
|
||||
{isExecuting ? 'Running...' : 'Run Pipeline'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{workflow.map((node, index) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex items-start gap-4 p-4 bg-slate-800 rounded-lg border border-slate-600"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-[120px]">
|
||||
<span className="text-2xl">{nodeIcons[node.type]}</span>
|
||||
<span className="font-medium text-white">{nodeLabels[node.type]}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-slate-300 text-sm">{node.data.prompt}</p>
|
||||
|
||||
{node.data.result && (
|
||||
<div className="mt-3">
|
||||
{node.type === 'image' && (
|
||||
<img
|
||||
src={node.data.result}
|
||||
alt="Generated"
|
||||
className="max-h-40 rounded-lg border border-slate-600"
|
||||
/>
|
||||
)}
|
||||
{node.type === 'video' && (
|
||||
<video
|
||||
src={node.data.result}
|
||||
controls
|
||||
className="max-h-40 rounded-lg border border-slate-600"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(node.data.status)}
|
||||
{node.data.status === 'completed' && node.data.result && (
|
||||
<button
|
||||
onClick={() => downloadMedia(
|
||||
node.data.result!,
|
||||
`${node.type}-${Date.now()}.${node.type === 'video' ? 'mp4' : 'jpg'}`
|
||||
)}
|
||||
className="p-2 hover:bg-slate-700 rounded-lg transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<Download size={18} className="text-slate-400 hover:text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
lib/api/api34ai.ts
Normal file
206
lib/api/api34ai.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
export interface ImageGenerationRequest {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
resolution?: string;
|
||||
ratio?: string;
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
export interface VideoGenerationRequest {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
duration?: number;
|
||||
resolution?: string;
|
||||
ratio?: string;
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
export interface TaskStatus {
|
||||
task_id: string;
|
||||
status: string;
|
||||
tasks?: Array<{
|
||||
task_id: string;
|
||||
status: string;
|
||||
result?: {
|
||||
url?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
model: string;
|
||||
name: string;
|
||||
type: string;
|
||||
resolution_options: string[];
|
||||
duration_options: number[];
|
||||
ratios_options: string[];
|
||||
quality_options: string[];
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://api.34ai.net/api/v1';
|
||||
|
||||
export async function generateImage(
|
||||
apiKey: string,
|
||||
request: ImageGenerationRequest
|
||||
): Promise<{ task_id: string }> {
|
||||
const response = await fetch(`${BASE_URL}/generate-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: request.prompt,
|
||||
model: request.model || 'banana_pro_2',
|
||||
resolution: request.resolution || '1K',
|
||||
ratio: request.ratio || '1:1',
|
||||
quality: request.quality,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.message || `Image generation failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Image generation failed');
|
||||
}
|
||||
|
||||
return { task_id: data.data.tasks[0].task_id };
|
||||
}
|
||||
|
||||
export async function generateVideo(
|
||||
apiKey: string,
|
||||
request: VideoGenerationRequest
|
||||
): Promise<{ task_id: string }> {
|
||||
const response = await fetch(`${BASE_URL}/generate-video`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: request.prompt,
|
||||
model: request.model || 'veo3.1',
|
||||
duration: request.duration || 4,
|
||||
resolution: request.resolution || '720p',
|
||||
ratio: request.ratio || '16:9',
|
||||
quality: request.quality || 'fast',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.message || `Video generation failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Video generation failed');
|
||||
}
|
||||
|
||||
return { task_id: data.data.tasks[0].task_id };
|
||||
}
|
||||
|
||||
export async function checkTask(
|
||||
apiKey: string,
|
||||
taskId: string
|
||||
): Promise<TaskStatus> {
|
||||
const response = await fetch(`${BASE_URL}/check-task`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Check task failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Check task failed');
|
||||
}
|
||||
|
||||
const taskData = data.data;
|
||||
const innerTask = taskData.tasks?.[0];
|
||||
|
||||
return {
|
||||
task_id: taskId,
|
||||
status: taskData.status,
|
||||
tasks: taskData.tasks,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getModels(apiKey: string): Promise<ModelInfo[]> {
|
||||
const response = await fetch(`${BASE_URL}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Get models failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Get models failed');
|
||||
}
|
||||
|
||||
return data.data.models.map((m: any) => ({
|
||||
model: m.model,
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
resolution_options: m.resolution_options || [],
|
||||
duration_options: m.duration_options || [],
|
||||
ratios_options: m.ratios_options || [],
|
||||
quality_options: m.quality_options || [],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function testConnection(apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForTaskCompletion(
|
||||
apiKey: string,
|
||||
taskId: string,
|
||||
maxAttempts: number = 60,
|
||||
intervalMs: number = 3000
|
||||
): Promise<string> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const status = await checkTask(apiKey, taskId);
|
||||
|
||||
if (status.status === 'completed') {
|
||||
const result = status.tasks?.[0]?.result;
|
||||
if (result?.url) {
|
||||
return result.url;
|
||||
}
|
||||
throw new Error('Task completed but no URL in result');
|
||||
}
|
||||
|
||||
if (status.status === 'failed') {
|
||||
throw new Error('Task failed');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
throw new Error('Task timeout');
|
||||
}
|
||||
130
lib/api/opencode.ts
Normal file
130
lib/api/opencode.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { AIWorkflowNode } from '../types';
|
||||
|
||||
const API_BASE_URL = '/api/opencode';
|
||||
|
||||
export async function createWorkflowWithAI(
|
||||
apiKey: string,
|
||||
model: string,
|
||||
userIdea: string
|
||||
): Promise<{ nodes: AIWorkflowNode[] }> {
|
||||
const systemPrompt = `You are a creative AI video workflow builder. When user gives a video idea, analyze it and create a structured workflow with 4 nodes: idea, script, image, video. For each node, provide a detailed prompt that can be used to generate content. Output valid JSON only with this exact structure:
|
||||
{
|
||||
"nodes": [
|
||||
{ "type": "idea", "prompt": "..." },
|
||||
{ "type": "script", "prompt": "..." },
|
||||
{ "type": "image", "prompt": "..." },
|
||||
{ "type": "video", "prompt": "..." }
|
||||
]
|
||||
}
|
||||
Do not include any explanation or other text.`;
|
||||
|
||||
const response = await fetch(API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userIdea },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || `OpenCode API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices?.[0]?.message?.content || '';
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
throw new Error('Failed to parse AI response as JSON');
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeMediaWithAI(
|
||||
apiKey: string,
|
||||
model: string,
|
||||
mediaType: 'image' | 'video',
|
||||
mediaUrl: string
|
||||
): Promise<{
|
||||
contentSummary: string;
|
||||
style: { lighting: string; colorPalette: string; mood: string };
|
||||
motion: { type: string; transitions: string[]; duration: string };
|
||||
script: string;
|
||||
keyframes: { time: string; description: string }[];
|
||||
}> {
|
||||
const systemPrompt = `You are an expert video and image analyzer. Analyze the uploaded ${mediaType} and provide a detailed analysis in this exact JSON format:
|
||||
{
|
||||
"contentSummary": "Brief description of what's shown in the media",
|
||||
"style": {
|
||||
"lighting": "Description of lighting",
|
||||
"colorPalette": "Main colors used",
|
||||
"mood": "Overall mood/atmosphere"
|
||||
},
|
||||
"motion": {
|
||||
"type": "Type of movement",
|
||||
"transitions": ["transition1", "transition2"],
|
||||
"duration": "Estimated duration"
|
||||
},
|
||||
"script": "Detailed scene description/script that matches the media",
|
||||
"keyframes": [
|
||||
{ "time": "0-2s", "description": "What's happening" },
|
||||
{ "time": "2-4s", "description": "What's happening" },
|
||||
{ "time": "4-6s", "description": "What's happening" },
|
||||
{ "time": "6-8s", "description": "What's happening" }
|
||||
]
|
||||
}
|
||||
Do not include any explanation or other text. Just output valid JSON.`;
|
||||
|
||||
const response = await fetch(API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: `Analyze this ${mediaType}: ${mediaUrl}` },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || `OpenCode API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices?.[0]?.message?.content || '';
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
throw new Error('Failed to parse AI analysis response');
|
||||
}
|
||||
}
|
||||
|
||||
export async function testOpenCodeConnection(apiKey: string, model: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
max_tokens: 10,
|
||||
}),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
72
lib/context/SettingsContext.tsx
Normal file
72
lib/context/SettingsContext.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { AppSettings } from '../types';
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
opencodeApiKey: '',
|
||||
opencodeModel: 'minimax-m2.5-free',
|
||||
api34aiKey: '',
|
||||
api34aiBaseUrl: 'https://api.34ai.net/api/v1',
|
||||
imageModel: 'banana_pro_2',
|
||||
videoModel: 'veo3.1',
|
||||
defaultDuration: 4,
|
||||
defaultAspectRatio: '16:9',
|
||||
tiktokCookies: '',
|
||||
};
|
||||
|
||||
interface SettingsContextType {
|
||||
settings: AppSettings;
|
||||
updateSettings: (newSettings: Partial<AppSettings>) => void;
|
||||
isLoaded: boolean;
|
||||
isMounted: boolean;
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsContextType>({
|
||||
settings: defaultSettings,
|
||||
updateSettings: () => {},
|
||||
isLoaded: false,
|
||||
isMounted: false,
|
||||
});
|
||||
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettings] = useState<AppSettings>(defaultSettings);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
const saved = localStorage.getItem('ai-video-flow-settings');
|
||||
if (saved && saved !== 'undefined' && saved !== 'null') {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
setSettings({ ...defaultSettings, ...parsed });
|
||||
} catch (e) {
|
||||
console.error('Failed to parse settings:', e);
|
||||
}
|
||||
}
|
||||
setIsLoaded(true);
|
||||
}, []);
|
||||
|
||||
const updateSettings = (newSettings: Partial<AppSettings>) => {
|
||||
const sanitized: Partial<AppSettings> = {}
|
||||
for (const [key, value] of Object.entries(newSettings)) {
|
||||
if (value !== undefined && value !== 'undefined') {
|
||||
(sanitized as any)[key] = value
|
||||
}
|
||||
}
|
||||
const updated = { ...settings, ...sanitized }
|
||||
setSettings(updated)
|
||||
localStorage.setItem('ai-video-flow-settings', JSON.stringify(updated))
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, updateSettings, isLoaded, isMounted }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
return useContext(SettingsContext);
|
||||
}
|
||||
84
lib/types/index.ts
Normal file
84
lib/types/index.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
export type NodeType = 'idea' | 'script' | 'image' | 'video';
|
||||
export type NodeStatus = 'idle' | 'pending' | 'running' | 'completed' | 'error';
|
||||
|
||||
export interface WorkflowNode {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
data: {
|
||||
prompt: string;
|
||||
result?: string;
|
||||
model?: string;
|
||||
status: NodeStatus;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
aspectRatio?: string;
|
||||
seed?: number;
|
||||
};
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface WorkflowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface AIWorkflowNode {
|
||||
type: NodeType;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
options?: {
|
||||
duration?: number;
|
||||
aspectRatio?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TikTokVideo {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
caption: string;
|
||||
playCount: number;
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
shareCount: number;
|
||||
collectCount: number;
|
||||
authorMeta: {
|
||||
name: string;
|
||||
nickName: string;
|
||||
avatar: string;
|
||||
};
|
||||
musicMeta: {
|
||||
musicName: string;
|
||||
musicAuthor: string;
|
||||
};
|
||||
datePosted: string;
|
||||
}
|
||||
|
||||
export interface TikTokProduct {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
price: number;
|
||||
originalPrice: number;
|
||||
discount: number;
|
||||
soldCount: number;
|
||||
rating: number;
|
||||
reviewCount: number;
|
||||
sellerName: string;
|
||||
shopUrl: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
opencodeApiKey: string;
|
||||
opencodeModel: string;
|
||||
api34aiKey: string;
|
||||
api34aiBaseUrl: string;
|
||||
imageModel: string;
|
||||
videoModel: string;
|
||||
defaultDuration: number;
|
||||
defaultAspectRatio: string;
|
||||
tiktokCookies: string;
|
||||
}
|
||||
9
next.config.js
Normal file
9
next.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
domains: ['34ai.net', 'p16-sig-default-us.akamaized.net', 'cdn3.dienthoaivui.com.vn', 'tiktok.com'],
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
31
package.json
Normal file
31
package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "ai-video-flow",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xct007/tiktok-scraper": "^1.0.2",
|
||||
"@xyflow/react": "^12.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.300.0",
|
||||
"next": "14.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
25
tailwind.config.ts
Normal file
25
tailwind.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#6366f1',
|
||||
dark: '#4f46e5',
|
||||
},
|
||||
secondary: '#8b5cf6',
|
||||
accent: '#22d3ee',
|
||||
surface: '#1e293b',
|
||||
background: '#0f172a',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Reference in a new issue