357 lines
17 KiB
TypeScript
357 lines
17 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { motion } from 'motion/react';
|
|
import {
|
|
VideoFilled,
|
|
ShareFilled,
|
|
} from '@/shared/icons/FilledIcons';
|
|
import { Button } from '@/shared/ui/button';
|
|
import { Input } from '@/shared/ui/input';
|
|
import { Textarea } from '@/shared/ui/textarea';
|
|
import { MOCK_CONTENT, INITIAL_CHANNELS } from '../data/distributionMocks';
|
|
|
|
// ─── 컴포넌트 ───
|
|
|
|
export default function DistributionPage() {
|
|
const [channels, setChannels] = useState(INITIAL_CHANNELS);
|
|
const [title, setTitle] = useState(MOCK_CONTENT.title);
|
|
const [description, setDescription] = useState(MOCK_CONTENT.description);
|
|
const [tags] = useState(MOCK_CONTENT.tags);
|
|
const [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now');
|
|
const [scheduleDate, setScheduleDate] = useState('');
|
|
const [scheduleHour, setScheduleHour] = useState(9);
|
|
const [scheduleMinute, setScheduleMinute] = useState(0);
|
|
const [schedulePeriod, setSchedulePeriod] = useState<'AM' | 'PM'>('AM');
|
|
const [isPublishing, setIsPublishing] = useState(false);
|
|
|
|
const selectedChannels = channels.filter(c => c.connected && c.selected);
|
|
const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published');
|
|
|
|
const toggleChannel = useCallback((id: string) => {
|
|
setChannels(prev => prev.map(c =>
|
|
c.id === id && c.connected ? { ...c, selected: !c.selected } : c
|
|
));
|
|
}, []);
|
|
|
|
const handlePublish = useCallback(() => {
|
|
setIsPublishing(true);
|
|
|
|
// 순차 발행 시뮬레이션
|
|
const selected = channels.filter(c => c.connected && c.selected);
|
|
selected.forEach((ch, i) => {
|
|
setTimeout(() => {
|
|
setChannels(prev => prev.map(c =>
|
|
c.id === ch.id ? { ...c, status: 'publishing' } : c
|
|
));
|
|
}, i * 1500);
|
|
|
|
setTimeout(() => {
|
|
setChannels(prev => prev.map(c =>
|
|
c.id === ch.id ? { ...c, status: 'published' } : c
|
|
));
|
|
if (i === selected.length - 1) setIsPublishing(false);
|
|
}, (i + 1) * 1500 + 500);
|
|
});
|
|
}, [channels]);
|
|
|
|
return (
|
|
<div className="pt-20 min-h-screen">
|
|
{/* Header */}
|
|
<div className="bg-brand-navy py-14 px-6 relative overflow-hidden">
|
|
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-brand-purple-vivid/10 blur-[120px]" />
|
|
<div className="max-w-5xl mx-auto relative">
|
|
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Content Distribution</p>
|
|
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3">
|
|
콘텐츠 배포
|
|
</h1>
|
|
<p className="text-purple-200/70 max-w-xl">
|
|
제작된 콘텐츠를 연결된 채널에 동시 배포합니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-5xl mx-auto px-6 py-10">
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
|
|
{/* Left: Content Preview + Meta */}
|
|
<div className="lg:col-span-1">
|
|
<h3 className="font-serif font-bold text-xl text-brand-navy mb-4">콘텐츠</h3>
|
|
|
|
{/* Video Preview */}
|
|
<div className="w-full aspect-[9/16] max-w-[220px] mx-auto rounded-2xl bg-gradient-to-br from-brand-tint-purple via-white to-brand-earth-bg border border-slate-200 flex flex-col items-center justify-center mb-6">
|
|
<VideoFilled size={32} className="text-brand-purple-soft mb-3" />
|
|
<p className="text-xs text-slate-500">{MOCK_CONTENT.aspectRatio}</p>
|
|
<p className="text-xs text-slate-400 mt-1">{MOCK_CONTENT.duration}</p>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<div className="mb-4">
|
|
<label className="text-xs font-medium text-slate-600 mb-1 block">제목</label>
|
|
<Input
|
|
value={title}
|
|
onChange={e => setTitle(e.target.value)}
|
|
className="w-full px-4 py-3 h-auto rounded-xl border-slate-200 text-sm text-slate-700 focus-visible:border-brand-purple-vivid focus-visible:ring-brand-purple-vivid/20"
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="mb-4">
|
|
<label className="text-xs font-medium text-slate-600 mb-1 block">설명</label>
|
|
<Textarea
|
|
value={description}
|
|
onChange={e => setDescription(e.target.value)}
|
|
rows={4}
|
|
className="w-full px-4 py-3 rounded-xl border-slate-200 text-sm text-slate-700 resize-y focus-visible:border-brand-purple-vivid focus-visible:ring-brand-purple-vivid/20"
|
|
/>
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
<div>
|
|
<label className="text-xs font-medium text-slate-600 mb-2 block">태그</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{tags.map(tag => (
|
|
<span
|
|
key={tag}
|
|
className="px-3 py-1 rounded-full bg-brand-tint-purple text-brand-purple-muted text-xs font-medium border border-brand-tint-lavender"
|
|
>
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Channel Selection + Schedule + Publish */}
|
|
<div className="lg:col-span-2">
|
|
{/* Channel Selection */}
|
|
<h3 className="font-serif font-bold text-xl text-brand-navy mb-4">배포 채널 선택</h3>
|
|
|
|
<div className="space-y-3 mb-8">
|
|
{channels.map(ch => {
|
|
const Icon = ch.icon;
|
|
return (
|
|
<motion.div
|
|
key={ch.id}
|
|
layout
|
|
className={`flex items-center gap-4 p-4 rounded-2xl border-2 transition-all ${
|
|
!ch.connected
|
|
? 'border-slate-100 bg-slate-50/50 opacity-50'
|
|
: ch.selected
|
|
? 'border-brand-purple-vivid bg-brand-tint-purple/20 shadow-[3px_4px_12px_rgba(108,92,231,0.08)]'
|
|
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.04)] hover:border-brand-tint-lavender'
|
|
}`}
|
|
>
|
|
{/* Checkbox */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => toggleChannel(ch.id)}
|
|
disabled={!ch.connected}
|
|
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 p-0 transition-all hover:bg-transparent ${
|
|
ch.selected && ch.connected
|
|
? 'border-brand-purple-vivid bg-brand-purple-vivid hover:bg-brand-purple-vivid'
|
|
: 'border-slate-300 bg-white'
|
|
} disabled:cursor-not-allowed`}
|
|
>
|
|
{ch.selected && ch.connected && (
|
|
<svg width="10" height="10" viewBox="0 0 14 14" fill="none">
|
|
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
)}
|
|
</Button>
|
|
|
|
{/* Icon */}
|
|
<div
|
|
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
|
|
style={{ backgroundColor: ch.bgColor }}
|
|
>
|
|
<Icon size={20} style={{ color: ch.brandColor }} />
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-brand-navy">{ch.name}</span>
|
|
{!ch.connected && (
|
|
<span className="px-2 py-1 rounded-full bg-brand-earth-bg text-brand-earth text-xs font-semibold border border-brand-earth-soft">
|
|
미연결
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-slate-400">{ch.format}</p>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="shrink-0">
|
|
{ch.status === 'publishing' && (
|
|
<div className="w-5 h-5 border-2 border-brand-purple-vivid border-t-transparent rounded-full animate-spin" />
|
|
)}
|
|
{ch.status === 'published' && (
|
|
<div className="w-6 h-6 rounded-full bg-brand-purple-vivid flex items-center justify-center">
|
|
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
|
|
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
{ch.status === 'failed' && (
|
|
<div className="w-6 h-6 rounded-full bg-brand-rose-bg flex items-center justify-center border border-brand-rose-soft">
|
|
<span className="text-brand-rose text-xs font-bold">!</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Schedule */}
|
|
<h3 className="font-serif font-bold text-xl text-brand-navy mb-4">배포 시간</h3>
|
|
<div className="flex gap-2 mb-4">
|
|
{([
|
|
{ key: 'now' as const, label: '즉시 배포' },
|
|
{ key: 'scheduled' as const, label: '예약 배포' },
|
|
]).map(opt => (
|
|
<Button
|
|
key={opt.key}
|
|
variant="ghost"
|
|
onClick={() => setScheduleMode(opt.key)}
|
|
className={`px-5 py-3 h-auto rounded-full text-sm font-medium transition-all ${
|
|
scheduleMode === opt.key
|
|
? 'bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white shadow-md hover:from-brand-purple hover:to-brand-purple-deep hover:text-white'
|
|
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
{scheduleMode === 'scheduled' && (
|
|
<div className="mb-6 space-y-4">
|
|
{/* Date */}
|
|
<div>
|
|
<label className="text-xs font-medium text-slate-600 mb-2 block">날짜</label>
|
|
<Input
|
|
type="date"
|
|
value={scheduleDate}
|
|
onChange={e => setScheduleDate(e.target.value)}
|
|
className="w-full px-4 py-3 h-auto rounded-xl border-slate-200 text-sm text-slate-700 focus-visible:border-brand-purple-vivid appearance-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Custom Time Picker */}
|
|
<div>
|
|
<label className="text-xs font-medium text-slate-600 mb-2 block">시간</label>
|
|
<div className="flex items-center gap-3">
|
|
{/* Hour */}
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
onClick={() => setScheduleHour(h => h <= 1 ? 12 : h - 1)}
|
|
className="w-8 h-8 rounded-lg bg-slate-50 border-slate-200 text-slate-500 hover:bg-slate-100"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
</Button>
|
|
<div className="w-12 h-10 rounded-xl bg-brand-tint-purple border border-brand-tint-lavender flex items-center justify-center text-lg font-semibold text-brand-navy">
|
|
{String(scheduleHour).padStart(2, '0')}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
onClick={() => setScheduleHour(h => h >= 12 ? 1 : h + 1)}
|
|
className="w-8 h-8 rounded-lg bg-slate-50 border-slate-200 text-slate-500 hover:bg-slate-100"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
</Button>
|
|
</div>
|
|
|
|
<span className="text-xl font-bold text-slate-300">:</span>
|
|
|
|
{/* Minute */}
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
onClick={() => setScheduleMinute(m => m <= 0 ? 55 : m - 5)}
|
|
className="w-8 h-8 rounded-lg bg-slate-50 border-slate-200 text-slate-500 hover:bg-slate-100"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
</Button>
|
|
<div className="w-12 h-10 rounded-xl bg-brand-tint-purple border border-brand-tint-lavender flex items-center justify-center text-lg font-semibold text-brand-navy">
|
|
{String(scheduleMinute).padStart(2, '0')}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
onClick={() => setScheduleMinute(m => m >= 55 ? 0 : m + 5)}
|
|
className="w-8 h-8 rounded-lg bg-slate-50 border-slate-200 text-slate-500 hover:bg-slate-100"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* AM/PM */}
|
|
<div className="flex rounded-xl overflow-hidden border border-slate-200">
|
|
{(['AM', 'PM'] as const).map(p => (
|
|
<Button
|
|
key={p}
|
|
variant="ghost"
|
|
onClick={() => setSchedulePeriod(p)}
|
|
className={`px-4 py-2 h-auto rounded-none text-sm font-medium transition-all ${
|
|
schedulePeriod === p
|
|
? 'bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white hover:from-brand-purple hover:to-brand-purple-deep hover:text-white'
|
|
: 'bg-white text-slate-500 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{p}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Publish Button */}
|
|
{!allPublished ? (
|
|
<Button
|
|
onClick={handlePublish}
|
|
disabled={selectedChannels.length === 0 || isPublishing}
|
|
className="w-full h-auto flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white text-sm font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{isPublishing ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
배포 중... ({selectedChannels.filter(c => c.status === 'published').length}/{selectedChannels.length})
|
|
</>
|
|
) : (
|
|
<>
|
|
<ShareFilled size={18} className="text-white" />
|
|
{selectedChannels.length}개 채널에 {scheduleMode === 'now' ? '즉시 배포' : '예약 배포'}
|
|
</>
|
|
)}
|
|
</Button>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="w-full py-6 rounded-2xl bg-brand-tint-purple border border-brand-tint-lavender text-center"
|
|
>
|
|
<div className="w-12 h-12 rounded-full bg-brand-purple-vivid flex items-center justify-center mx-auto mb-3">
|
|
<svg width="20" height="20" viewBox="0 0 14 14" fill="none">
|
|
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-lg font-semibold text-brand-navy mb-1">배포 완료</p>
|
|
<p className="text-sm text-brand-purple-muted">
|
|
{selectedChannels.length}개 채널에 성공적으로 배포되었습니다
|
|
</p>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|