Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user