commit 6adb18f2af820fb242a1be97a1118264adf37b85 Author: Khoa VD Date: Tue Apr 28 17:50:29 2026 +0700 Initial commit: AI Video Flow app with TikTok trending, OpenCode and 34ai integration diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b45e2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +*.log +.env*.local +.DS_Store +!.gitignore \ No newline at end of file diff --git a/app/api/opencode/route.ts b/app/api/opencode/route.ts new file mode 100644 index 0000000..0265d59 --- /dev/null +++ b/app/api/opencode/route.ts @@ -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 }) + } +} \ No newline at end of file diff --git a/app/api/tiktok/route.ts b/app/api/tiktok/route.ts new file mode 100644 index 0000000..6340ab3 --- /dev/null +++ b/app/api/tiktok/route.ts @@ -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 = { + 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 }) + } +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..37ddf04 --- /dev/null +++ b/app/globals.css @@ -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; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..20a61ea --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + {children} + + + + ) +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..92ba782 --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+
Loading...
+
+ ) + } + + const checkConfiguration = () => { + if (settings.opencodeApiKey && settings.api34aiKey) { + setIsConfigured(true) + } + } + + return ( +
+
+
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..40d347c --- /dev/null +++ b/app/settings/page.tsx @@ -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(null) + const [api34aiError, setApi34aiError] = useState(null) + const [testingTikTok, setTestingTikTok] = useState(false) + const [tiktokStatus, setTiktokStatus] = useState<'untested' | 'success' | 'failed'>('untested') + const [tiktokError, setTiktokError] = useState(null) + + if (!isMounted) { + return ( +
+
Loading...
+
+ ) + } + + 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 + case 'failed': + return + default: + return
+ } + } + + return ( +
+
+
+
+
+

Settings

+

Configure your API keys and TikTok cookies

+
+ + {saved && ( +
+ + Settings saved successfully! +
+ )} + +
+

OpenCode Configuration

+ +
+
+ +
+ 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" + /> + +
+
+ +
+ + +
+ +
+ + {getStatusIcon(openCodeStatus)} + {openCodeStatus === 'success' && ( + Connected! + )} +
+ {openCodeError && ( +

{openCodeError}

+ )} +
+
+ +
+

34ai Configuration

+ +
+
+ +
+ 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" + /> + +
+
+ +
+ + 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" + /> +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + {getStatusIcon(status34ai)} + {status34ai === 'success' && ( + Connected! + )} +
+ {api34aiError && ( +

{api34aiError}

+ )} +
+
+ +
+

TikTok Cookies (Required for Trending)

+ +
+
+

How to get TikTok cookies:

+
    +
  1. Install "Cookie-Editor" extension on Chrome
  2. +
  3. Go to tiktok.com and login
  4. +
  5. Click Cookie-Editor → Export → Copy
  6. +
  7. Paste the cookies below
  8. +
+
+ +
+ +