Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)

This commit is contained in:
2026-08-11 12:27:44 +02:00
commit d5af006443
304 changed files with 74604 additions and 0 deletions
@@ -0,0 +1,729 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, AlertCircle, Trash2, Youtube, RefreshCw, CheckCircle2, Plus, Clock } from 'lucide-react';
import {
fetchTeam, inviteTeamMember, updateMemberRole, removeTeamMember, fullRefreshChannel,
purgeDeletedVideos,
fetchTeamSettings, updateTeamSettings,
type TeamMember, type FullRefreshResult, type PurgeResult, type PublishingSlot,
} from '@/lib/api';
import { useAuthStore } from '@/store/useAuthStore';
import f from '@/components/shared/FormField.module.css';
import styles from './page.module.css';
const ROLES = ['OWNER', 'ADMIN', 'EDITOR', 'REVIEWER', 'READONLY'] as const;
type Role = typeof ROLES[number];
const ROLE_PRIORITY: Record<Role, number> = {
OWNER: 5, ADMIN: 4, EDITOR: 3, REVIEWER: 2, READONLY: 1,
};
const ROLE_DESC: Record<Role, string> = {
OWNER: 'Full control, cannot be removed',
ADMIN: 'Manage members, channels, settings',
EDITOR: 'Create and edit content',
REVIEWER: 'View and comment only',
READONLY: 'View only',
};
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const DAY_LABELS_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const TIMEZONES = [
'UTC',
'Europe/London',
'Europe/Berlin',
'Europe/Paris',
'Europe/Madrid',
'Europe/Rome',
'Europe/Amsterdam',
'Europe/Stockholm',
'Europe/Warsaw',
'Europe/Zurich',
'Europe/Vienna',
'Europe/Prague',
'Europe/Helsinki',
'Europe/Lisbon',
'Europe/Athens',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Phoenix',
'America/Toronto',
'America/Vancouver',
'America/Sao_Paulo',
'America/Mexico_City',
'America/Buenos_Aires',
'Asia/Tokyo',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Singapore',
'Asia/Kolkata',
'Asia/Dubai',
'Asia/Istanbul',
'Australia/Sydney',
'Australia/Melbourne',
'Pacific/Auckland',
'Pacific/Honolulu',
'Africa/Johannesburg',
'Africa/Cairo',
];
function RoleBadge({ role }: { role: Role }) {
return <span className={`${styles.roleBadge} ${styles[`role${role}`]}`}>{role}</span>;
}
function SlotDayPills({ days }: { days: number[] }) {
if (days.length === 0) {
return <span className={styles.slotEveryDay}>Every day</span>;
}
return (
<span className={styles.slotDays}>
{DAY_LABELS_SHORT.map((label, i) => (
<span key={i} className={`${styles.slotDay} ${days.includes(i) ? styles.slotDayActive : ''}`}>
{label}
</span>
))}
</span>
);
}
export default function SettingsPage() {
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
const teamId = user?.teamId ?? '';
const myRole = (user?.teamRole ?? 'READONLY') as Role;
const isAdmin = ROLE_PRIORITY[myRole] >= ROLE_PRIORITY.ADMIN;
const { data: team, isLoading, isError } = useQuery({
queryKey: ['team', teamId],
queryFn: () => fetchTeam(teamId),
enabled: !!teamId,
});
const { data: settings } = useQuery({
queryKey: ['teamSettings', teamId],
queryFn: () => fetchTeamSettings(teamId),
enabled: !!teamId,
});
// ── Invite ────────────────────────────────────────────────────────────────
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<Role>('EDITOR');
const [inviteError, setInviteError] = useState<string | null>(null);
const inviteMut = useMutation({
mutationFn: () => inviteTeamMember(teamId, inviteEmail.trim(), inviteRole),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['team', teamId] });
setInviteEmail('');
setInviteError(null);
},
onError: (e: unknown) => setInviteError((e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Invite failed'),
});
// ── Role change ───────────────────────────────────────────────────────────
const roleMut = useMutation({
mutationFn: ({ userId, role }: { userId: string; role: string }) =>
updateMemberRole(teamId, userId, role),
onSuccess: () => qc.invalidateQueries({ queryKey: ['team', teamId] }),
});
// ── Remove member ─────────────────────────────────────────────────────────
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
const removeMut = useMutation({
mutationFn: (userId: string) => removeTeamMember(teamId, userId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['team', teamId] });
setConfirmRemove(null);
},
});
// ── Full refresh ──────────────────────────────────────────────────────────
const [refreshResults, setRefreshResults] = useState<Record<string, FullRefreshResult>>({});
const refreshMut = useMutation({
mutationFn: (channelId: string) => fullRefreshChannel(channelId),
onSuccess: (data, channelId) => {
setRefreshResults((prev) => ({ ...prev, [channelId]: data }));
qc.invalidateQueries({ queryKey: ['videos'] });
qc.invalidateQueries({ queryKey: ['playlists'] });
},
});
// ── Purge deleted ────────────────────────────────────────────────────────
const [purgeResults, setPurgeResults] = useState<Record<string, PurgeResult>>({});
const purgeMut = useMutation({
mutationFn: (channelId: string) => purgeDeletedVideos(channelId),
onSuccess: (data, channelId) => {
setPurgeResults((prev) => ({ ...prev, [channelId]: data }));
qc.invalidateQueries({ queryKey: ['videos'] });
},
});
// ── Publishing schedule ───────────────────────────────────────────────────
const [schedTimezone, setSchedTimezone] = useState<string>('');
const [schedSlots, setSchedSlots] = useState<PublishingSlot[] | null>(null);
const [newSlotDays, setNewSlotDays] = useState<number[]>([]);
const [newSlotTime, setNewSlotTime] = useState('12:00');
const [schedSaved, setSchedSaved] = useState(false);
const [showCanvaLink, setShowCanvaLink] = useState<boolean | null>(null);
const [showDeletedVideos, setShowDeletedVideos] = useState<boolean | null>(null);
// ── Conflict detection ────────────────────────────────────────────────────
const [cdEnabled, setCdEnabled] = useState<boolean | null>(null);
const [cdBatchSize, setCdBatchSize] = useState<number | null>(null);
const [cdMinAgeDays, setCdMinAgeDays] = useState<number | null>(null);
const [cdSaved, setCdSaved] = useState(false);
// Initialise local schedule state from server data once loaded
const effectiveTimezone = schedTimezone || settings?.timezone || 'UTC';
const effectiveSlots: PublishingSlot[] = schedSlots ?? settings?.publishingSchedule ?? [];
const effectiveShowCanva = showCanvaLink ?? settings?.showCanvaLink ?? false;
const effectiveShowDeleted = showDeletedVideos ?? settings?.showDeletedVideos ?? false;
const effectiveCdEnabled = cdEnabled ?? settings?.conflictDetectionEnabled ?? false;
const effectiveCdBatchSize = cdBatchSize ?? settings?.conflictDetectionBatchSize ?? 50;
const effectiveCdMinAgeDays = cdMinAgeDays ?? settings?.conflictDetectionMinAgeDays ?? 7;
const schedMut = useMutation({
mutationFn: () => updateTeamSettings(teamId, { timezone: effectiveTimezone, publishingSchedule: effectiveSlots, showCanvaLink: effectiveShowCanva, showDeletedVideos: effectiveShowDeleted }),
onSuccess: (data) => {
qc.setQueryData(['teamSettings', teamId], data);
setSchedTimezone('');
setSchedSlots(null);
setShowCanvaLink(null);
setShowDeletedVideos(null);
setSchedSaved(true);
setTimeout(() => setSchedSaved(false), 3000);
},
});
const cdMut = useMutation({
mutationFn: () => updateTeamSettings(teamId, {
conflictDetectionEnabled: effectiveCdEnabled,
conflictDetectionBatchSize: effectiveCdBatchSize,
conflictDetectionMinAgeDays: effectiveCdMinAgeDays,
}),
onSuccess: (data) => {
qc.setQueryData(['teamSettings', teamId], data);
setCdEnabled(null);
setCdBatchSize(null);
setCdMinAgeDays(null);
setCdSaved(true);
setTimeout(() => setCdSaved(false), 3000);
},
});
function addSlot() {
if (!newSlotTime) return;
const slot: PublishingSlot = { days: newSlotDays, time: newSlotTime };
setSchedSlots([...effectiveSlots, slot]);
setNewSlotDays([]);
setNewSlotTime('12:00');
}
function removeSlot(idx: number) {
setSchedSlots(effectiveSlots.filter((_, i) => i !== idx));
}
function toggleNewDay(day: number) {
setNewSlotDays((prev) =>
prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day],
);
}
// ── Render ────────────────────────────────────────────────────────────────
if (isLoading) return (
<div className={styles.state}><Loader2 size={20} className={styles.spin} /> Loading</div>
);
if (isError || !team) return (
<div className={styles.state}><AlertCircle size={18} /> Failed to load team settings.</div>
);
const editableRoles = ROLES.filter((r) => r !== 'OWNER');
const canChangeRole = (member: TeamMember) =>
isAdmin && member.role !== 'OWNER' && member.userId !== user?.id;
const canRemove = (member: TeamMember) =>
isAdmin && member.role !== 'OWNER' && member.userId !== user?.id;
return (
<div className={styles.container}>
<header className={styles.header}>
<div>
<span className={styles.eyebrow}>Workspace</span>
<h1>{team.name}</h1>
</div>
</header>
{/* ── Members ── */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Team Members</h2>
<p className={styles.sectionDesc}>People with access to this workspace.</p>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Member</th>
<th>Role</th>
{isAdmin && <th />}
</tr>
</thead>
<tbody>
{team.members.map((m) => (
<tr key={m.userId}>
<td>
<div className={styles.memberCell}>
<div className={styles.avatar}>{(m.user.name ?? m.user.email).charAt(0).toUpperCase()}</div>
<div>
<div className={styles.memberName}>{m.user.name ?? '—'}</div>
<div className={styles.memberEmail}>{m.user.email}</div>
</div>
{m.userId === user?.id && <span className={styles.youBadge}>you</span>}
</div>
</td>
<td>
{canChangeRole(m) ? (
<select
className={`${f.input} ${styles.roleSelect}`}
value={m.role}
onChange={(e) => roleMut.mutate({ userId: m.userId, role: e.target.value })}
disabled={roleMut.isPending}
>
{editableRoles.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
) : (
<RoleBadge role={m.role as Role} />
)}
</td>
{isAdmin && (
<td className={styles.actionsCell}>
{canRemove(m) && (
confirmRemove === m.userId ? (
<div className={styles.confirmDelete}>
<span className={styles.confirmText}>Remove?</span>
<button
className={styles.confirmYes}
onClick={() => removeMut.mutate(m.userId)}
disabled={removeMut.isPending}
>
{removeMut.isPending ? <Loader2 size={11} /> : 'Yes'}
</button>
<button className={styles.confirmNo} onClick={() => setConfirmRemove(null)}>No</button>
</div>
) : (
<button className={styles.trashBtn} onClick={() => setConfirmRemove(m.userId)}>
<Trash2 size={14} />
</button>
)
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{isAdmin && (
<div className={styles.inviteBox}>
<h3 className={styles.inviteTitle}>Invite member</h3>
<div className={styles.inviteRow}>
<input
className={f.input}
type="email"
placeholder="email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && inviteEmail.trim() && inviteMut.mutate()}
/>
<select
className={`${f.input} ${styles.inviteRoleSelect}`}
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as Role)}
style={{ width: 'auto' }}
>
{editableRoles.map((r) => (
<option key={r} value={r} title={ROLE_DESC[r]}>{r}</option>
))}
</select>
<button
className="btn btn-primary"
onClick={() => inviteMut.mutate()}
disabled={!inviteEmail.trim() || inviteMut.isPending}
>
{inviteMut.isPending ? <Loader2 size={14} /> : null}
Invite
</button>
</div>
{inviteError && <p className={styles.inviteError}>{inviteError}</p>}
{inviteMut.isSuccess && <p className={styles.inviteSuccess}>Invitation sent.</p>}
<div className={styles.roleHints}>
{editableRoles.map((r) => (
<span key={r} className={styles.roleHint}><strong>{r}</strong> {ROLE_DESC[r]}</span>
))}
</div>
</div>
)}
</section>
{/* ── Channels ── */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Connected YouTube Channels</h2>
<p className={styles.sectionDesc}>YouTube channels synced to this workspace.</p>
{team.channels.length === 0 ? (
<div className={styles.empty}>
<Youtube size={32} className={styles.emptyIcon} />
<p>No channels connected yet.</p>
</div>
) : (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Channel</th>
<th>YouTube ID</th>
<th>Connected</th>
{isAdmin && <th />}
</tr>
</thead>
<tbody>
{team.channels.map((ch) => {
const isRefreshing = refreshMut.isPending && refreshMut.variables === ch.id;
const isPurging = purgeMut.isPending && purgeMut.variables === ch.id;
const result = refreshResults[ch.id];
const purgeResult = purgeResults[ch.id];
return (
<tr key={ch.id}>
<td>
<div className={styles.channelCell}>
<div className={styles.channelIcon}><Youtube size={15} /></div>
<span className={styles.channelName}>{ch.name}</span>
</div>
</td>
<td>
<code className={styles.channelId}>{ch.youtubeChannelId}</code>
</td>
<td className={styles.channelDate}>
{new Date(ch.createdAt).toLocaleDateString()}
</td>
{isAdmin && (
<td className={styles.channelActionsCell}>
{result ? (
<div className={styles.refreshResult}>
<CheckCircle2 size={13} className={styles.refreshOk} />
<span>{result.total} videos, {result.playlistsForceSynced} playlists synced</span>
{result.orphansImported > 0 && (
<span className={styles.refreshInfo}>{result.orphansImported} orphaned video{result.orphansImported !== 1 ? 's' : ''} imported</span>
)}
{result.deleted > 0 && (
<span className={styles.refreshWarn} title={result.deletedTitles.join('\n')}>{result.deleted} deleted from YouTube removed</span>
)}
{result.duplicateUploadsEntries > 0 && (
<span className={styles.refreshWarn}>{result.duplicateUploadsEntries} duplicate playlist entries</span>
)}
{result.orphansRejected > 0 && (
<span className={styles.refreshWarn}>{result.orphansRejected} skipped different channel</span>
)}
<button className={styles.refreshDismiss} onClick={() => setRefreshResults((p) => { const n = { ...p }; delete n[ch.id]; return n; })}>×</button>
</div>
) : purgeResult ? (
<div className={styles.refreshResult}>
<CheckCircle2 size={13} className={styles.refreshOk} />
{purgeResult.deleted === 0
? <span>All {purgeResult.checked} videos still on YouTube</span>
: <span className={styles.refreshWarn} title={purgeResult.deletedTitles.join('\n')}>{purgeResult.deleted} deleted video{purgeResult.deleted !== 1 ? 's' : ''} removed</span>
}
<button className={styles.refreshDismiss} onClick={() => setPurgeResults((p) => { const n = { ...p }; delete n[ch.id]; return n; })}>×</button>
</div>
) : (
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<button
className={`btn btn-secondary ${styles.refreshBtn}`}
onClick={() => refreshMut.mutate(ch.id)}
disabled={refreshMut.isPending || purgeMut.isPending}
title="Force-reimport all videos and playlists for this channel"
>
{isRefreshing ? <Loader2 size={13} className={styles.spin} /> : <RefreshCw size={13} />}
Full Refresh
</button>
<button
className={`btn btn-secondary ${styles.refreshBtn}`}
onClick={() => purgeMut.mutate(ch.id)}
disabled={purgeMut.isPending || refreshMut.isPending}
title="Check for videos deleted on YouTube and remove them locally"
>
{isPurging ? <Loader2 size={13} className={styles.spin} /> : <Trash2 size={13} />}
Purge deleted
</button>
</div>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
{/* ── Publishing Schedule ── */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Publishing Schedule</h2>
<p className={styles.sectionDesc}>
Define the time slots when videos are allowed to publish. Used by the "Next free slot" feature in the video editor.
</p>
<div className={styles.scheduleBox}>
{/* Timezone */}
<div className={styles.scheduleRow}>
<label className={`${f.label} ${styles.scheduleLabel}`}>Timezone</label>
{isAdmin ? (
<select
className={`${f.input} ${styles.tzSelect}`}
value={effectiveTimezone}
onChange={(e) => setSchedTimezone(e.target.value)}
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
) : (
<span className={styles.scheduleValue}>{effectiveTimezone}</span>
)}
</div>
{/* Slot list */}
<div className={styles.slotList}>
{effectiveSlots.length === 0 && (
<div className={styles.slotEmpty}>No slots configured yet.</div>
)}
{effectiveSlots.map((slot, idx) => (
<div key={idx} className={styles.slotRow}>
<SlotDayPills days={slot.days} />
<span className={styles.slotTime}>
<Clock size={12} />
{slot.time}
</span>
{isAdmin && (
<button className={styles.slotDelete} onClick={() => removeSlot(idx)} title="Remove slot">
×
</button>
)}
</div>
))}
</div>
{/* Add slot form */}
{isAdmin && (
<div className={styles.addSlotForm}>
<span className={`${f.label} ${styles.scheduleLabel}`}>Add slot</span>
<div className={styles.addSlotRow}>
<div className={styles.dayCheckboxes}>
<label className={styles.everyDayToggle}>
<input
type="checkbox"
checked={newSlotDays.length === 0}
onChange={() => setNewSlotDays([])}
/>
Every day
</label>
{DAY_LABELS.map((label, i) => (
<label key={i} className={`${styles.dayCheckbox} ${newSlotDays.includes(i) ? styles.dayCheckboxActive : ''}`}>
<input
type="checkbox"
checked={newSlotDays.includes(i)}
onChange={() => toggleNewDay(i)}
style={{ display: 'none' }}
/>
{label}
</label>
))}
</div>
<input
className={`${f.input} ${styles.timeInput}`}
type="time"
value={newSlotTime}
onChange={(e) => setNewSlotTime(e.target.value)}
/>
<button
className="btn btn-secondary"
onClick={addSlot}
disabled={!newSlotTime}
>
<Plus size={13} />
Add
</button>
</div>
</div>
)}
{/* Options */}
<div className={styles.scheduleRow}>
{isAdmin ? (
<label className={styles.toggleRow}>
<input
type="checkbox"
checked={effectiveShowCanva}
onChange={(e) => setShowCanvaLink(e.target.checked)}
/>
<span>Show Canva link in video editor</span>
<span className={styles.toggleDesc}>
Adds a link next to YouTube Studio that searches Canva for the video's Game Title.
</span>
</label>
) : (
<span className={styles.scheduleValue}>
Canva link: {effectiveShowCanva ? 'enabled' : 'disabled'}
</span>
)}
</div>
<div className={styles.scheduleRow}>
{isAdmin ? (
<label className={styles.toggleRow}>
<input
type="checkbox"
checked={effectiveShowDeleted}
onChange={(e) => setShowDeletedVideos(e.target.checked)}
/>
<span>Show deleted videos tab</span>
<span className={styles.toggleDesc}>
Reveals a "Deleted" tab in the video list for videos removed from YouTube. They are kept locally for 30 days before being permanently deleted.
</span>
</label>
) : (
<span className={styles.scheduleValue}>
Deleted videos tab: {effectiveShowDeleted ? 'enabled' : 'disabled'}
</span>
)}
</div>
{/* Save */}
{isAdmin && (
<div className={styles.schedSaveRow}>
<button
className="btn btn-primary"
onClick={() => schedMut.mutate()}
disabled={schedMut.isPending}
>
{schedMut.isPending ? <Loader2 size={13} /> : null}
Save schedule
</button>
{schedSaved && (
<span className={styles.schedSaved}>
<CheckCircle2 size={13} /> Saved
</span>
)}
{schedMut.isError && (
<span className={styles.schedError}>Failed to save.</span>
)}
</div>
)}
</div>
</section>
{/* ── Remote Conflict Detection ── */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Remote Conflict Detection</h2>
<p className={styles.sectionDesc}>
Periodically checks YouTube for out-of-band edits to your videos. When a mismatch is found,
the video is flagged with a lint error so you can review the diff and either accept the remote
version or push the local one. Costs 1 quota unit per video checked.
</p>
<div className={styles.scheduleBox}>
<div className={styles.scheduleRow}>
{isAdmin ? (
<label className={styles.toggleRow}>
<input
type="checkbox"
checked={effectiveCdEnabled}
onChange={(e) => setCdEnabled(e.target.checked)}
/>
<span>Enable scheduled conflict detection</span>
<span className={styles.toggleDesc}>
Runs on the schedule configured by your server administrator.
</span>
</label>
) : (
<span className={styles.scheduleValue}>
Conflict detection: {effectiveCdEnabled ? 'enabled' : 'disabled'}
</span>
)}
</div>
{effectiveCdEnabled && (
<>
<div className={styles.scheduleRow}>
<label className={`${f.label} ${styles.scheduleLabel}`}>Videos per run</label>
{isAdmin ? (
<input
className={f.input}
type="number"
min={1}
max={500}
value={effectiveCdBatchSize}
onChange={(e) => setCdBatchSize(Number(e.target.value))}
style={{ width: '8rem' }}
/>
) : (
<span className={styles.scheduleValue}>{effectiveCdBatchSize}</span>
)}
</div>
<div className={styles.scheduleRow}>
<label className={`${f.label} ${styles.scheduleLabel}`}>Only check videos older than (days)</label>
{isAdmin ? (
<input
className={f.input}
type="number"
min={0}
value={effectiveCdMinAgeDays}
onChange={(e) => setCdMinAgeDays(Number(e.target.value))}
style={{ width: '8rem' }}
/>
) : (
<span className={styles.scheduleValue}>{effectiveCdMinAgeDays}</span>
)}
</div>
</>
)}
{isAdmin && (
<div className={styles.schedSaveRow}>
<button
className="btn btn-primary"
onClick={() => cdMut.mutate()}
disabled={cdMut.isPending}
>
{cdMut.isPending ? <Loader2 size={13} /> : null}
Save
</button>
{cdSaved && (
<span className={styles.schedSaved}>
<CheckCircle2 size={13} /> Saved
</span>
)}
{cdMut.isError && (
<span className={styles.schedError}>Failed to save.</span>
)}
</div>
)}
</div>
</section>
</div>
);
}