next build runs ESLint + full type-check and fails on errors (not just warnings) - the production build was broken. Fixes: unused imports/vars, any-typed diff/preference lookups replaced with the actual union/Record types, ternary-as-statement flagged by no-unused-expressions, unescaped JSX entities, a UserPreferences interface missing two fields the video editor already reads/writes at runtime, and an import of ColumnVisibilityState which @tanstack/react-table does not export (the real name is VisibilityState).
730 lines
30 KiB
TypeScript
730 lines
30 KiB
TypeScript
'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>
|
||
);
|
||
}
|