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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user