generated from fahricansecer/boilerplate-fe
main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
This commit is contained in:
161
src/components/dashboard/dashboard-charts.tsx
Normal file
161
src/components/dashboard/dashboard-charts.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
|
||||
const weeklyData = [
|
||||
{ name: "Pzt", videos: 1, credits: 4 },
|
||||
{ name: "Sal", videos: 2, credits: 8 },
|
||||
{ name: "Çar", videos: 0, credits: 0 },
|
||||
{ name: "Per", videos: 3, credits: 12 },
|
||||
{ name: "Cum", videos: 1, credits: 4 },
|
||||
{ name: "Cmt", videos: 2, credits: 8 },
|
||||
{ name: "Paz", videos: 1, credits: 4 },
|
||||
];
|
||||
|
||||
const statusData = [
|
||||
{ name: "Tamamlanan", value: 8, color: "#34d399" },
|
||||
{ name: "Devam Eden", value: 2, color: "#8b5cf6" },
|
||||
{ name: "Taslak", value: 2, color: "#4a4a6a" },
|
||||
];
|
||||
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; dataKey: string; color: string }>;
|
||||
label?: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="glass rounded-lg px-3 py-2 text-xs shadow-xl">
|
||||
<p className="text-[var(--color-text-muted)] mb-1">{label}</p>
|
||||
{payload.map((entry) => (
|
||||
<p key={entry.dataKey} className="text-[var(--color-text-primary)] font-medium">
|
||||
{entry.dataKey === "videos" ? "Video" : "Kredi"}: {entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardCharts() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* ── Haftalık Video Üretimi (Area Chart) ── */}
|
||||
<div className="lg:col-span-2 card-surface p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold">
|
||||
Haftalık Üretim
|
||||
</h2>
|
||||
<span className="badge badge-violet">Bu Hafta</span>
|
||||
</div>
|
||||
<div className="h-48 md:h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={weeklyData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradientViolet" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#8b5cf6" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradientCyan" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#06b6d4" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "#6a6a8a", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "#6a6a8a", fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="videos"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
fill="url(#gradientViolet)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="credits"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={1.5}
|
||||
fill="url(#gradientCyan)"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-violet-500" />
|
||||
Video
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-cyan-500" />
|
||||
Kredi Kullanımı
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Proje Durumu (Donut Chart) ── */}
|
||||
<div className="card-surface p-5">
|
||||
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold mb-4">
|
||||
Proje Durumu
|
||||
</h2>
|
||||
<div className="h-40 flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={65}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{statusData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="space-y-2 mt-2">
|
||||
{statusData.map((item) => (
|
||||
<div key={item.name} className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-2 text-[var(--color-text-secondary)]">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
src/components/dashboard/recent-projects.tsx
Normal file
196
src/components/dashboard/recent-projects.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Download,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ProjectStatus = "COMPLETED" | "RENDERING" | "GENERATING_MEDIA" | "DRAFT" | "FAILED";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
status: ProjectStatus;
|
||||
progress: number;
|
||||
language: string;
|
||||
duration: number;
|
||||
createdAt: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Boötes Boşluğu — Evrenin En Büyük Gizemi",
|
||||
status: "COMPLETED",
|
||||
progress: 100,
|
||||
language: "tr",
|
||||
duration: 45,
|
||||
createdAt: "2 saat önce",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Kuantum Dolanıklık Nedir?",
|
||||
status: "RENDERING",
|
||||
progress: 72,
|
||||
language: "tr",
|
||||
duration: 60,
|
||||
createdAt: "30 dk önce",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "The Dark History of the Bermuda Triangle",
|
||||
status: "GENERATING_MEDIA",
|
||||
progress: 35,
|
||||
language: "en",
|
||||
duration: 40,
|
||||
createdAt: "15 dk önce",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Yapay Zeka Sanatı Öldürecek mi?",
|
||||
status: "DRAFT",
|
||||
progress: 0,
|
||||
language: "tr",
|
||||
duration: 55,
|
||||
createdAt: "1 gün önce",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "5 Misterios sin Resolver de la Historia",
|
||||
status: "FAILED",
|
||||
progress: 48,
|
||||
language: "es",
|
||||
duration: 50,
|
||||
createdAt: "3 saat önce",
|
||||
},
|
||||
];
|
||||
|
||||
const statusConfig: Record<
|
||||
ProjectStatus,
|
||||
{ label: string; icon: React.ElementType; badgeClass: string }
|
||||
> = {
|
||||
COMPLETED: { label: "Tamamlandı", icon: CheckCircle2, badgeClass: "badge-emerald" },
|
||||
RENDERING: { label: "Render", icon: Play, badgeClass: "badge-violet" },
|
||||
GENERATING_MEDIA: { label: "Medya Üretimi", icon: Clock, badgeClass: "badge-cyan" },
|
||||
DRAFT: { label: "Taslak", icon: FileText, badgeClass: "badge-amber" },
|
||||
FAILED: { label: "Başarısız", icon: AlertCircle, badgeClass: "badge-rose" },
|
||||
};
|
||||
|
||||
const flagEmoji: Record<string, string> = {
|
||||
tr: "🇹🇷",
|
||||
en: "🇺🇸",
|
||||
es: "🇪🇸",
|
||||
de: "🇩🇪",
|
||||
fr: "🇫🇷",
|
||||
ar: "🇸🇦",
|
||||
};
|
||||
|
||||
export function RecentProjects() {
|
||||
return (
|
||||
<div className="card-surface overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 md:p-5 border-b border-[var(--color-border-faint)]">
|
||||
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold">
|
||||
Son Projeler
|
||||
</h2>
|
||||
<Link
|
||||
href="/dashboard/projects"
|
||||
className="text-xs text-violet-400 hover:text-violet-300 transition-colors"
|
||||
>
|
||||
Tümünü gör →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[var(--color-border-faint)]">
|
||||
{mockProjects.map((project, i) => {
|
||||
const config = statusConfig[project.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.06, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<Link
|
||||
href={`/dashboard/projects/${project.id}`}
|
||||
className="group flex items-center gap-3 md:gap-4 px-4 md:px-5 py-3.5 hover:bg-[var(--color-bg-elevated)]/50 transition-colors"
|
||||
>
|
||||
{/* Thumbnail placeholder */}
|
||||
<div className="w-12 h-12 md:w-14 md:h-14 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<span className="text-xl">{flagEmoji[project.language] || "🌍"}</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium truncate group-hover:text-violet-300 transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn("badge text-[10px]", config.badgeClass)}>
|
||||
<StatusIcon size={10} className="mr-1" />
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{project.duration}s
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{project.createdAt}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{project.progress > 0 && project.progress < 100 && (
|
||||
<div className="progress-bar mt-2 w-full max-w-[200px]">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{ width: `${project.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="hidden md:flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{project.status === "COMPLETED" && (
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
||||
aria-label="İndir"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
aria-label="Sil"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] transition-colors"
|
||||
aria-label="Daha fazla"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
386
src/components/dashboard/tweet-import-card.tsx
Normal file
386
src/components/dashboard/tweet-import-card.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Twitter,
|
||||
Loader2,
|
||||
Heart,
|
||||
Repeat2,
|
||||
Eye,
|
||||
MessageCircle,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Flame,
|
||||
Image as ImageIcon,
|
||||
Link2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useTweet } from "@/hooks/use-tweet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TweetImportCardProps {
|
||||
onProjectCreated?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export function TweetImportCard({ onProjectCreated }: TweetImportCardProps) {
|
||||
const {
|
||||
preview,
|
||||
isLoadingPreview,
|
||||
isCreatingProject,
|
||||
error,
|
||||
createdProject,
|
||||
isValidTweetUrl,
|
||||
previewTweet,
|
||||
createProjectFromTweet,
|
||||
reset,
|
||||
} = useTweet();
|
||||
|
||||
const [tweetUrl, setTweetUrl] = useState("");
|
||||
const [customTitle, setCustomTitle] = useState("");
|
||||
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!tweetUrl.trim()) return;
|
||||
await previewTweet(tweetUrl.trim());
|
||||
}, [tweetUrl, previewTweet]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!tweetUrl.trim()) return;
|
||||
|
||||
const project = await createProjectFromTweet({
|
||||
tweetUrl: tweetUrl.trim(),
|
||||
title: customTitle.trim() || undefined,
|
||||
});
|
||||
|
||||
if (project && onProjectCreated) {
|
||||
onProjectCreated(project.id);
|
||||
}
|
||||
}, [tweetUrl, customTitle, createProjectFromTweet, onProjectCreated]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!preview) {
|
||||
handlePreview();
|
||||
} else {
|
||||
handleCreate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isUrlValid = tweetUrl.trim().length > 0 && isValidTweetUrl(tweetUrl.trim());
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const viralColor = (score: number) => {
|
||||
if (score >= 70) return "text-emerald-400";
|
||||
if (score >= 40) return "text-amber-400";
|
||||
return "text-[var(--color-text-muted)]";
|
||||
};
|
||||
|
||||
const viralBg = (score: number) => {
|
||||
if (score >= 70) return "bg-emerald-500/10 border-emerald-500/30";
|
||||
if (score >= 40) return "bg-amber-500/10 border-amber-500/30";
|
||||
return "bg-[var(--color-bg-elevated)] border-[var(--color-border-faint)]";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card-surface overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4 md:p-5 border-b border-[var(--color-border-faint)]">
|
||||
<div className="w-9 h-9 rounded-xl bg-sky-500/15 flex items-center justify-center">
|
||||
<Twitter size={18} className="text-sky-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-[family-name:var(--font-display)] text-sm font-semibold">
|
||||
X/Twitter Import
|
||||
</h3>
|
||||
<p className="text-[11px] text-[var(--color-text-ghost)]">
|
||||
Tweet → Video pipeline
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL Input */}
|
||||
<div className="p-4 md:p-5 space-y-4">
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]">
|
||||
<Link2 size={16} />
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
value={tweetUrl}
|
||||
onChange={(e) => {
|
||||
setTweetUrl(e.target.value);
|
||||
if (preview) reset();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="https://x.com/user/status/123..."
|
||||
className="w-full h-11 pl-10 pr-24 rounded-xl bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-ghost)] focus:border-sky-500/50 focus:ring-1 focus:ring-sky-500/25 outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={preview ? handleCreate : handlePreview}
|
||||
disabled={!isUrlValid || isLoadingPreview || isCreatingProject}
|
||||
className={cn(
|
||||
"absolute right-1.5 top-1/2 -translate-y-1/2 h-8 px-3 rounded-lg text-xs font-medium transition-all flex items-center gap-1.5",
|
||||
isUrlValid && !isLoadingPreview && !isCreatingProject
|
||||
? "bg-sky-500 text-white hover:bg-sky-400 shadow-sm"
|
||||
: "bg-[var(--color-bg-surface)] text-[var(--color-text-ghost)] cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isLoadingPreview ? (
|
||||
<>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
Çekiliyor
|
||||
</>
|
||||
) : isCreatingProject ? (
|
||||
<>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
Üretiliyor
|
||||
</>
|
||||
) : preview ? (
|
||||
<>
|
||||
<Zap size={13} />
|
||||
Oluştur
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRight size={13} />
|
||||
Ön İzle
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
<AnimatePresence mode="wait">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8, height: 0 }}
|
||||
animate={{ opacity: 1, y: 0, height: "auto" }}
|
||||
exit={{ opacity: 0, y: -8, height: 0 }}
|
||||
className="flex items-center gap-2 text-xs text-rose-400 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2"
|
||||
>
|
||||
<XCircle size={14} />
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Preview Card */}
|
||||
<AnimatePresence mode="wait">
|
||||
{preview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 12, scale: 0.97 }}
|
||||
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{/* Author + Viral Score */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-full bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] overflow-hidden flex items-center justify-center">
|
||||
{preview.tweet.author.avatarUrl ? (
|
||||
<img
|
||||
src={preview.tweet.author.avatarUrl}
|
||||
alt={preview.tweet.author.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Twitter size={16} className="text-sky-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{preview.tweet.author.name}
|
||||
</span>
|
||||
{preview.tweet.author.verified && (
|
||||
<CheckCircle2
|
||||
size={13}
|
||||
className="text-sky-400 fill-sky-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--color-text-ghost)]">
|
||||
@{preview.tweet.author.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Viral Score Badge */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium",
|
||||
viralBg(preview.viralScore)
|
||||
)}
|
||||
>
|
||||
<Flame size={13} className={viralColor(preview.viralScore)} />
|
||||
<span className={viralColor(preview.viralScore)}>
|
||||
{preview.viralScore}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tweet Text */}
|
||||
<div className="bg-[var(--color-bg-elevated)] rounded-xl p-3 border border-[var(--color-border-faint)]">
|
||||
<p className="text-[13px] text-[var(--color-text-secondary)] leading-relaxed line-clamp-4">
|
||||
{preview.tweet.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Media preview */}
|
||||
{preview.tweet.media.length > 0 && (
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||
{preview.tweet.media.slice(0, 4).map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-16 h-16 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] overflow-hidden flex-shrink-0"
|
||||
>
|
||||
{m.type === "photo" ? (
|
||||
<img
|
||||
src={m.url}
|
||||
alt={`Media ${i + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[var(--color-text-ghost)]">
|
||||
<ImageIcon size={20} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{
|
||||
icon: Heart,
|
||||
value: preview.tweet.metrics.likes,
|
||||
color: "text-rose-400",
|
||||
},
|
||||
{
|
||||
icon: Repeat2,
|
||||
value: preview.tweet.metrics.retweets,
|
||||
color: "text-emerald-400",
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
value: preview.tweet.metrics.views,
|
||||
color: "text-sky-400",
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
value: preview.tweet.metrics.replies,
|
||||
color: "text-amber-400",
|
||||
},
|
||||
].map(({ icon: Icon, value, color }, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-center gap-1.5 py-2 rounded-lg bg-[var(--color-bg-elevated)]/50 border border-[var(--color-border-faint)]"
|
||||
>
|
||||
<Icon size={13} className={color} />
|
||||
<span className="text-[11px] font-medium">
|
||||
{formatNumber(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info badges */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="badge badge-cyan text-[10px]">
|
||||
{preview.contentType === "thread"
|
||||
? "Thread"
|
||||
: preview.contentType === "quote_tweet"
|
||||
? "Alıntı"
|
||||
: "Tweet"}
|
||||
</span>
|
||||
<span className="badge badge-violet text-[10px]">
|
||||
~{preview.estimatedDuration}s video
|
||||
</span>
|
||||
{preview.tweet.media.length > 0 && (
|
||||
<span className="badge badge-amber text-[10px]">
|
||||
{preview.tweet.media.length} medya
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom Title */}
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder={preview.suggestedTitle}
|
||||
className="w-full h-10 px-3 rounded-xl bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-ghost)]/50 focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 outline-none transition-all"
|
||||
/>
|
||||
|
||||
{/* Create Button */}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreatingProject}
|
||||
className={cn(
|
||||
"w-full h-11 rounded-xl text-sm font-semibold shadow-sm transition-all flex items-center justify-center gap-2",
|
||||
isCreatingProject
|
||||
? "bg-violet-600/50 text-violet-200 cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-violet-600 to-purple-600 text-white hover:from-violet-500 hover:to-purple-500 hover:shadow-lg hover:shadow-violet-500/25 active:scale-[0.98]"
|
||||
)}
|
||||
>
|
||||
{isCreatingProject ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Senaryo üretiliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={16} />
|
||||
Tweet'ten Video Oluştur
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Success */}
|
||||
<AnimatePresence>
|
||||
{createdProject && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="flex items-center gap-3 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/25"
|
||||
>
|
||||
<CheckCircle2 size={18} className="text-emerald-400 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-emerald-300">
|
||||
Proje oluşturuldu!
|
||||
</p>
|
||||
<p className="text-[11px] text-emerald-400/70 truncate">
|
||||
{createdProject.title}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onProjectCreated) onProjectCreated(createdProject.id);
|
||||
}}
|
||||
className="text-[11px] text-emerald-400 hover:text-emerald-300 font-medium transition-colors whitespace-nowrap"
|
||||
>
|
||||
Görüntüle →
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
src/components/layout/app-shell.tsx
Normal file
192
src/components/layout/app-shell.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" },
|
||||
{ href: "/dashboard/projects", icon: FolderOpen, label: "Projeler" },
|
||||
{ href: "/dashboard/templates", icon: LayoutGrid, label: "Şablonlar" },
|
||||
{ href: "/dashboard/settings", icon: Settings, label: "Ayarlar" },
|
||||
];
|
||||
|
||||
export function MobileNav() {
|
||||
const pathname = usePathname();
|
||||
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
||||
|
||||
return (
|
||||
<nav className="mobile-nav md:hidden">
|
||||
<div className="flex items-center justify-around px-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
localePath === item.href ||
|
||||
(item.href !== "/dashboard" && localePath.startsWith(item.href));
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-0.5 py-1.5 px-3 rounded-xl min-w-[4rem] transition-colors",
|
||||
isActive
|
||||
? "text-violet-400"
|
||||
: "text-[var(--color-text-muted)] active:text-[var(--color-text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<Icon size={22} strokeWidth={isActive ? 2.2 : 1.8} />
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-indicator"
|
||||
className="absolute -inset-2 rounded-xl bg-violet-500/10"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium tracking-wide">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesktopSidebar() {
|
||||
const pathname = usePathname();
|
||||
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex md:w-64 lg:w-72 flex-col h-screen sticky top-0 border-r border-[var(--color-border-faint)] bg-[var(--color-bg-deep)]">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-6 py-5 border-b border-[var(--color-border-faint)]">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 flex items-center justify-center shadow-lg">
|
||||
<Sparkles size={18} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-[family-name:var(--font-display)] text-lg font-bold tracking-tight text-[var(--color-text-primary)]">
|
||||
ContentGen
|
||||
</h1>
|
||||
<p className="text-[10px] text-[var(--color-text-muted)] uppercase tracking-widest">
|
||||
AI Studio
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
localePath === item.href ||
|
||||
(item.href !== "/dashboard" && localePath.startsWith(item.href));
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"relative flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
? "text-white bg-violet-500/12 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
|
||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)]"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="sidebar-active"
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-6 rounded-r-full bg-gradient-to-b from-violet-400 to-violet-600"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
<Icon size={18} strokeWidth={isActive ? 2.2 : 1.6} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Credits Card */}
|
||||
<div className="mx-3 mb-4 p-4 rounded-xl bg-gradient-to-br from-violet-500/8 to-cyan-400/5 border border-[var(--color-border-faint)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">Kalan Kredi</span>
|
||||
<span className="badge badge-violet">Pro</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-[family-name:var(--font-display)] text-[var(--color-text-primary)]">
|
||||
47
|
||||
</div>
|
||||
<div className="progress-bar mt-2">
|
||||
<div className="progress-bar-fill" style={{ width: "94%" }} />
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--color-text-ghost)] mt-1.5">
|
||||
50 kredilik planınızın 47'si kaldı
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar() {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 glass">
|
||||
<div className="flex items-center justify-between px-4 md:px-6 h-14 md:h-16">
|
||||
{/* Mobil logo */}
|
||||
<div className="flex items-center gap-2.5 md:hidden">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-cyan-400 flex items-center justify-center">
|
||||
<Sparkles size={16} className="text-white" />
|
||||
</div>
|
||||
<span className="font-[family-name:var(--font-display)] font-bold text-base">
|
||||
ContentGen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Spacer — desktop */}
|
||||
<div className="hidden md:block" />
|
||||
|
||||
{/* Sağ taraf */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Bildirim */}
|
||||
<button
|
||||
className="relative w-9 h-9 rounded-xl flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||
aria-label="Bildirimler"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-violet-500 rounded-full" />
|
||||
</button>
|
||||
|
||||
{/* Avatar */}
|
||||
<button
|
||||
className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-600 to-cyan-500 flex items-center justify-center text-white text-sm font-semibold shadow-md"
|
||||
aria-label="Profil"
|
||||
>
|
||||
H
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<DesktopSidebar />
|
||||
<div className="flex-1 flex flex-col min-h-screen">
|
||||
<TopBar />
|
||||
<main className="flex-1 px-[var(--spacing-page)] py-5 pb-safe page-enter">
|
||||
{children}
|
||||
</main>
|
||||
<MobileNav />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { ColorModeProvider, type ColorModeProviderProps } from "./color-mode";
|
||||
import { system } from "../../theme/theme";
|
||||
import { Toaster } from "./feedback/toaster";
|
||||
import TopLoader from "./top-loader";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import ReactQueryProvider from "@/provider/react-query-provider";
|
||||
|
||||
export function Provider(props: ColorModeProviderProps) {
|
||||
export function Provider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ReactQueryProvider>
|
||||
<ChakraProvider value={system}>
|
||||
<TopLoader />
|
||||
<ColorModeProvider {...props} />
|
||||
<Toaster />
|
||||
</ChakraProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</ReactQueryProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user