383 lines
17 KiB
TypeScript
383 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect } from 'react';
|
|
import { Menu, Search, Sun, Moon, Eye, LogOut, Loader2, CloudUpload, CheckCircle2, XCircle, Clock, Loader } from 'lucide-react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
import { useUIStore } from '@/store/useUIStore';
|
|
import { useAuthStore } from '@/store/useAuthStore';
|
|
import { useTheme } from '@/hooks/useTheme';
|
|
import apiClient from '@/lib/api-client';
|
|
import { fetchSavedViews, fetchTemplates, bulkPreview, bulkApply, fetchSyncQueueStatus, type BulkActionType, type BulkPreviewResponse } from '@/lib/api';
|
|
import Modal from './Modal';
|
|
import f from './FormField.module.css';
|
|
import styles from './Header.module.css';
|
|
|
|
// ─── Bulk Action Modal ────────────────────────────────────────────────────────
|
|
|
|
const ACTION_LABELS: Record<BulkActionType, string> = {
|
|
SET_PRIVACY: 'Set Privacy Status',
|
|
SET_TEMPLATE: 'Assign Template',
|
|
ADD_TAGS: 'Add Tags',
|
|
REMOVE_TAGS: 'Remove Tags',
|
|
SEARCH_REPLACE_TITLE: 'Search & Replace in Title',
|
|
};
|
|
|
|
function BulkModal({ onClose }: { onClose: () => void }) {
|
|
const [step, setStep] = useState<'configure' | 'preview'>('configure');
|
|
const [actionType, setActionType] = useState<BulkActionType>('SET_PRIVACY');
|
|
const [targetType, setTargetType] = useState<'all' | 'view'>('all');
|
|
const [savedViewId, setSavedViewId] = useState('');
|
|
const [payload, setPayload] = useState<Record<string, string>>({});
|
|
const [preview, setPreview] = useState<BulkPreviewResponse | null>(null);
|
|
|
|
const { data: savedViews = [] } = useQuery({ queryKey: ['saved-views'], queryFn: fetchSavedViews });
|
|
const { data: templates = [] } = useQuery({ queryKey: ['templates'], queryFn: fetchTemplates });
|
|
|
|
const buildPayload = () => {
|
|
if ((actionType === 'ADD_TAGS' || actionType === 'REMOVE_TAGS') && typeof payload.tags === 'string') {
|
|
return { ...payload, tags: payload.tags.split(',').map((t) => t.trim()).filter(Boolean) };
|
|
}
|
|
return payload;
|
|
};
|
|
|
|
const previewMut = useMutation({
|
|
mutationFn: () => bulkPreview({
|
|
type: actionType,
|
|
payload: buildPayload(),
|
|
savedViewId: targetType === 'view' ? savedViewId : undefined,
|
|
}),
|
|
onSuccess: (data) => { setPreview(data); setStep('preview'); },
|
|
});
|
|
|
|
const applyMut = useMutation({
|
|
mutationFn: () => bulkApply({
|
|
type: actionType,
|
|
payload: buildPayload(),
|
|
savedViewId: targetType === 'view' ? savedViewId : undefined,
|
|
}),
|
|
onSuccess: onClose,
|
|
});
|
|
|
|
const setP = (key: string, val: string) => setPayload((p) => ({ ...p, [key]: val }));
|
|
|
|
return (
|
|
<Modal title="Bulk Change" onClose={onClose} width={640}>
|
|
{step === 'configure' && (
|
|
<>
|
|
<div className={f.field}>
|
|
<label className={f.label}>Action</label>
|
|
<select className={f.select} value={actionType} onChange={(e) => { setActionType(e.target.value as BulkActionType); setPayload({}); }}>
|
|
{(Object.keys(ACTION_LABELS) as BulkActionType[]).map((k) => (
|
|
<option key={k} value={k}>{ACTION_LABELS[k]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{actionType === 'SET_PRIVACY' && (
|
|
<div className={f.field}>
|
|
<label className={f.label}>Privacy Status</label>
|
|
<select className={f.select} value={payload.privacyStatus ?? 'PUBLIC'} onChange={(e) => setP('privacyStatus', e.target.value)}>
|
|
<option value="PUBLIC">Public</option>
|
|
<option value="PRIVATE">Private</option>
|
|
<option value="UNLISTED">Unlisted</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
{actionType === 'SET_TEMPLATE' && (
|
|
<div className={f.field}>
|
|
<label className={f.label}>Template</label>
|
|
<select className={f.select} value={payload.templateId ?? ''} onChange={(e) => setP('templateId', e.target.value)}>
|
|
<option value="">— select —</option>
|
|
{templates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
|
</select>
|
|
</div>
|
|
)}
|
|
{(actionType === 'ADD_TAGS' || actionType === 'REMOVE_TAGS') && (
|
|
<div className={f.field}>
|
|
<label className={f.label}>Tags (comma-separated)</label>
|
|
<input className={f.input} value={payload.tags ?? ''} onChange={(e) => setP('tags', e.target.value)} placeholder="tag1, tag2, tag3" />
|
|
</div>
|
|
)}
|
|
{actionType === 'SEARCH_REPLACE_TITLE' && (
|
|
<div className={f.row}>
|
|
<div className={f.field}>
|
|
<label className={f.label}>Search</label>
|
|
<input className={f.input} value={payload.search ?? ''} onChange={(e) => setP('search', e.target.value)} placeholder="text to find" />
|
|
</div>
|
|
<div className={f.field}>
|
|
<label className={f.label}>Replace with</label>
|
|
<input className={f.input} value={payload.replace ?? ''} onChange={(e) => setP('replace', e.target.value)} placeholder="replacement" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className={f.field}>
|
|
<label className={f.label}>Target Videos</label>
|
|
<select className={f.select} value={targetType} onChange={(e) => setTargetType(e.target.value as 'all' | 'view')}>
|
|
<option value="all">All videos in team</option>
|
|
<option value="view">Saved view</option>
|
|
</select>
|
|
</div>
|
|
{targetType === 'view' && (
|
|
<div className={f.field}>
|
|
<label className={f.label}>Saved View</label>
|
|
<select className={f.select} value={savedViewId} onChange={(e) => setSavedViewId(e.target.value)}>
|
|
<option value="">— select —</option>
|
|
{savedViews.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{previewMut.isError && <p style={{ color: 'var(--color-error)', fontSize: 'var(--text-xs)' }}>Preview failed — check the backend logs.</p>}
|
|
<div className={f.actions}>
|
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
|
<button className="btn btn-primary" onClick={() => previewMut.mutate()} disabled={previewMut.isPending}>
|
|
{previewMut.isPending ? <Loader2 size={14} /> : <Eye size={14} />}
|
|
Preview Changes
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{step === 'preview' && preview && (
|
|
<>
|
|
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-muted)' }}>
|
|
<strong>{preview.count}</strong> video{preview.count !== 1 ? 's' : ''} will be affected by <strong>{ACTION_LABELS[preview.type]}</strong>.
|
|
</p>
|
|
<div style={{ maxHeight: 320, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--text-xs)' }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '1px solid var(--color-border)', background: 'var(--color-surface-offset)' }}>
|
|
<th style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--color-text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Video</th>
|
|
<th style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--color-text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Before</th>
|
|
<th style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--color-text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>After</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{preview.previews.slice(0, 50).map((p) => (
|
|
<tr key={p.videoId} style={{ borderBottom: '1px solid var(--color-divider)' }}>
|
|
<td style={{ padding: '8px 12px', color: 'var(--color-text-muted)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{p.before.title}
|
|
</td>
|
|
<td style={{ padding: '8px 12px', color: 'var(--color-text-faint)' }}>
|
|
{preview.type === 'SET_PRIVACY' && p.before.privacyStatus}
|
|
{preview.type === 'ADD_TAGS' && (p.before.tags ?? []).join(', ')}
|
|
{preview.type === 'REMOVE_TAGS' && (p.before.tags ?? []).join(', ')}
|
|
{preview.type === 'SEARCH_REPLACE_TITLE' && p.before.title}
|
|
{preview.type === 'SET_TEMPLATE' && (p.before.templateId ?? '—')}
|
|
</td>
|
|
<td style={{ padding: '8px 12px', color: 'var(--color-primary)', fontWeight: 600 }}>
|
|
{preview.type === 'SET_PRIVACY' && p.after.privacyStatus}
|
|
{preview.type === 'ADD_TAGS' && (p.after.tags ?? []).join(', ')}
|
|
{preview.type === 'REMOVE_TAGS' && (p.after.tags ?? []).join(', ')}
|
|
{preview.type === 'SEARCH_REPLACE_TITLE' && p.after.title}
|
|
{preview.type === 'SET_TEMPLATE' && (p.after.templateId ?? '—')}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{preview.previews.length > 50 && (
|
|
<p style={{ padding: '8px 12px', color: 'var(--color-text-faint)', fontSize: 'var(--text-xs)' }}>
|
|
+ {preview.previews.length - 50} more videos not shown
|
|
</p>
|
|
)}
|
|
</div>
|
|
{applyMut.isError && <p style={{ color: 'var(--color-error)', fontSize: 'var(--text-xs)' }}>Apply failed — check the backend logs.</p>}
|
|
<div className={f.actions}>
|
|
<button className="btn btn-secondary" onClick={() => setStep('configure')}>Back</button>
|
|
<button className="btn btn-primary" onClick={() => applyMut.mutate()} disabled={applyMut.isPending}>
|
|
{applyMut.isPending ? <Loader2 size={14} /> : null}
|
|
Apply to {preview.count} Video{preview.count !== 1 ? 's' : ''}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// ─── Sync Status Indicator ────────────────────────────────────────────────────
|
|
|
|
function SyncStatusIndicator() {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
const { data } = useQuery({
|
|
queryKey: ['syncQueueStatus'],
|
|
queryFn: fetchSyncQueueStatus,
|
|
refetchInterval: (query) => {
|
|
const d = query.state.data;
|
|
if (d?.active.length) return 3_000;
|
|
if (d?.waiting.length) return 8_000;
|
|
return 30_000;
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function handleClick(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
}
|
|
document.addEventListener('mousedown', handleClick);
|
|
return () => document.removeEventListener('mousedown', handleClick);
|
|
}, [open]);
|
|
|
|
const activeCount = data?.active.length ?? 0;
|
|
const waitingCount = data?.waiting.length ?? 0;
|
|
const pendingCount = activeCount + waitingCount;
|
|
const hasFailures = (data?.recentFailed.length ?? 0) > 0;
|
|
|
|
function formatRelative(iso: string | null) {
|
|
if (!iso) return '';
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
const mins = Math.floor(diff / 60_000);
|
|
if (mins < 1) return 'just now';
|
|
if (mins < 60) return `${mins}m ago`;
|
|
return `${Math.floor(mins / 60)}h ago`;
|
|
}
|
|
|
|
return (
|
|
<div className={styles.syncWrap} ref={ref}>
|
|
<button
|
|
className={`${styles.syncBtn} ${pendingCount > 0 ? styles.syncBtnActive : ''} ${hasFailures && pendingCount === 0 ? styles.syncBtnFailed : ''}`}
|
|
onClick={() => setOpen((o) => !o)}
|
|
title="YouTube sync queue"
|
|
>
|
|
{activeCount > 0
|
|
? <Loader size={18} className={styles.syncSpinner} />
|
|
: <CloudUpload size={18} />}
|
|
{pendingCount > 0 && <span className={styles.syncBadge}>{pendingCount}</span>}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className={styles.syncDropdown}>
|
|
<p className={styles.syncDropdownTitle}>YouTube sync queue</p>
|
|
|
|
{activeCount === 0 && waitingCount === 0 && data?.recentFailed.length === 0 && data?.recentCompleted.length === 0 && (
|
|
<p className={styles.syncEmpty}>Queue is empty.</p>
|
|
)}
|
|
|
|
{activeCount > 0 && (
|
|
<div className={styles.syncSection}>
|
|
<p className={styles.syncSectionLabel}>Processing</p>
|
|
{data!.active.map((j) => (
|
|
<div key={j.jobId} className={styles.syncRow}>
|
|
<Loader size={13} className={styles.syncSpinner} style={{ color: 'var(--color-primary)', flexShrink: 0 }} />
|
|
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{waitingCount > 0 && (
|
|
<div className={styles.syncSection}>
|
|
<p className={styles.syncSectionLabel}>Queued</p>
|
|
{data!.waiting.map((j) => (
|
|
<div key={j.jobId} className={styles.syncRow}>
|
|
<Clock size={13} style={{ color: 'var(--color-text-faint)', flexShrink: 0 }} />
|
|
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{(data?.recentFailed.length ?? 0) > 0 && (
|
|
<div className={styles.syncSection}>
|
|
<p className={styles.syncSectionLabel}>Failed</p>
|
|
{data!.recentFailed.map((j) => (
|
|
<div key={j.jobId} className={styles.syncRow}>
|
|
<XCircle size={13} style={{ color: 'var(--color-error)', flexShrink: 0 }} />
|
|
<div className={styles.syncRowInfo}>
|
|
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
|
|
{j.failedReason && <span className={styles.syncRowSub}>{j.failedReason}</span>}
|
|
</div>
|
|
<span className={styles.syncRowTime}>{formatRelative(j.failedAt)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{(data?.recentCompleted.length ?? 0) > 0 && (
|
|
<div className={styles.syncSection}>
|
|
<p className={styles.syncSectionLabel}>Completed</p>
|
|
{data!.recentCompleted.map((j) => (
|
|
<div key={j.jobId} className={styles.syncRow}>
|
|
<CheckCircle2 size={13} style={{ color: 'var(--color-success, #22c55e)', flexShrink: 0 }} />
|
|
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
|
|
<span className={styles.syncRowTime}>{formatRelative(j.completedAt)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Header ───────────────────────────────────────────────────────────────────
|
|
|
|
export default function Header() {
|
|
const { toggleSidebar } = useUIStore();
|
|
const { theme, toggleTheme } = useTheme();
|
|
const { user, clearAuth } = useAuthStore();
|
|
const router = useRouter();
|
|
const [bulkOpen, setBulkOpen] = useState(false);
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
|
|
async function handleLogout() {
|
|
try { await apiClient.post('/auth/logout'); } catch {}
|
|
clearAuth();
|
|
document.cookie = 'sf_session=; path=/; max-age=0';
|
|
router.replace('/login');
|
|
}
|
|
|
|
function handleSearch(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
if (e.key === 'Enter') {
|
|
const q = (e.target as HTMLInputElement).value.trim();
|
|
if (q) router.push(`/videos?search=${encodeURIComponent(q)}`);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<header className={styles.header}>
|
|
<div className={styles.left}>
|
|
<button onClick={toggleSidebar} className={styles.menuBtn}>
|
|
<Menu size={20} />
|
|
</button>
|
|
<label className={styles.search}>
|
|
<Search size={18} />
|
|
<input ref={searchRef} type="text" placeholder="Search videos… (Enter)" onKeyDown={handleSearch} />
|
|
</label>
|
|
</div>
|
|
|
|
<div className={styles.right}>
|
|
<SyncStatusIndicator />
|
|
<button className={styles.btnSecondary} onClick={toggleTheme}>
|
|
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
|
|
<span>Theme</span>
|
|
</button>
|
|
<button className={styles.btnSecondary} onClick={() => setBulkOpen(true)}>
|
|
<Eye size={18} />
|
|
<span>Bulk change</span>
|
|
</button>
|
|
{user && (
|
|
<div className={styles.userArea}>
|
|
<span className={styles.teamBadge}>{user.teamRole}</span>
|
|
<span className={styles.userName}>{user.name}</span>
|
|
<button onClick={handleLogout} className={styles.logoutBtn} title="Sign out">
|
|
<LogOut size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{bulkOpen && <BulkModal onClose={() => setBulkOpen(false)} />}
|
|
</>
|
|
);
|
|
}
|