Initial commit: AI Video Flow app with TikTok trending, OpenCode and 34ai integration

This commit is contained in:
Khoa VD 2026-04-28 17:50:29 +07:00
commit 6adb18f2af
21 changed files with 2700 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
.next/
*.log
.env*.local
.DS_Store
!.gitignore

39
app/api/opencode/route.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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
View 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
View 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;
}
}

View 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
View 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
View 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
View 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
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

25
tailwind.config.ts Normal file
View 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
View 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"]
}