Files
youtube-studio-flow/frontend/src/app/(dashboard)/settings/page.tsx
T
devil b51c79e889 fix(frontend): resolve build-blocking lint and type errors
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).
2026-08-11 14:18:26 +02:00

730 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 &quot;Next free slot&quot; 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&apos;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 &quot;Deleted&quot; 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>
);
}