generated from fahricansecer/boilerplate-fe
This commit is contained in:
+2
-2
@@ -4,7 +4,7 @@ RUN apk add --no-cache libc6-compat
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# pnpm kurulumu (workspace kuralı gereği)
|
# 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 ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
@@ -13,7 +13,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
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 --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Wrench, Video, ArrowRight } from "lucide-react";
|
import { Wrench, Video, ArrowRight, Mic } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { motion, useMotionTemplate, useMotionValue, useSpring } from "framer-motion";
|
import { motion, useMotionTemplate, useMotionValue, useSpring } from "framer-motion";
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
@@ -164,6 +164,15 @@ export default function ToolsPage() {
|
|||||||
colorClass="bg-blue-500/20 text-blue-400"
|
colorClass="bg-blue-500/20 text-blue-400"
|
||||||
spotlightColor="rgba(59, 130, 246, 0.15)"
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+531
-41
@@ -3,7 +3,7 @@
|
|||||||
import React, { useState, useEffect, Component, ErrorInfo, ReactNode } from 'react';
|
import React, { useState, useEffect, Component, ErrorInfo, ReactNode } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { 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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -41,11 +41,14 @@ export default function EpisodeWorkbenchPage() {
|
|||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [isGeneratingQuestions, setIsGeneratingQuestions] = useState(false);
|
||||||
const [isGeneratingSeo, setIsGeneratingSeo] = useState(false);
|
const [isGeneratingSeo, setIsGeneratingSeo] = useState(false);
|
||||||
const [isGeneratingCrisis, setIsGeneratingCrisis] = useState(false);
|
const [isGeneratingCrisis, setIsGeneratingCrisis] = useState(false);
|
||||||
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = 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 [isThumbnailExpanded, setIsThumbnailExpanded] = useState(false);
|
||||||
const [isCopied, setIsCopied] = 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 = () => {
|
const handleDownloadThumbnail = () => {
|
||||||
if (episode?.masterAnalysis?.thumbnailUrl) {
|
if (episode?.masterAnalysis?.thumbnailUrl) {
|
||||||
const link = document.createElement('a');
|
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} • <strong>Süre:</strong> ${episode.duration} • <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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
@@ -219,26 +481,37 @@ export default function EpisodeWorkbenchPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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' ? (
|
{episode.status !== 'COMPLETED' ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleAnalyze}
|
onClick={handleAnalyze}
|
||||||
disabled={isAnalyzing}
|
disabled={isAnalyzing}
|
||||||
className="px-6 py-3 bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600 text-white font-bold text-sm rounded-xl flex items-center gap-2 shadow-lg shadow-orange-500/20 transition-all disabled:opacity-50"
|
className="px-6 py-3 bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600 text-white font-bold text-sm rounded-xl flex items-center gap-2 shadow-lg shadow-orange-500/20 transition-all disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isAnalyzing ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />}
|
{isAnalyzing ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />}
|
||||||
{isAnalyzing ? 'Analiz Ediliyor...' : 'Analizi Başlat'}
|
{isAnalyzing ? 'Analiz Ediliyor...' : 'Analizi Başlat'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleAnalyze}
|
onClick={handleAnalyze}
|
||||||
disabled={isAnalyzing}
|
disabled={isAnalyzing}
|
||||||
className="px-6 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 disabled:opacity-50"
|
className="px-6 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 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isAnalyzing ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />}
|
{isAnalyzing ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />}
|
||||||
{isAnalyzing ? 'Yeniden Analiz Ediliyor...' : 'Analizi Yeniden Yap'}
|
{isAnalyzing ? 'Yeniden Analiz Ediliyor...' : 'Analizi Yeniden Yap'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -341,6 +614,39 @@ export default function EpisodeWorkbenchPage() {
|
|||||||
>
|
>
|
||||||
<Zap size={18} /> Kriz & Sponsor
|
<Zap size={18} /> Kriz & Sponsor
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
@@ -467,25 +773,24 @@ 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 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="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>
|
||||||
|
|
||||||
{/* Tags */}
|
<div className="flex gap-4">
|
||||||
<div className="flex gap-2 justify-end mb-4 absolute top-4 right-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">
|
||||||
{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}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{neuroScore && (
|
|
||||||
<span className="px-2 py-1 bg-red-500/10 border border-red-500/20 text-red-500 text-[10px] font-bold rounded-md uppercase tracking-wider flex items-center gap-1">
|
|
||||||
<Star size={10} /> Skoru: {neuroScore}
|
|
||||||
</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}
|
{idx + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 flex-1">
|
<div className="space-y-3 flex-1">
|
||||||
|
{/* Tags */}
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{neuroScore && (
|
||||||
|
<span className="px-2 py-1 bg-red-500/10 border border-red-500/20 text-red-500 text-[10px] font-bold rounded-md uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<Star size={10} /> Skoru: {neuroScore}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<h3 className="text-lg font-bold text-[var(--color-text-primary)] leading-snug">{questionText}</h3>
|
<h3 className="text-lg font-bold text-[var(--color-text-primary)] leading-snug">{questionText}</h3>
|
||||||
{neuroDirection && (
|
{neuroDirection && (
|
||||||
<div className="p-4 bg-orange-500/5 rounded-xl border border-orange-500/10 mt-3">
|
<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>
|
</div>
|
||||||
<div className="flex-1 flex flex-col">
|
<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">
|
||||||
<Mail size={14} /> Türkçe E-Posta Taslağı
|
<h5 className="text-xs font-bold uppercase tracking-wider text-[var(--color-text-secondary)] flex items-center gap-2">
|
||||||
</h5>
|
<Mail size={14} /> Türkçe E-Posta Taslağı
|
||||||
<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">
|
</h5>
|
||||||
|
<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}
|
{sponsor.emailDraft}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -742,6 +1060,178 @@ export default function EpisodeWorkbenchPage() {
|
|||||||
</motion.div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+121
-28
@@ -8,7 +8,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getProjectById, ProjectResponse, addVideoToProject, addDocumentToProject,
|
getProjectById, ProjectResponse, addVideoToProject, addDocumentToProject,
|
||||||
updateProject, createEpisode, getTopicSuggestions, TopicSuggestion, EpisodeResponse
|
updateProject, createEpisode, getTopicSuggestions, TopicSuggestion, EpisodeResponse,
|
||||||
|
generateCommunityIdeas
|
||||||
} from '../services/strategistApi';
|
} from '../services/strategistApi';
|
||||||
|
|
||||||
class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasError: boolean, error: Error | null}> {
|
class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasError: boolean, error: Error | null}> {
|
||||||
@@ -55,6 +56,7 @@ export default function StrategistHubPage() {
|
|||||||
|
|
||||||
// Settings Modal State
|
// Settings Modal State
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isFormatExpanded, setIsFormatExpanded] = useState(false);
|
||||||
const [settingsForm, setSettingsForm] = useState({
|
const [settingsForm, setSettingsForm] = useState({
|
||||||
name: "", tone: "", targetDuration: "", speakerName: "", targetAudience: "", formatDescription: ""
|
name: "", tone: "", targetDuration: "", speakerName: "", targetAudience: "", formatDescription: ""
|
||||||
});
|
});
|
||||||
@@ -68,6 +70,7 @@ export default function StrategistHubPage() {
|
|||||||
const [isAiTopic, setIsAiTopic] = useState(false);
|
const [isAiTopic, setIsAiTopic] = useState(false);
|
||||||
const [suggestions, setSuggestions] = useState<TopicSuggestion[]>([]);
|
const [suggestions, setSuggestions] = useState<TopicSuggestion[]>([]);
|
||||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||||
|
const [isGeneratingIdeas, setIsGeneratingIdeas] = useState(false);
|
||||||
const [isCreatingEpisode, setIsCreatingEpisode] = useState(false);
|
const [isCreatingEpisode, setIsCreatingEpisode] = useState(false);
|
||||||
const [episodeError, setEpisodeError] = useState("");
|
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) => {
|
const handleCreateEpisode = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const finalTopic = isAiTopic ? "AI_AUTO" : episodeForm.topic;
|
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">
|
<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
|
<Target className="text-blue-500" size={20} /> Format & Konsept
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-[var(--color-text-secondary)] leading-relaxed whitespace-pre-wrap">
|
<div className="text-sm text-[var(--color-text-secondary)] leading-relaxed whitespace-pre-wrap">
|
||||||
{project.formatDescription || "Format açıklaması girilmemiş. Ayarlardan ekleyebilirsiniz."}
|
{project.formatDescription ? (
|
||||||
</p>
|
<>
|
||||||
|
{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 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)]">
|
<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,22 +531,79 @@ export default function StrategistHubPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings Modal */}
|
{/* Settings Modal */}
|
||||||
{isSettingsOpen && (
|
{isSettingsOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 overflow-y-auto 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="flex min-h-full items-center justify-center p-4 py-10">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-[var(--color-border-faint)]">
|
<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">
|
||||||
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">Proje Ayarları</h2>
|
<div className="flex items-center justify-between p-6 border-b border-[var(--color-border-faint)]">
|
||||||
<button onClick={() => setIsSettingsOpen(false)} className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
|
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">Proje Ayarları</h2>
|
||||||
<X size={20} />
|
<button onClick={() => setIsSettingsOpen(false)} className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
|
||||||
</button>
|
<X size={20} />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleUpdateSettings} className="p-6 overflow-y-auto space-y-5">
|
<form onSubmit={handleUpdateSettings} className="p-6 space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-[var(--color-text-secondary)] uppercase tracking-wider mb-2">Proje Adı</label>
|
<label className="block text-xs font-bold text-[var(--color-text-secondary)] uppercase tracking-wider mb-2">Proje Adı</label>
|
||||||
<input
|
<input
|
||||||
@@ -582,27 +672,29 @@ export default function StrategistHubPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New Episode Modal */}
|
{/* New Episode Modal */}
|
||||||
{isEpisodeModalOpen && (
|
{isEpisodeModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 overflow-y-auto 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="flex min-h-full items-center justify-center p-4 py-10">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-[var(--color-border-faint)]">
|
<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>
|
<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)] flex items-center gap-2">
|
<div>
|
||||||
<Zap className="text-orange-500" /> Yeni Bölüm Tasarla
|
<h2 className="text-xl font-bold text-[var(--color-text-primary)] flex items-center gap-2">
|
||||||
</h2>
|
<Zap className="text-orange-500" /> Yeni Bölüm Tasarla
|
||||||
<p className="text-sm text-[var(--color-text-secondary)] mt-1">
|
</h2>
|
||||||
Bu format için yeni bir bölümün Ön-Yapım (Pre-Production) sürecini başlatın.
|
<p className="text-sm text-[var(--color-text-secondary)] mt-1">
|
||||||
</p>
|
Bu format için yeni bir bölümün Ön-Yapım (Pre-Production) sürecini başlatın.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setIsEpisodeModalOpen(false)} className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setIsEpisodeModalOpen(false)} className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
|
|
||||||
<X size={20} />
|
|
||||||
</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 */}
|
{/* Topic Section */}
|
||||||
<div className="p-5 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-base)] space-y-4">
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { useRouter, useParams } from 'next/navigation';
|
||||||
import { LegacyUploader } from './components/LegacyUploader';
|
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 { cn } from '@/lib/utils';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
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 (
|
return (
|
||||||
<div className="max-w-6xl mx-auto space-y-8 pb-24 font-sans px-4 sm:px-0">
|
<div className="max-w-6xl mx-auto space-y-8 pb-24 font-sans px-4 sm:px-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -166,15 +181,24 @@ 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="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">
|
<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={cn(
|
<div className="flex items-center gap-2">
|
||||||
"px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider",
|
<div className={cn(
|
||||||
proj.status === 'COMPLETED' ? "bg-emerald-500/10 text-emerald-500 border border-emerald-500/20" :
|
"px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider",
|
||||||
proj.status === 'ANALYZING' ? "bg-amber-500/10 text-amber-500 border border-amber-500/20 flex items-center gap-1" :
|
proj.status === 'COMPLETED' ? "bg-emerald-500/10 text-emerald-500 border border-emerald-500/20" :
|
||||||
"bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] border border-[var(--color-border-faint)]"
|
proj.status === 'ANALYZING' ? "bg-amber-500/10 text-amber-500 border border-amber-500/20 flex items-center gap-1" :
|
||||||
)}>
|
"bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] border border-[var(--color-border-faint)]"
|
||||||
{proj.status === 'ANALYZING' && <Loader2 className="w-3 h-3 animate-spin" />}
|
)}>
|
||||||
{proj.status === 'COMPLETED' ? 'Tamamlandı' : proj.status === 'ANALYZING' ? 'Analiz Ediliyor' : 'Bekliyor'}
|
{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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+24
@@ -28,6 +28,9 @@ export interface EpisodeResponse {
|
|||||||
format: string;
|
format: string;
|
||||||
status: string;
|
status: string;
|
||||||
masterAnalysis: any;
|
masterAnalysis: any;
|
||||||
|
thumbnailMatrix?: any;
|
||||||
|
shortsConcepts?: any;
|
||||||
|
sponsorshipPitch?: any;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -52,6 +55,7 @@ export interface ProjectResponse {
|
|||||||
episodes?: EpisodeResponse[];
|
episodes?: EpisodeResponse[];
|
||||||
_count?: { videos: number };
|
_count?: { videos: number };
|
||||||
masterAnalysis: any;
|
masterAnalysis: any;
|
||||||
|
communityInsights?: any;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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);
|
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> => {
|
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);
|
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);
|
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> => {
|
export const generateEpisodeQuestions = async (episodeId: string): Promise<any> => {
|
||||||
return apiClient.post<any>(`/youtube-tools/strategist/episodes/${episodeId}/generate-questions`).then(r => r.data);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -251,7 +251,7 @@ body {
|
|||||||
/* ── Page Transition ── */
|
/* ── Page Transition ── */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
.page-enter {
|
.page-enter {
|
||||||
animation: fadeIn var(--duration-slow) var(--ease-out-expo) forwards;
|
animation: fadeIn var(--duration-slow) var(--ease-out-expo) forwards;
|
||||||
|
|||||||
@@ -71,12 +71,18 @@ const handler = NextAuth({
|
|||||||
token.refreshToken = user.refreshToken;
|
token.refreshToken = user.refreshToken;
|
||||||
token.id = user.id;
|
token.id = user.id;
|
||||||
token.roles = user.roles;
|
token.roles = user.roles;
|
||||||
|
token.name = user.name;
|
||||||
|
token.email = user.email;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, token }: any) {
|
async session({ session, token }: any) {
|
||||||
session.user.id = token.id;
|
if (session.user) {
|
||||||
session.user.roles = token.roles;
|
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.accessToken = token.accessToken;
|
||||||
session.refreshToken = token.refreshToken;
|
session.refreshToken = token.refreshToken;
|
||||||
return session;
|
return session;
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ export default function Header() {
|
|||||||
return (
|
return (
|
||||||
<MenuRoot positioning={{ placement: "bottom-start" }}>
|
<MenuRoot positioning={{ placement: "bottom-start" }}>
|
||||||
<MenuTrigger rounded="full" focusRing="none">
|
<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>
|
</MenuTrigger>
|
||||||
<MenuContent>
|
<MenuContent>
|
||||||
<MenuItem onClick={handleLogout} value="sign-out">
|
<MenuItem onClick={handleLogout} value="sign-out">
|
||||||
@@ -112,9 +115,13 @@ export default function Header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
|
const displayInitial = session?.user?.name
|
||||||
|
? session.user.name
|
||||||
|
: (session?.user?.email ? session.user.email.split('@')[0] : "Kullanıcı");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
<Avatar name={displayInitial} variant="solid" />
|
||||||
<Button
|
<Button
|
||||||
variant="surface"
|
variant="surface"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user