'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 = { OWNER: 5, ADMIN: 4, EDITOR: 3, REVIEWER: 2, READONLY: 1, }; const ROLE_DESC: Record = { 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 {role}; } function SlotDayPills({ days }: { days: number[] }) { if (days.length === 0) { return Every day; } return ( {DAY_LABELS_SHORT.map((label, i) => ( {label} ))} ); } 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('EDITOR'); const [inviteError, setInviteError] = useState(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(null); const removeMut = useMutation({ mutationFn: (userId: string) => removeTeamMember(teamId, userId), onSuccess: () => { qc.invalidateQueries({ queryKey: ['team', teamId] }); setConfirmRemove(null); }, }); // ── Full refresh ────────────────────────────────────────────────────────── const [refreshResults, setRefreshResults] = useState>({}); 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>({}); 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(''); const [schedSlots, setSchedSlots] = useState(null); const [newSlotDays, setNewSlotDays] = useState([]); const [newSlotTime, setNewSlotTime] = useState('12:00'); const [schedSaved, setSchedSaved] = useState(false); const [showCanvaLink, setShowCanvaLink] = useState(null); const [showDeletedVideos, setShowDeletedVideos] = useState(null); // ── Conflict detection ──────────────────────────────────────────────────── const [cdEnabled, setCdEnabled] = useState(null); const [cdBatchSize, setCdBatchSize] = useState(null); const [cdMinAgeDays, setCdMinAgeDays] = useState(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 (
Loading…
); if (isError || !team) return (
Failed to load team settings.
); 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 (
Workspace

{team.name}

{/* ── Members ── */}

Team Members

People with access to this workspace.

{isAdmin && {team.members.map((m) => ( {isAdmin && ( )} ))}
Member Role}
{(m.user.name ?? m.user.email).charAt(0).toUpperCase()}
{m.user.name ?? '—'}
{m.user.email}
{m.userId === user?.id && you}
{canChangeRole(m) ? ( ) : ( )} {canRemove(m) && ( confirmRemove === m.userId ? (
Remove?
) : ( ) )}
{isAdmin && (

Invite member

setInviteEmail(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && inviteEmail.trim() && inviteMut.mutate()} />
{inviteError &&

{inviteError}

} {inviteMut.isSuccess &&

Invitation sent.

}
{editableRoles.map((r) => ( {r} — {ROLE_DESC[r]} ))}
)}
{/* ── Channels ── */}

Connected YouTube Channels

YouTube channels synced to this workspace.

{team.channels.length === 0 ? (

No channels connected yet.

) : (
{isAdmin && {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 ( {isAdmin && ( )} ); })}
Channel YouTube ID Connected}
{ch.name}
{ch.youtubeChannelId} {new Date(ch.createdAt).toLocaleDateString()} {result ? (
{result.total} videos, {result.playlistsForceSynced} playlists synced {result.orphansImported > 0 && ( {result.orphansImported} orphaned video{result.orphansImported !== 1 ? 's' : ''} imported )} {result.deleted > 0 && ( {result.deleted} deleted from YouTube removed )} {result.duplicateUploadsEntries > 0 && ( {result.duplicateUploadsEntries} duplicate playlist entries )} {result.orphansRejected > 0 && ( {result.orphansRejected} skipped — different channel )}
) : purgeResult ? (
{purgeResult.deleted === 0 ? All {purgeResult.checked} videos still on YouTube : {purgeResult.deleted} deleted video{purgeResult.deleted !== 1 ? 's' : ''} removed }
) : (
)}
)}
{/* ── Publishing Schedule ── */}

Publishing Schedule

Define the time slots when videos are allowed to publish. Used by the "Next free slot" feature in the video editor.

{/* Timezone */}
{isAdmin ? ( ) : ( {effectiveTimezone} )}
{/* Slot list */}
{effectiveSlots.length === 0 && (
No slots configured yet.
)} {effectiveSlots.map((slot, idx) => (
{slot.time} {isAdmin && ( )}
))}
{/* Add slot form */} {isAdmin && (
Add slot
{DAY_LABELS.map((label, i) => ( ))}
setNewSlotTime(e.target.value)} />
)} {/* Options */}
{isAdmin ? ( ) : ( Canva link: {effectiveShowCanva ? 'enabled' : 'disabled'} )}
{isAdmin ? ( ) : ( Deleted videos tab: {effectiveShowDeleted ? 'enabled' : 'disabled'} )}
{/* Save */} {isAdmin && (
{schedSaved && ( Saved )} {schedMut.isError && ( Failed to save. )}
)}
{/* ── Remote Conflict Detection ── */}

Remote Conflict Detection

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.

{isAdmin ? ( ) : ( Conflict detection: {effectiveCdEnabled ? 'enabled' : 'disabled'} )}
{effectiveCdEnabled && ( <>
{isAdmin ? ( setCdBatchSize(Number(e.target.value))} style={{ width: '8rem' }} /> ) : ( {effectiveCdBatchSize} )}
{isAdmin ? ( setCdMinAgeDays(Number(e.target.value))} style={{ width: '8rem' }} /> ) : ( {effectiveCdMinAgeDays} )}
)} {isAdmin && (
{cdSaved && ( Saved )} {cdMut.isError && ( Failed to save. )}
)}
); }