main
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-05-11 07:32:47 +02:00
parent 4b1abf1996
commit fc5ceeebb6
11 changed files with 1226 additions and 91 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
# pnpm kurulumu (workspace kuralı gereği)
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
@@ -13,7 +13,7 @@ RUN pnpm install --frozen-lockfile
FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -1,6 +1,6 @@
"use client";
import { Wrench, Video, ArrowRight } from "lucide-react";
import { Wrench, Video, ArrowRight, Mic } from "lucide-react";
import Link from "next/link";
import { motion, useMotionTemplate, useMotionValue, useSpring } from "framer-motion";
import { useState, useRef } from "react";
@@ -164,6 +164,15 @@ export default function ToolsPage() {
colorClass="bg-blue-500/20 text-blue-400"
spotlightColor="rgba(59, 130, 246, 0.15)"
/>
<ToolCard
href="/dashboard/tools/voicebox"
title="VoiceBox Studio"
description="Açık kaynak, yerel ve limitsiz yapay zeka ses stüdyosu. Videolarınız için klonlanmış veya varsayılan ultra-gerçekçi sesler (TTS) üretin."
icon={Mic}
colorClass="bg-purple-500/20 text-purple-400"
spotlightColor="rgba(168, 85, 247, 0.15)"
/>
</div>
</div>
);
@@ -3,7 +3,7 @@
import React, { useState, useEffect, Component, ErrorInfo, ReactNode } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { getEpisodeById, analyzeEpisode, generateMoreQuestions, generateEpisodeQuestions, generateEpisodeSeoMarketing, generateEpisodeCrisisSponsors, generateThumbnail, EpisodeResponse } from '../../../services/strategistApi';
import { getEpisodeById, analyzeEpisode, generateMoreQuestions, generateEpisodeQuestions, generateEpisodeSeoMarketing, generateEpisodeCrisisSponsors, generateThumbnail, generateThumbnailMatrix, generateEpisodeShorts, generateEpisodeSponsorship, EpisodeResponse } from '../../../services/strategistApi';
import { Loader2, ArrowLeft, Sparkles, AlertTriangle, Layers, Target, FileText, Camera, ShieldAlert, Users, Film, Clock, Presentation, Type as TypeIcon, HelpCircle, BarChart, Zap, Download, Maximize2, RefreshCw, Star, ShieldCheck, Mail, Copy, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -41,11 +41,14 @@ export default function EpisodeWorkbenchPage() {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'titles' | 'questions' | 'seo' | 'gap' | 'segments' | 'friction' | 'visual' | 'guest' | 'crisis'>('titles');
const [activeTab, setActiveTab] = useState<'titles' | 'questions' | 'seo' | 'gap' | 'segments' | 'friction' | 'visual' | 'guest' | 'crisis' | 'thumbnailMatrix' | 'shorts' | 'sponsorship'>('titles');
const [isGeneratingQuestions, setIsGeneratingQuestions] = useState(false);
const [isGeneratingSeo, setIsGeneratingSeo] = useState(false);
const [isGeneratingCrisis, setIsGeneratingCrisis] = useState(false);
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false);
const [isGeneratingThumbnailMatrix, setIsGeneratingThumbnailMatrix] = useState(false);
const [isGeneratingShorts, setIsGeneratingShorts] = useState(false);
const [isGeneratingSponsorship, setIsGeneratingSponsorship] = useState(false);
const [isThumbnailExpanded, setIsThumbnailExpanded] = useState(false);
const [isCopied, setIsCopied] = useState(false);
@@ -143,6 +146,45 @@ export default function EpisodeWorkbenchPage() {
}
};
const handleGenerateThumbnailMatrix = async () => {
setIsGeneratingThumbnailMatrix(true);
setError(null);
try {
await generateThumbnailMatrix(episodeId);
await fetchEpisode();
} catch (err: any) {
setError(err?.response?.data?.message || "A/B test matrisi üretilirken hata oluştu.");
} finally {
setIsGeneratingThumbnailMatrix(false);
}
};
const handleGenerateShorts = async () => {
setIsGeneratingShorts(true);
setError(null);
try {
await generateEpisodeShorts(episodeId);
await fetchEpisode();
} catch (err: any) {
setError(err?.response?.data?.message || "Shorts/Reels fikirleri üretilirken hata oluştu.");
} finally {
setIsGeneratingShorts(false);
}
};
const handleGenerateSponsorship = async () => {
setIsGeneratingSponsorship(true);
setError(null);
try {
await generateEpisodeSponsorship(episodeId);
await fetchEpisode();
} catch (err: any) {
setError(err?.response?.data?.message || "Sponsorluk taslakları üretilirken hata oluştu.");
} finally {
setIsGeneratingSponsorship(false);
}
};
const handleDownloadThumbnail = () => {
if (episode?.masterAnalysis?.thumbnailUrl) {
const link = document.createElement('a');
@@ -163,6 +205,226 @@ export default function EpisodeWorkbenchPage() {
}
};
const handleDownloadHtml = () => {
if (!episode || !episode.masterAnalysis) return;
const analysis = episode.masterAnalysis;
const htmlContent = `
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tube Strategist Analiz: ${episode.topic}</title>
<style>
:root {
--bg: #09090b;
--bg-surface: #18181b;
--text-main: #f4f4f5;
--text-muted: #a1a1aa;
--primary: #f97316;
--border: #27272a;
}
body {
background-color: var(--bg);
color: var(--text-main);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
padding: 40px 20px;
max-width: 900px;
margin: 0 auto;
}
h1, h2, h3, h4 { color: var(--primary); }
.header { border-bottom: 2px solid var(--border); padding-bottom: 20px; margin-bottom: 40px; }
.section { background: var(--bg-surface); padding: 24px; border-radius: 12px; margin-bottom: 30px; border: 1px solid var(--border); }
.grid { display: grid; gap: 20px; }
.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; background: rgba(249,115,22,0.1); color: var(--primary); font-size: 12px; font-weight: bold; border: 1px solid rgba(249,115,22,0.2); }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { border: 1px solid var(--border); padding: 12px; text-align: left; }
th { color: var(--primary); }
.card { background: var(--bg); padding: 16px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 16px; }
pre { background: #000; padding: 16px; border-radius: 8px; overflow-x: auto; white-space: pre-wrap; font-size: 14px; color: var(--text-muted); }
img { max-width: 100%; border-radius: 12px; margin-top: 10px; }
.flex { display: flex; gap: 10px; flex-wrap: wrap; }
.text-sm { font-size: 14px; }
.text-muted { color: var(--text-muted); }
</style>
</head>
<body>
<div class="header">
<div class="badge">Pre-Production</div>
<h1>${episode.topic === 'AI_AUTO' ? 'Yapay Zeka Tarafından Belirlenecek' : episode.topic}</h1>
<p><strong>Hedef Kitle:</strong> ${episode.targetAudience} &bull; <strong>Süre:</strong> ${episode.duration} &bull; <strong>Format:</strong> ${episode.format}</p>
</div>
${analysis.titleSuggestions ? `
<div class="section">
<h2>🎯 Başlık Önerileri</h2>
<div class="grid">
${analysis.titleSuggestions.map((title: any) => `
<div class="card">
<div class="flex" style="justify-content: space-between; align-items: start;">
<h3 style="margin:0 0 10px 0; color:var(--text-main);">${title.title}</h3>
${title.seoScore ? `<span class="badge">SEO Skoru: ${title.seoScore}</span>` : ''}
</div>
<p class="text-sm text-muted">${title.description}</p>
<p class="text-sm"><strong>Neden:</strong> ${title.reasoning}</p>
</div>
`).join('')}
</div>
</div>` : ''}
${analysis.seoAnalysis ? `
<div class="section">
<h2>🔍 SEO & Boşluk Analizi</h2>
${analysis.seoAnalysis.targetKeywords ? `
<h4>Hedef Anahtar Kelimeler</h4>
<div class="flex" style="margin-bottom: 20px;">
${analysis.seoAnalysis.targetKeywords.map((kw: string) => `<span class="badge">${kw}</span>`).join('')}
</div>
` : ''}
<div class="grid" style="grid-template-columns: 1fr 1fr;">
<div class="card">
<h4 style="margin-top:0;">SEO Başlığı</h4>
<p class="text-sm">${analysis.seoAnalysis.seoTitle || '-'}</p>
</div>
<div class="card">
<h4 style="margin-top:0;">SEO Açıklaması</h4>
<p class="text-sm">${analysis.seoAnalysis.seoDescription || '-'}</p>
</div>
</div>
</div>` : ''}
${analysis.audienceGap ? `
<div class="section">
<h2>🧩 Kitle & Sürtünme Analizi</h2>
<div class="card">
<h4 style="margin-top:0;">Rakip İçeriklerdeki Boşluk (Audience Gap)</h4>
<p class="text-sm">${analysis.audienceGap}</p>
</div>
${analysis.frictionPoints && analysis.frictionPoints.length > 0 ? `
<h4>İzleyiciyi Sıkabilecek Noktalar (Friction Points)</h4>
<ul>
${analysis.frictionPoints.map((fp: any) => `
<li><strong>Risk:</strong> <span class="text-muted">${fp.risk}</span><br/><strong>Çözüm:</strong> ${fp.solution}</li>
`).join('')}
</ul>
` : ''}
</div>` : ''}
${analysis.contentSegments && analysis.contentSegments.length > 0 ? `
<div class="section">
<h2>⏱️ İçerik Akışı (Segmentler)</h2>
${analysis.contentSegments.map((seg: any) => `
<div class="card">
<div class="flex" style="justify-content: space-between;">
<h4 style="margin:0;">${seg.title}</h4>
<span class="badge">${seg.timestamp}</span>
</div>
<p class="text-sm text-muted">${seg.description}</p>
<p class="text-sm"><strong>Hedef:</strong> ${seg.purpose}</p>
</div>
`).join('')}
</div>` : ''}
${analysis.visualAudioIdeas && analysis.visualAudioIdeas.length > 0 ? `
<div class="section">
<h2>🎬 Görsel & İşitsel Fikirler</h2>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));">
${analysis.visualAudioIdeas.map((idea: any) => `
<div class="card">
<p class="text-sm"><strong>Tür:</strong> <span style="text-transform: capitalize;">${idea.type}</span></p>
<p class="text-sm text-muted">${idea.description}</p>
<p class="text-sm"><strong>Amaç:</strong> ${idea.purpose}</p>
</div>
`).join('')}
</div>
</div>` : ''}
${analysis.guestRecommendations && analysis.guestRecommendations.length > 0 ? `
<div class="section">
<h2>👥 Konuk Önerileri</h2>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));">
${analysis.guestRecommendations.map((guest: any) => `
<div class="card">
<h4 style="margin:0 0 10px 0;">${guest.guestName}</h4>
<p class="text-sm text-muted">${guest.reasoning}</p>
</div>
`).join('')}
</div>
</div>` : ''}
${analysis.interviewQuestions && analysis.interviewQuestions.length > 0 ? `
<div class="section">
<h2>❓ Mülakat Soruları ve Nöro-Pazarlama İpuçları</h2>
${analysis.interviewQuestions.map((q: any, i: number) => `
<div class="card">
<h3 style="margin-top:0; color: var(--text-main);">Soru ${i + 1}: ${q.question}</h3>
${q.targetArea || q.neuroMarketingScore ? `
<div class="flex" style="margin-bottom: 10px;">
${q.targetArea ? `<span class="badge">${q.targetArea}</span>` : ''}
${q.neuroMarketingScore ? `<span class="badge">Nöro Skor: ${q.neuroMarketingScore}</span>` : ''}
</div>
` : ''}
${q.neuroMarketingAnswerDirection ? `
<p class="text-sm" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border);">
<strong>Cevaplama Stratejisi:</strong> <span class="text-muted">${q.neuroMarketingAnswerDirection}</span>
</p>
` : ''}
</div>
`).join('')}
</div>` : ''}
${analysis.crisisManagement || (analysis.sponsors && analysis.sponsors.length > 0) ? `
<div class="section">
<h2>🛡️ Kriz Yönetimi & Sponsorlar</h2>
${analysis.crisisManagement ? `
<div class="card">
<h4>Linç / Tepki İhtimali</h4>
<p class="text-sm text-muted">${analysis.crisisManagement.potentialBacklash}</p>
<h4>PR Stratejisi</h4>
<p class="text-sm text-muted">${analysis.crisisManagement.prStrategy}</p>
</div>
` : ''}
${analysis.sponsors && analysis.sponsors.length > 0 ? `
<h3 style="margin-top: 30px;">Potansiyel Sponsor Markalar</h3>
${analysis.sponsors.map((sponsor: any) => `
<div class="card" style="margin-bottom: 20px;">
<h4 style="margin-top:0;">${sponsor.brandName}</h4>
${sponsor.reasoning ? `<p class="text-sm"><strong>Seçim Nedeni:</strong> ${sponsor.reasoning}</p>` : ''}
${sponsor.integrationIdea ? `<p class="text-sm"><strong>Entegrasyon Fikri:</strong> ${sponsor.integrationIdea}</p>` : ''}
<div style="margin-top: 15px;">
<p class="text-sm"><strong>E-Posta Taslağı:</strong></p>
<pre>${sponsor.emailDraft}</pre>
</div>
</div>
`).join('')}
` : ''}
</div>` : ''}
${analysis.thumbnailConcept || analysis.thumbnailUrl ? `
<div class="section">
<h2>🖼️ Thumbnail Konsepti</h2>
${analysis.thumbnailConcept ? `<p class="text-sm text-muted">${analysis.thumbnailConcept}</p>` : ''}
${analysis.thumbnailUrl ? `<img src="${analysis.thumbnailUrl.startsWith('/') ? 'http://localhost:3000' + analysis.thumbnailUrl : analysis.thumbnailUrl}" alt="Thumbnail"/>` : ''}
</div>` : ''}
</body>
</html>`;
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const safeTopic = (episode.topic || 'bolum').replace(/[^a-z0-9ğüşıöç]/gi, '-').toLowerCase();
link.download = `tube-strategist-${safeTopic}-analiz.html`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
@@ -219,6 +481,16 @@ export default function EpisodeWorkbenchPage() {
</div>
</div>
</div>
<div className="flex items-center gap-3">
{analysis && (
<button
onClick={handleDownloadHtml}
className="px-4 py-3 bg-[var(--color-bg-surface)] hover:bg-[var(--color-bg-hover)] border border-[var(--color-border-default)] text-[var(--color-text-primary)] font-bold text-sm rounded-xl flex items-center gap-2 transition-all"
>
<Download size={18} className="text-orange-500" />
<span className="hidden sm:inline">HTML İndir</span>
</button>
)}
{episode.status !== 'COMPLETED' ? (
<button
@@ -240,6 +512,7 @@ export default function EpisodeWorkbenchPage() {
</button>
)}
</div>
</div>
<AnimatePresence>
{error && (
@@ -341,6 +614,39 @@ export default function EpisodeWorkbenchPage() {
>
<Zap size={18} /> Kriz & Sponsor
</button>
<div className="h-px bg-[var(--color-border-faint)] my-2"></div>
<div className="text-[10px] font-bold text-[var(--color-text-ghost)] uppercase tracking-wider px-4 py-2">İleri Seviye (V16)</div>
<button
onClick={() => setActiveTab('thumbnailMatrix')}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all",
activeTab === 'thumbnailMatrix' ? "bg-purple-500/10 text-purple-500 border border-purple-500/20" : "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] border border-transparent"
)}
>
<Camera size={18} /> A/B Test Matrisi
</button>
<button
onClick={() => setActiveTab('shorts')}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all",
activeTab === 'shorts' ? "bg-purple-500/10 text-purple-500 border border-purple-500/20" : "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] border border-transparent"
)}
>
<Film size={18} /> Shorts Çarpanı
</button>
<button
onClick={() => setActiveTab('sponsorship')}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all",
activeTab === 'sponsorship' ? "bg-purple-500/10 text-purple-500 border border-purple-500/20" : "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] border border-transparent"
)}
>
<Mail size={18} /> Sponsorluk Pitch
</button>
</div>
{/* Content Area */}
@@ -467,8 +773,13 @@ export default function EpisodeWorkbenchPage() {
<div key={idx} className="p-6 bg-[var(--color-bg-surface)] border border-[var(--color-border-default)] rounded-2xl relative overflow-hidden group hover:border-orange-500/50 transition-colors">
<div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-orange-500 to-red-500 opacity-50 group-hover:opacity-100 transition-opacity"></div>
<div className="flex gap-4">
<div className="w-10 h-10 rounded-full bg-[var(--color-bg-base)] border border-[var(--color-border-strong)] flex items-center justify-center font-bold text-orange-500 flex-shrink-0 shadow-sm mt-1">
{idx + 1}
</div>
<div className="space-y-3 flex-1">
{/* Tags */}
<div className="flex gap-2 justify-end mb-4 absolute top-4 right-4">
<div className="flex flex-wrap gap-2 mb-2">
{targetArea && (
<span className="px-2 py-1 bg-blue-500/10 border border-blue-500/20 text-blue-500 text-[10px] font-bold rounded-md uppercase tracking-wider flex items-center gap-1">
<Target size={10} /> {targetArea}
@@ -480,12 +791,6 @@ export default function EpisodeWorkbenchPage() {
</span>
)}
</div>
<div className="flex gap-4 pr-32">
<div className="w-10 h-10 rounded-full bg-[var(--color-bg-base)] border border-[var(--color-border-strong)] flex items-center justify-center font-bold text-orange-500 flex-shrink-0 shadow-sm">
{idx + 1}
</div>
<div className="space-y-3 flex-1">
<h3 className="text-lg font-bold text-[var(--color-text-primary)] leading-snug">{questionText}</h3>
{neuroDirection && (
<div className="p-4 bg-orange-500/5 rounded-xl border border-orange-500/10 mt-3">
@@ -720,10 +1025,23 @@ export default function EpisodeWorkbenchPage() {
</div>
</div>
<div className="flex-1 flex flex-col">
<h5 className="text-xs font-bold uppercase tracking-wider text-[var(--color-text-secondary)] mb-2 flex items-center gap-2">
<div className="flex items-center justify-between mb-2">
<h5 className="text-xs font-bold uppercase tracking-wider text-[var(--color-text-secondary)] flex items-center gap-2">
<Mail size={14} /> Türkçe E-Posta Taslağı
</h5>
<div className="flex-1 p-4 bg-[var(--color-bg-base)] rounded-xl border border-[var(--color-border-strong)] text-sm text-[var(--color-text-secondary)] whitespace-pre-wrap font-mono text-xs overflow-auto max-h-48">
<button
onClick={() => {
navigator.clipboard.writeText(sponsor.emailDraft);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
}}
className="text-[var(--color-text-secondary)] hover:text-orange-500 transition-colors p-1"
title="Kopyala"
>
{isCopied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
</div>
<div className="flex-1 p-4 bg-[var(--color-bg-base)] rounded-xl border border-[var(--color-border-strong)] text-sm text-[var(--color-text-secondary)] whitespace-pre-wrap font-mono text-xs overflow-auto max-h-72 relative group">
{sponsor.emailDraft}
</div>
</div>
@@ -742,6 +1060,178 @@ export default function EpisodeWorkbenchPage() {
</motion.div>
)}
{activeTab === 'thumbnailMatrix' && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] flex items-center gap-3">
<Camera className="text-purple-500" /> Thumbnail & Title Matrisi
</h2>
<button
onClick={handleGenerateThumbnailMatrix}
disabled={isGeneratingThumbnailMatrix}
className="px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white font-bold text-sm rounded-xl flex items-center gap-2 transition-colors disabled:opacity-50"
>
{isGeneratingThumbnailMatrix ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
{episode.thumbnailMatrix ? "Yeniden Üret" : "A/B Matrisi Üret"}
</button>
</div>
{episode.thumbnailMatrix?.concepts && episode.thumbnailMatrix.concepts.length > 0 ? (
<div className="grid md:grid-cols-2 gap-6">
{episode.thumbnailMatrix.concepts.map((concept: any, idx: number) => (
<div key={idx} className="p-6 bg-[var(--color-bg-surface)] border border-[var(--color-border-default)] rounded-2xl flex flex-col gap-4 group hover:border-purple-500/50 transition-colors">
<div className="flex items-start justify-between">
<h3 className="font-bold text-[var(--color-text-primary)]">{concept.conceptName}</h3>
<span className="px-2 py-1 bg-[var(--color-bg-base)] border border-[var(--color-border-strong)] rounded text-xs font-bold text-[var(--color-text-secondary)]">Option {idx + 1}</span>
</div>
<div>
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider block mb-1">Görsel Açıklaması</span>
<p className="text-sm text-[var(--color-text-primary)]">{concept.visualDescription}</p>
</div>
<div>
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider block mb-1">Başlık (Title)</span>
<p className="text-sm font-bold text-purple-500 bg-purple-500/10 p-2 rounded-lg">{concept.title}</p>
</div>
<div>
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider block mb-1">Clickbait Seviyesi</span>
<div className="flex items-center gap-2">
<div className="w-full h-2 bg-[var(--color-bg-base)] rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full", concept.clickbaitLevel > 7 ? "bg-red-500" : concept.clickbaitLevel > 4 ? "bg-amber-500" : "bg-green-500")}
style={{ width: `${concept.clickbaitLevel * 10}%` }}
/>
</div>
<span className="text-xs font-bold text-[var(--color-text-primary)]">{concept.clickbaitLevel}/10</span>
</div>
</div>
<div>
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider block mb-1">Görselde Metin</span>
<p className="text-sm font-medium text-[var(--color-text-primary)]">{concept.textOnImage || "Yok"}</p>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12 bg-[var(--color-bg-surface)] border border-dashed border-[var(--color-border-default)] rounded-xl">
<p className="text-sm text-[var(--color-text-secondary)]">A/B test matrisi henüz üretilmedi.</p>
</div>
)}
</motion.div>
)}
{activeTab === 'shorts' && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] flex items-center gap-3">
<Film className="text-purple-500" /> Shorts / Reels Çarpanı
</h2>
<button
onClick={handleGenerateShorts}
disabled={isGeneratingShorts}
className="px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white font-bold text-sm rounded-xl flex items-center gap-2 transition-colors disabled:opacity-50"
>
{isGeneratingShorts ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
{episode.shortsConcepts ? "Yeniden Üret" : "Kısa Format Fikirleri Üret"}
</button>
</div>
{episode.shortsConcepts?.shorts && episode.shortsConcepts.shorts.length > 0 ? (
<div className="grid gap-6">
{episode.shortsConcepts.shorts.map((short: any, idx: number) => (
<div key={idx} className="p-6 bg-[var(--color-bg-surface)] border border-[var(--color-border-default)] rounded-2xl group hover:border-purple-500/50 transition-colors">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg text-[var(--color-text-primary)]">{short.topic}</h3>
<span className="px-3 py-1 bg-purple-500/10 text-purple-500 border border-purple-500/20 rounded-full text-xs font-bold">{short.hookIdea}</span>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="bg-[var(--color-bg-base)] p-4 rounded-xl border border-[var(--color-border-strong)]">
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider block mb-2">Hedef Platform</span>
<div className="flex gap-2">
{short.targetPlatform.map((p: string) => (
<span key={p} className="px-2 py-1 bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] rounded text-xs font-medium text-[var(--color-text-primary)]">{p}</span>
))}
</div>
</div>
<div className="bg-[var(--color-bg-base)] p-4 rounded-xl border border-[var(--color-border-strong)]">
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider block mb-2">Tahmini Uzunluk</span>
<p className="text-sm font-medium text-[var(--color-text-primary)]">{short.estimatedLength}</p>
</div>
</div>
<div className="bg-[var(--color-bg-base)] p-4 rounded-xl border border-[var(--color-border-strong)]">
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider block mb-2">Script Taslağı (Senaryo)</span>
<pre className="text-sm text-[var(--color-text-primary)] font-mono whitespace-pre-wrap">{short.scriptDraft}</pre>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12 bg-[var(--color-bg-surface)] border border-dashed border-[var(--color-border-default)] rounded-xl">
<p className="text-sm text-[var(--color-text-secondary)]">Shorts fikirleri henüz üretilmedi.</p>
</div>
)}
</motion.div>
)}
{activeTab === 'sponsorship' && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] flex items-center gap-3">
<Mail className="text-purple-500" /> Sponsorluk (Pitch Deck)
</h2>
<button
onClick={handleGenerateSponsorship}
disabled={isGeneratingSponsorship}
className="px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white font-bold text-sm rounded-xl flex items-center gap-2 transition-colors disabled:opacity-50"
>
{isGeneratingSponsorship ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
{episode.sponsorshipPitch ? "Yeniden Üret" : "Sponsor Fikirleri Üret"}
</button>
</div>
{episode.sponsorshipPitch?.pitches && episode.sponsorshipPitch.pitches.length > 0 ? (
<div className="grid gap-6">
{episode.sponsorshipPitch.pitches.map((pitch: any, idx: number) => (
<div key={idx} className="p-6 bg-[var(--color-bg-surface)] border border-[var(--color-border-default)] rounded-2xl group hover:border-purple-500/50 transition-colors flex flex-col md:flex-row gap-6">
<div className="flex-1 space-y-4">
<div>
<h3 className="font-bold text-xl text-[var(--color-text-primary)] mb-1">{pitch.industry}</h3>
<p className="text-sm text-[var(--color-text-secondary)]">Önerilen Marka(lar): <span className="font-medium text-purple-500">{pitch.exampleBrands.join(", ")}</span></p>
</div>
<div className="p-4 bg-[var(--color-bg-base)] border border-[var(--color-border-strong)] rounded-xl">
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider block mb-2">Bölümle Uyumu (Neden Mantıklı?)</span>
<p className="text-sm text-[var(--color-text-primary)]">{pitch.reasoning}</p>
</div>
<div className="p-4 bg-[var(--color-bg-base)] border border-[var(--color-border-strong)] rounded-xl">
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider block mb-2">Entegrasyon Formatı</span>
<p className="text-sm text-[var(--color-text-primary)]">{pitch.integrationFormat}</p>
</div>
</div>
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider">Örnek E-Posta Taslağı</span>
<button
onClick={() => {
navigator.clipboard.writeText(pitch.emailDraft);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
}}
className="text-[var(--color-text-secondary)] hover:text-purple-500 transition-colors"
>
{isCopied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
</div>
<pre className="flex-1 p-4 bg-[var(--color-bg-base)] border border-[var(--color-border-strong)] rounded-xl text-xs font-mono text-[var(--color-text-secondary)] whitespace-pre-wrap overflow-y-auto max-h-72">{pitch.emailDraft}</pre>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12 bg-[var(--color-bg-surface)] border border-dashed border-[var(--color-border-default)] rounded-xl">
<p className="text-sm text-[var(--color-text-secondary)]">Sponsorluk pitch deck henüz üretilmedi.</p>
</div>
)}
</motion.div>
)}
</div>
</div>
) : (
@@ -8,7 +8,8 @@ import {
} from 'lucide-react';
import {
getProjectById, ProjectResponse, addVideoToProject, addDocumentToProject,
updateProject, createEpisode, getTopicSuggestions, TopicSuggestion, EpisodeResponse
updateProject, createEpisode, getTopicSuggestions, TopicSuggestion, EpisodeResponse,
generateCommunityIdeas
} from '../services/strategistApi';
class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasError: boolean, error: Error | null}> {
@@ -55,6 +56,7 @@ export default function StrategistHubPage() {
// Settings Modal State
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isFormatExpanded, setIsFormatExpanded] = useState(false);
const [settingsForm, setSettingsForm] = useState({
name: "", tone: "", targetDuration: "", speakerName: "", targetAudience: "", formatDescription: ""
});
@@ -68,6 +70,7 @@ export default function StrategistHubPage() {
const [isAiTopic, setIsAiTopic] = useState(false);
const [suggestions, setSuggestions] = useState<TopicSuggestion[]>([]);
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
const [isGeneratingIdeas, setIsGeneratingIdeas] = useState(false);
const [isCreatingEpisode, setIsCreatingEpisode] = useState(false);
const [episodeError, setEpisodeError] = useState("");
@@ -163,6 +166,19 @@ export default function StrategistHubPage() {
}
};
const handleGenerateCommunityIdeas = async () => {
setIsGeneratingIdeas(true);
try {
await generateCommunityIdeas(projectId);
await fetchProject();
} catch (err) {
console.error("Failed to generate community ideas", err);
alert("Topluluk analizi yapılırken bir hata oluştu.");
} finally {
setIsGeneratingIdeas(false);
}
};
const handleCreateEpisode = async (e: React.FormEvent) => {
e.preventDefault();
const finalTopic = isAiTopic ? "AI_AUTO" : episodeForm.topic;
@@ -256,9 +272,26 @@ export default function StrategistHubPage() {
<h3 className="text-lg font-bold text-[var(--color-text-primary)] flex items-center gap-2">
<Target className="text-blue-500" size={20} /> Format & Konsept
</h3>
<p className="text-sm text-[var(--color-text-secondary)] leading-relaxed whitespace-pre-wrap">
{project.formatDescription || "Format açıklaması girilmemiş. Ayarlardan ekleyebilirsiniz."}
</p>
<div className="text-sm text-[var(--color-text-secondary)] leading-relaxed whitespace-pre-wrap">
{project.formatDescription ? (
<>
{isFormatExpanded || project.formatDescription.length <= 500
? project.formatDescription
: `${project.formatDescription.slice(0, 500)}...`}
{project.formatDescription.length > 500 && (
<button
onClick={() => setIsFormatExpanded(!isFormatExpanded)}
className="text-blue-500 hover:text-blue-400 font-medium ml-2 underline underline-offset-2"
>
{isFormatExpanded ? 'Daha az göster' : 'Devamını gör'}
</button>
)}
</>
) : (
"Format açıklaması girilmemiş. Ayarlardan ekleyebilirsiniz."
)}
</div>
<div className="flex flex-wrap items-center gap-4 pt-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-surface)] px-3 py-1.5 rounded-lg border border-[var(--color-border-default)]">
@@ -498,14 +531,71 @@ export default function StrategistHubPage() {
</div>
)}
</div>
{/* Community Demand Radar Section */}
<div className="card p-6 border border-purple-500/20 shadow-[0_8px_30px_rgb(168,85,247,0.1)] mt-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-500/10 flex items-center justify-center text-purple-500">
<Sparkles size={20} />
</div>
<div>
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">Gelecek Bölüm Radarı</h2>
<p className="text-sm text-[var(--color-text-secondary)]">İzleyici talepleri ve analizleri</p>
</div>
</div>
<button
onClick={handleGenerateCommunityIdeas}
disabled={isGeneratingIdeas || (youtubeVideos.length === 0 && manualDocs.length === 0)}
className="px-4 py-2 bg-purple-500 hover:bg-purple-600 disabled:opacity-50 text-white font-bold text-sm rounded-xl flex items-center gap-2 transition-colors"
>
{isGeneratingIdeas ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles size={16} />}
{project.communityInsights ? "Yeniden Analiz Et" : "Kitleyi Analiz Et"}
</button>
</div>
{project.communityInsights?.insights && project.communityInsights.insights.length > 0 ? (
<div className="grid grid-cols-1 gap-4">
{project.communityInsights.insights.map((idea: any, idx: number) => (
<div key={idx} className="bg-[var(--color-bg-elevated)] p-4 rounded-xl border border-[var(--color-border-faint)] hover:border-purple-500/30 transition-colors">
<div className="flex items-start justify-between mb-2">
<h3 className="font-bold text-[var(--color-text-primary)] text-sm">{idea.topic}</h3>
<div className="flex flex-col items-end">
<span className="text-[10px] font-bold text-[var(--color-text-secondary)] uppercase tracking-wider mb-1">Virallik Skoru</span>
<div className="flex items-center gap-2">
<div className="w-16 h-2 bg-[var(--color-bg-default)] rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-purple-500"
style={{ width: `${idea.viralityScore}%` }}
/>
</div>
<span className="text-xs font-bold text-[var(--color-text-primary)]">{idea.viralityScore}</span>
</div>
</div>
</div>
<p className="text-xs text-[var(--color-text-secondary)] mb-3 line-clamp-2">{idea.demandReason}</p>
<div className="bg-[var(--color-bg-default)] p-2 rounded-lg border border-[var(--color-border-faint)]">
<span className="text-[10px] text-purple-500 font-bold block mb-1">Önerilen Başlık</span>
<p className="text-xs text-[var(--color-text-primary)] font-medium">"{idea.proposedTitle}"</p>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<p className="text-[var(--color-text-secondary)] text-sm">Henüz bir kitle analizi bulunmuyor. Referans ekledikten sonra analizi başlatabilirsiniz.</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Settings Modal */}
{isSettingsOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-[var(--color-bg-elevated)] w-full max-w-2xl rounded-2xl border border-[var(--color-border-faint)] shadow-2xl flex flex-col max-h-[90vh]">
<div className="fixed inset-0 z-50 overflow-y-auto bg-black/60 backdrop-blur-sm">
<div className="flex min-h-full items-center justify-center p-4 py-10">
<div className="bg-[var(--color-bg-elevated)] w-full max-w-2xl rounded-2xl border border-[var(--color-border-faint)] shadow-2xl flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-[var(--color-border-faint)]">
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">Proje Ayarları</h2>
<button onClick={() => setIsSettingsOpen(false)} className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
@@ -513,7 +603,7 @@ export default function StrategistHubPage() {
</button>
</div>
<form onSubmit={handleUpdateSettings} className="p-6 overflow-y-auto space-y-5">
<form onSubmit={handleUpdateSettings} className="p-6 space-y-5">
<div>
<label className="block text-xs font-bold text-[var(--color-text-secondary)] uppercase tracking-wider mb-2">Proje Adı</label>
<input
@@ -582,12 +672,14 @@ export default function StrategistHubPage() {
</form>
</div>
</div>
</div>
)}
{/* New Episode Modal */}
{isEpisodeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-[var(--color-bg-elevated)] w-full max-w-3xl rounded-2xl border border-[var(--color-border-faint)] shadow-2xl flex flex-col max-h-[90vh]">
<div className="fixed inset-0 z-50 overflow-y-auto bg-black/60 backdrop-blur-sm">
<div className="flex min-h-full items-center justify-center p-4 py-10">
<div className="bg-[var(--color-bg-elevated)] w-full max-w-3xl rounded-2xl border border-[var(--color-border-faint)] shadow-2xl flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-[var(--color-border-faint)]">
<div>
<h2 className="text-xl font-bold text-[var(--color-text-primary)] flex items-center gap-2">
@@ -602,7 +694,7 @@ export default function StrategistHubPage() {
</button>
</div>
<form onSubmit={handleCreateEpisode} className="p-6 overflow-y-auto space-y-6">
<form onSubmit={handleCreateEpisode} className="p-6 space-y-6">
{/* Topic Section */}
<div className="p-5 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-base)] space-y-4">
@@ -718,6 +810,7 @@ export default function StrategistHubPage() {
</form>
</div>
</div>
</div>
)}
</ErrorBoundary>
@@ -2,10 +2,10 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { getProjects, createProject, ProjectResponse } from './services/strategistApi';
import { getProjects, createProject, deleteProject, ProjectResponse } from './services/strategistApi';
import { useRouter, useParams } from 'next/navigation';
import { LegacyUploader } from './components/LegacyUploader';
import { MonitorPlay, Plus, FolderKanban, Loader2, Calendar, LayoutTemplate, X, TrendingUp, Sparkles, User, Target, Users, PlayCircle, History } from 'lucide-react';
import { MonitorPlay, Plus, FolderKanban, Loader2, Calendar, LayoutTemplate, X, TrendingUp, Sparkles, User, Target, Users, PlayCircle, History, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@@ -66,6 +66,21 @@ export default function TubeStrategistDashboard() {
}
};
const handleDeleteProject = async (e: React.MouseEvent, projectId: string) => {
e.stopPropagation();
if (!window.confirm("Bu projeyi silmek istediğinize emin misiniz? Bu işlem geri alınamaz ve proje içindeki tüm analiz, video ve bölümler silinecektir.")) {
return;
}
try {
await deleteProject(projectId);
setProjects(projects.filter(p => p.id !== projectId));
} catch (error) {
console.error("Proje silinirken hata:", error);
alert("Proje silinirken bir hata oluştu.");
}
};
return (
<div className="max-w-6xl mx-auto space-y-8 pb-24 font-sans px-4 sm:px-0">
{/* Header */}
@@ -166,7 +181,8 @@ export default function TubeStrategistDashboard() {
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-red-500/5 to-orange-500/5 rounded-bl-full -z-10 group-hover:scale-110 transition-transform"></div>
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-bold text-[var(--color-text-primary)] line-clamp-1 group-hover:text-red-500 transition-colors">{proj.name}</h3>
<h3 className="text-lg font-bold text-[var(--color-text-primary)] line-clamp-1 group-hover:text-red-500 transition-colors pr-2">{proj.name}</h3>
<div className="flex items-center gap-2">
<div className={cn(
"px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider",
proj.status === 'COMPLETED' ? "bg-emerald-500/10 text-emerald-500 border border-emerald-500/20" :
@@ -176,6 +192,14 @@ export default function TubeStrategistDashboard() {
{proj.status === 'ANALYZING' && <Loader2 className="w-3 h-3 animate-spin" />}
{proj.status === 'COMPLETED' ? 'Tamamlandı' : proj.status === 'ANALYZING' ? 'Analiz Ediliyor' : 'Bekliyor'}
</div>
<button
onClick={(e) => handleDeleteProject(e, proj.id)}
className="p-1.5 rounded-md text-[var(--color-text-ghost)] hover:text-red-500 hover:bg-red-500/10 transition-colors opacity-0 group-hover:opacity-100"
title="Projeyi Sil"
>
<Trash2 size={14} />
</button>
</div>
</div>
<div className="space-y-3 mb-6 flex-1">
@@ -28,6 +28,9 @@ export interface EpisodeResponse {
format: string;
status: string;
masterAnalysis: any;
thumbnailMatrix?: any;
shortsConcepts?: any;
sponsorshipPitch?: any;
createdAt: string;
updatedAt: string;
}
@@ -52,6 +55,7 @@ export interface ProjectResponse {
episodes?: EpisodeResponse[];
_count?: { videos: number };
masterAnalysis: any;
communityInsights?: any;
createdAt: string;
updatedAt: string;
}
@@ -91,6 +95,10 @@ export const getProjectById = async (projectId: string): Promise<ProjectResponse
return apiClient.get<ProjectResponse>(`/youtube-tools/strategist/projects/${projectId}`).then(r => r.data as unknown as ProjectResponse);
};
export const deleteProject = async (projectId: string): Promise<any> => {
return apiClient.delete<any>(`/youtube-tools/strategist/projects/${projectId}`).then(r => r.data);
};
export const addVideoToProject = async (projectId: string, url: string): Promise<any> => {
return apiClient.post<any>(`/youtube-tools/strategist/projects/${projectId}/video`, { youtubeUrl: url }).then(r => r.data);
};
@@ -136,6 +144,22 @@ export const generateMoreQuestions = async (episodeId: string): Promise<any> =>
return apiClient.post<any>(`/youtube-tools/strategist/episodes/${episodeId}/generate-more-questions`).then(r => r.data);
};
export const generateCommunityIdeas = async (projectId: string): Promise<any> => {
return apiClient.post<any>(`/youtube-tools/strategist/projects/${projectId}/community-ideas`).then(r => r.data);
};
export const generateThumbnailMatrix = async (episodeId: string): Promise<any> => {
return apiClient.post<any>(`/youtube-tools/strategist/episodes/${episodeId}/thumbnail-matrix`).then(r => r.data);
};
export const generateEpisodeShorts = async (episodeId: string): Promise<any> => {
return apiClient.post<any>(`/youtube-tools/strategist/episodes/${episodeId}/shorts-concepts`).then(r => r.data);
};
export const generateEpisodeSponsorship = async (episodeId: string): Promise<any> => {
return apiClient.post<any>(`/youtube-tools/strategist/episodes/${episodeId}/sponsorship`).then(r => r.data);
};
export const generateEpisodeQuestions = async (episodeId: string): Promise<any> => {
return apiClient.post<any>(`/youtube-tools/strategist/episodes/${episodeId}/generate-questions`).then(r => r.data);
};
@@ -0,0 +1,403 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { voiceboxApi } from '@/services/voiceboxApi';
import { Play, Download, Mic, Settings2, Loader2, Sparkles, Volume2, AlertTriangle, History, Clock, ChevronDown, ChevronUp } from 'lucide-react';
export default function VoiceBoxStudio() {
const [text, setText] = useState('');
const [profiles, setProfiles] = useState<any[]>([]);
const [selectedProfile, setSelectedProfile] = useState('');
const [historyItems, setHistoryItems] = useState<any[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
// Advanced settings
const [engine, setEngine] = useState('kokoro');
const [language, setLanguage] = useState('tr');
const [modelSize, setModelSize] = useState('1.7B');
const [instruct, setInstruct] = useState('');
const [seed, setSeed] = useState<number | ''>('');
const [showAdvanced, setShowAdvanced] = useState(false);
// Derive if current profile is a preset
const currentProfile = profiles.find(p => p.id === selectedProfile);
const isPresetProfile = currentProfile?.voice_type === 'preset';
useEffect(() => {
const fetchInitialData = async () => {
try {
const profileData = await voiceboxApi.getProfiles();
const fetchedProfiles = Array.isArray(profileData) ? profileData : (profileData?.profiles || []);
if (fetchedProfiles && fetchedProfiles.length > 0) {
setProfiles(fetchedProfiles);
setSelectedProfile(fetchedProfiles[0].id);
}
} catch (error) {
console.error('Failed to load profiles', error);
}
try {
const historyData = await voiceboxApi.getHistory();
setHistoryItems(historyData?.items || []);
} catch (error) {
console.error('Failed to load history', error);
}
};
fetchInitialData();
}, []);
// Sync engine when profile changes
useEffect(() => {
if (currentProfile) {
if (currentProfile.voice_type === 'preset' && currentProfile.preset_engine) {
setEngine(currentProfile.preset_engine);
} else if (currentProfile.default_engine) {
setEngine(currentProfile.default_engine);
} else {
setEngine('qwen'); // default for cloned voices if not specified
}
}
}, [selectedProfile, currentProfile]);
const handleGenerate = async () => {
if (!text.trim()) {
alert('Lütfen dönüştürülecek metni girin.');
return;
}
setIsGenerating(true);
setAudioUrl(null);
try {
const options = {
language,
engine,
modelSize: engine === 'qwen' ? modelSize : undefined,
instruct: instruct.trim() || undefined,
seed: seed !== '' ? Number(seed) : undefined,
};
const audioBlob = await voiceboxApi.generateSpeech(text, selectedProfile, options);
const blob = new Blob([audioBlob], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
// Refresh history after generation
const historyData = await voiceboxApi.getHistory();
setHistoryItems(historyData?.items || []);
} catch (error: any) {
alert(`Ses üretilirken bir hata oluştu: ${error.message || 'Bilinmeyen hata'}\n\nAyarları (özellikle ağır modelleri) kontrol edin.`);
} finally {
setIsGenerating(false);
}
};
const handleDownload = (url: string, filename: string) => {
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const insertTag = (tag: string) => {
setText((prev) => (prev ? `${prev} ${tag}` : tag));
};
const formatDate = (dateString: string) => {
const d = new Date(dateString);
return new Intl.DateTimeFormat('tr-TR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }).format(d);
};
return (
<div className="container mx-auto p-4 md:p-8 space-y-8 max-w-7xl">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-purple-400 to-blue-500 bg-clip-text text-transparent flex items-center gap-2">
<Mic className="w-8 h-8 text-purple-500" />
VoiceBox Studio <span className="text-xs bg-purple-500/20 text-purple-400 px-2 py-1 rounded-full align-top">Pro</span>
</h1>
<p className="text-muted-foreground mt-2">
Gelişmiş AI Ses Sentezi ve Klonlama Arayüzü
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* LEFT COLUMN: Prompt & Results */}
<div className="lg:col-span-8 space-y-6">
<div className="rounded-xl border border-border/50 shadow-xl bg-card/50 backdrop-blur-sm text-card-foreground">
<div className="flex flex-col space-y-1.5 p-6 border-b border-border/50 bg-background/30 rounded-t-xl">
<h3 className="font-semibold leading-none tracking-tight">Senaryo Girişi</h3>
</div>
<div className="p-6 space-y-4">
<textarea
placeholder="Seslendirilmesini istediğiniz metni buraya yazın... [laugh]"
className="w-full rounded-xl border border-input/50 px-4 py-3 text-sm shadow-inner placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-purple-500 min-h-[200px] resize-y bg-background text-base transition-all"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap gap-2">
{['[laugh]', '[sigh]', '[breath]', '[pause]'].map((tag) => (
<button
key={tag}
type="button"
onClick={() => insertTag(tag)}
className="inline-flex items-center rounded-md border border-border/50 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-purple-500/20 hover:border-purple-500/50 hover:text-purple-400 bg-background/50 text-muted-foreground relative z-10 cursor-pointer"
>
{tag}
</button>
))}
</div>
<button
onClick={handleGenerate}
disabled={isGenerating || !text.trim()}
className="inline-flex items-center justify-center rounded-lg text-sm font-semibold transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 h-11 px-8 bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-500 hover:to-blue-500 text-white shadow-[0_0_20px_rgba(168,85,247,0.3)] hover:shadow-[0_0_25px_rgba(168,85,247,0.5)] w-full sm:w-auto"
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Ses Üretiliyor...
</>
) : (
<>
<Volume2 className="mr-2 h-5 w-5" />
Sesi Sentezle
</>
)}
</button>
</div>
</div>
</div>
<AnimatePresence>
{audioUrl && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="rounded-xl border border-purple-500/30 shadow-2xl bg-gradient-to-br from-card to-purple-900/10 relative overflow-hidden"
>
<div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-purple-500 to-blue-500 shadow-[0_0_15px_rgba(168,85,247,0.5)]"></div>
<div className="p-6 flex flex-col sm:flex-row items-center gap-6 relative z-10">
<div className="flex-1 w-full space-y-3">
<h3 className="font-semibold flex items-center gap-2 text-purple-400">
<Sparkles className="w-5 h-5" />
Üretim Başarılı
</h3>
<audio controls src={audioUrl} className="w-full h-12" autoPlay />
</div>
<button
onClick={() => handleDownload(audioUrl, `voicebox_${new Date().getTime()}.wav`)}
className="inline-flex items-center justify-center rounded-lg text-sm font-medium transition-colors h-11 px-6 w-full sm:w-auto border border-purple-500/30 hover:bg-purple-500/20 text-purple-300 bg-background/50 backdrop-blur-sm"
>
<Download className="w-4 h-4 mr-2" />
İndir
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* RIGHT COLUMN: Settings & History */}
<div className="lg:col-span-4 space-y-6">
{/* Settings Card */}
<div className="rounded-xl border border-border/50 shadow-lg bg-card/50 backdrop-blur-sm overflow-hidden">
<div className="p-5 border-b border-border/50 bg-background/30 flex items-center gap-2">
<Settings2 className="w-5 h-5 text-purple-400" />
<h3 className="font-semibold">Temel Ayarlar</h3>
</div>
<div className="p-5 space-y-4">
<div className="space-y-2 relative z-20">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Ses Profili</label>
<select
value={selectedProfile}
onChange={(e) => setSelectedProfile(e.target.value)}
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm focus:ring-1 focus:ring-purple-500 focus:border-purple-500 outline-none transition-all cursor-pointer"
>
{profiles.map((p) => (
<option key={p.id} value={p.id} className="bg-background">
{p.name} {p.voice_type === 'preset' ? '(Hazır)' : '(Klon)'}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 relative z-20">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
Motor (Engine)
{isPresetProfile && <span className="text-[10px] text-purple-400 bg-purple-500/10 px-1.5 py-0.5 rounded ml-2">Sabit</span>}
</label>
<select
value={engine}
onChange={(e) => setEngine(e.target.value)}
disabled={isPresetProfile}
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm focus:ring-1 focus:ring-purple-500 focus:border-purple-500 outline-none transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="kokoro">Kokoro (Hızlı, CPU)</option>
<option value="edge_tts">Edge TTS (Türkçe İçin En İyisi)</option>
<option value="qwen">Qwen</option>
<option value="qwen_custom_voice">Qwen CustomVoice</option>
<option value="chatterbox">Chatterbox</option>
<option value="luxtts">LuxTTS</option>
</select>
{isPresetProfile && (
<p className="text-[10px] text-muted-foreground">Hazır profillerin motoru değiştirilemez.</p>
)}
</div>
<div className="space-y-2 relative z-20">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Dil</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm focus:ring-1 focus:ring-purple-500 focus:border-purple-500 outline-none transition-all cursor-pointer"
>
<option value="tr">Türkçe (TR)</option>
<option value="en">İngilizce (EN)</option>
<option value="zh">Çince (ZH)</option>
<option value="ja">Japonca (JA)</option>
<option value="ko">Korece (KO)</option>
<option value="de">Almanca (DE)</option>
<option value="fr">Fransızca (FR)</option>
<option value="es">İspanyolca (ES)</option>
</select>
</div>
</div>
</div>
{/* Advanced Settings Accordion */}
<div className="border-t border-border/50">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full flex items-center justify-between p-4 hover:bg-background/50 transition-colors text-sm font-medium"
>
Gelişmiş Ayarlar
{showAdvanced ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
<AnimatePresence>
{showAdvanced && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="p-5 pt-0 space-y-4 bg-background/20">
{engine === 'qwen' && (
<div className="rounded-lg bg-orange-500/10 border border-orange-500/20 p-3 flex gap-3 text-orange-400">
<AlertTriangle className="w-5 h-5 shrink-0" />
<p className="text-xs">
<strong>Uyarı:</strong> Qwen veya büyük boyutlu (1.7B, 4B) modeller, sınırlı RAM'e sahip ortamlarda (Raspberry Pi vb.) stabilite sorunları ve bellek taşması (Out of Memory) yaratabilir. Varsayılan Kokoro motoru daha verimlidir.
</p>
</div>
)}
<div className="space-y-2">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Model Boyutu</label>
<select
value={modelSize}
onChange={(e) => setModelSize(e.target.value)}
disabled={engine !== 'qwen'}
className="w-full rounded-lg border border-input bg-background/50 px-3 py-2 text-sm focus:ring-1 focus:ring-purple-500 disabled:opacity-50"
>
<option value="0.6B">0.6B (Hızlı/Düşük RAM)</option>
<option value="1.7B">1.7B (Dengeli)</option>
<option value="3B">3B (Gelişmiş)</option>
<option value="4B">4B (Ağır/Yüksek RAM)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Talimat (Instruct)</label>
<input
type="text"
value={instruct}
onChange={(e) => setInstruct(e.target.value)}
placeholder="Örn: Fısıldayarak konuş..."
className="w-full rounded-lg border border-input bg-background/50 px-3 py-2 text-sm focus:ring-1 focus:ring-purple-500"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Tohum (Seed)</label>
<input
type="number"
value={seed}
onChange={(e) => setSeed(e.target.value ? Number(e.target.value) : '')}
placeholder="Boş bırakırsanız rastgele"
className="w-full rounded-lg border border-input bg-background/50 px-3 py-2 text-sm focus:ring-1 focus:ring-purple-500"
/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* History Card */}
<div className="rounded-xl border border-border/50 shadow-lg bg-card/50 backdrop-blur-sm overflow-hidden flex flex-col h-[400px]">
<div className="p-4 border-b border-border/50 bg-background/30 flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="w-5 h-5 text-blue-400" />
<h3 className="font-semibold">Üretim Geçmişi</h3>
</div>
<span className="bg-blue-500/10 text-blue-400 text-xs px-2 py-1 rounded-full font-medium">
{historyItems.length} Kayıt
</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{historyItems.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground p-6 text-center">
<History className="w-10 h-10 mb-3 opacity-20" />
<p className="text-sm">Henüz bir ses üretmediniz.</p>
</div>
) : (
historyItems.map((item) => (
<div key={item.id} className="group rounded-lg border border-border/50 bg-background/40 hover:bg-background/80 p-3 transition-colors">
<div className="flex items-start justify-between mb-2">
<p className="text-xs font-medium line-clamp-2 pr-4">{item.text}</p>
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground mb-3">
<span className="flex items-center gap-1 bg-muted px-1.5 py-0.5 rounded">
<Clock className="w-3 h-3" /> {formatDate(item.created_at)}
</span>
<span className="uppercase font-semibold tracking-wider">{item.engine}</span>
<span className="uppercase">{item.language}</span>
</div>
<div className="flex items-center gap-2">
<audio controls src={voiceboxApi.getAudioUrl(item.id)} className="h-7 w-full [&::-webkit-media-controls-panel]:bg-background" />
<button
onClick={() => handleDownload(voiceboxApi.getAudioUrl(item.id), `history_${item.id}.wav`)}
className="p-1.5 rounded-md border border-border/50 hover:bg-purple-500/20 hover:text-purple-400 transition-colors"
title="İndir"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -251,7 +251,7 @@ body {
/* ── Page Transition ── */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
to { opacity: 1; transform: none; }
}
.page-enter {
animation: fadeIn var(--duration-slow) var(--ease-out-expo) forwards;
+6
View File
@@ -71,12 +71,18 @@ const handler = NextAuth({
token.refreshToken = user.refreshToken;
token.id = user.id;
token.roles = user.roles;
token.name = user.name;
token.email = user.email;
}
return token;
},
async session({ session, token }: any) {
if (session.user) {
session.user.id = token.id;
session.user.roles = token.roles;
session.user.name = token.name;
session.user.email = token.email;
}
session.accessToken = token.accessToken;
session.refreshToken = token.refreshToken;
return session;
+9 -2
View File
@@ -80,7 +80,10 @@ export default function Header() {
return (
<MenuRoot positioning={{ placement: "bottom-start" }}>
<MenuTrigger rounded="full" focusRing="none">
<Avatar name={session?.user?.name || "User"} variant="solid" />
<Avatar
name={session?.user?.name || (session?.user?.email ? session.user.email.split('@')[0] : "Kullanıcı")}
variant="solid"
/>
</MenuTrigger>
<MenuContent>
<MenuItem onClick={handleLogout} value="sign-out">
@@ -112,9 +115,13 @@ export default function Header() {
}
if (isAuthenticated) {
const displayInitial = session?.user?.name
? session.user.name
: (session?.user?.email ? session.user.email.split('@')[0] : "Kullanıcı");
return (
<>
<Avatar name={session?.user?.name || "User"} variant="solid" />
<Avatar name={displayInitial} variant="solid" />
<Button
variant="surface"
size="sm"
+79
View File
@@ -0,0 +1,79 @@
import { clientMap } from '@/lib/api/client-map';
export const voiceboxApi = {
getProfiles: async () => {
try {
const response = await clientMap.core.get('/voicebox/profiles');
return response.data;
} catch (error) {
console.error('Error fetching VoiceBox profiles:', error);
throw error;
}
},
generateSpeech: async (text: string, profileId: string, options: { language?: string, engine?: string, modelSize?: string, instruct?: string, seed?: number } = {}): Promise<Blob> => {
try {
const response = await clientMap.core.post(
'/voicebox/generate',
{ text, profileId, language: options.language || 'tr', engine: options.engine, modelSize: options.modelSize, instruct: options.instruct, seed: options.seed },
{ responseType: 'blob' }
);
const data = response.data;
// Eğer backend JSON döndüyse (hata durumu) ama responseType blob olduğu için blob olarak geldiyse
if (data instanceof Blob) {
// Content-Type kontrolü her zaman güvenilir olmayabilir (özellikle hata durumlarında interceptor'lar değiştirebilir).
// Eğer boyut çok küçükse (örneğin < 1000 byte) ve type json içeriyorsa veya hiç type yoksa kontrol et:
if (data.type.includes('application/json') || data.size < 2000) {
const textData = await data.text();
try {
const json = JSON.parse(textData);
if (!json.success) {
throw new Error(json.message || 'Ses üretilirken bir hata oluştu.');
}
} catch (e) {
// Eğer JSON parse hatası verdiyse, demek ki gerçekten küçük bir ses dosyası (ya da geçersiz json).
// Eğer orijinal hata fırlatıldıysa (Error instance'ı), onu yukarı taşı:
if (e instanceof Error && e.message !== 'Unexpected token' && !e.message.includes('JSON')) {
throw e;
}
}
}
}
return data;
} catch (error) {
console.error('Error generating VoiceBox speech:', error);
throw error;
}
},
getHistory: async () => {
try {
const response = await clientMap.core.get('/voicebox/history');
return response.data;
} catch (error) {
console.error('Error fetching VoiceBox history:', error);
throw error;
}
},
getAudioUrl: (generationId: string) => {
// API endpoint for `<audio src="...">` tag
return `${process.env.NEXT_PUBLIC_CORE_API_URL || 'http://localhost:3000/api'}/voicebox/audio/${generationId}`;
},
speak: async (text: string, profile: string, personality: boolean = false) => {
try {
const response = await clientMap.core.post('/voicebox/speak', {
text,
profile,
personality,
});
return response.data;
} catch (error) {
console.error('Error triggering VoiceBox speak:', error);
throw error;
}
},
};