Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
fetchCollaboratorVideos, createCollaborator, updateCollaborator,
|
||||
type Collaborator, type CollaboratorPayload,
|
||||
} from '@/lib/api';
|
||||
import UsagePanel from './UsagePanel';
|
||||
import Modal from './Modal';
|
||||
import f from './FormField.module.css';
|
||||
|
||||
export const PLATFORMS = [
|
||||
{ key: 'youtubeLink', prefix: 'https://www.youtube.com/@', display: 'youtube.com/@', label: 'YouTube', urlField: true },
|
||||
{ key: 'twitchLink', prefix: 'https://twitch.tv/', display: 'twitch.tv/', label: 'Twitch', urlField: true },
|
||||
{ key: 'instagramLink', prefix: 'https://instagram.com/', display: 'instagram.com/', label: 'Instagram', urlField: true },
|
||||
{ key: 'tiktokLink', prefix: 'https://tiktok.com/@', display: 'tiktok.com/@', label: 'TikTok', urlField: true },
|
||||
{ key: 'twitterLink', prefix: 'https://x.com/', display: 'x.com/', label: 'Twitter / X', urlField: true },
|
||||
{ key: 'blueskyLink', prefix: 'https://bsky.app/profile/', display: 'bsky.app/profile/', label: 'Bluesky', urlField: true },
|
||||
{ key: 'discordHandle', prefix: '', display: '', label: 'Discord', urlField: false },
|
||||
] as const;
|
||||
|
||||
export type PlatformKey = typeof PLATFORMS[number]['key'];
|
||||
|
||||
export function toUsername(url: string | null | undefined, prefix: string): string {
|
||||
if (!url) return '';
|
||||
return prefix ? url.replace(prefix, '').replace(/^\/+/, '') : url;
|
||||
}
|
||||
|
||||
export function toValue(username: string, prefix: string): string {
|
||||
if (!username.trim()) return '';
|
||||
return prefix ? prefix + username.trim().replace(/^\/+/, '') : username.trim();
|
||||
}
|
||||
|
||||
const EMPTY_FORM: CollaboratorPayload = {
|
||||
name: '',
|
||||
youtubeLink: '', twitchLink: '', instagramLink: '', tiktokLink: '',
|
||||
twitterLink: '', blueskyLink: '', discordHandle: '',
|
||||
aliases: [], notes: '', active: true,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
initial?: Collaborator;
|
||||
onClose: () => void;
|
||||
/** Called after a successful create, with the new collaborator id */
|
||||
onCreated?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function CollaboratorModal({ initial, onClose, onCreated }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [form, setForm] = useState<CollaboratorPayload>(
|
||||
initial
|
||||
? {
|
||||
name: initial.name,
|
||||
youtubeLink: initial.youtubeLink ?? '',
|
||||
twitchLink: initial.twitchLink ?? '',
|
||||
instagramLink: initial.instagramLink ?? '',
|
||||
tiktokLink: initial.tiktokLink ?? '',
|
||||
twitterLink: initial.twitterLink ?? '',
|
||||
blueskyLink: initial.blueskyLink ?? '',
|
||||
discordHandle: initial.discordHandle ?? '',
|
||||
aliases: initial.aliases,
|
||||
notes: initial.notes ?? '',
|
||||
active: initial.active,
|
||||
}
|
||||
: EMPTY_FORM,
|
||||
);
|
||||
const [aliasInput, setAliasInput] = useState(initial?.aliases.join(', ') ?? '');
|
||||
|
||||
const { data: usageVideos = [], isLoading: usageLoading } = useQuery({
|
||||
queryKey: ['collaborator-videos', initial?.id],
|
||||
queryFn: () => fetchCollaboratorVideos(initial!.id),
|
||||
enabled: !!initial,
|
||||
});
|
||||
|
||||
const [usernames, setUsernames] = useState<Record<PlatformKey, string>>(() => {
|
||||
const src = initial ?? {} as Collaborator;
|
||||
return Object.fromEntries(
|
||||
PLATFORMS.map((p) => [p.key, toUsername(src[p.key as keyof Collaborator] as string | null, p.prefix)])
|
||||
) as Record<PlatformKey, string>;
|
||||
});
|
||||
|
||||
const setUsername = (key: PlatformKey, val: string) =>
|
||||
setUsernames((prev) => ({ ...prev, [key]: val }));
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const platformValues = Object.fromEntries(
|
||||
PLATFORMS.map((p) => [p.key, toValue(usernames[p.key], p.prefix)])
|
||||
);
|
||||
const payload: CollaboratorPayload = {
|
||||
...form,
|
||||
...platformValues,
|
||||
aliases: aliasInput.split(',').map((a) => a.trim()).filter(Boolean),
|
||||
};
|
||||
return initial ? updateCollaborator(initial.id, payload) : createCollaborator(payload);
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
qc.invalidateQueries({ queryKey: ['collaborators'] });
|
||||
if (!initial && onCreated) onCreated((saved as Collaborator).id);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const set = (key: keyof CollaboratorPayload, val: unknown) =>
|
||||
setForm((p) => ({ ...p, [key]: val }));
|
||||
|
||||
return (
|
||||
<Modal title={initial ? 'Edit Collaborator' : 'Add Collaborator'} onClose={onClose} width={600}>
|
||||
<div className={f.field}>
|
||||
<label className={f.label}>Name</label>
|
||||
<input
|
||||
className={f.input}
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
placeholder="Full name or channel name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{([
|
||||
PLATFORMS.slice(0, 2),
|
||||
PLATFORMS.slice(2, 4),
|
||||
PLATFORMS.slice(4, 6),
|
||||
PLATFORMS.slice(6, 7),
|
||||
] as const).map((row, ri) => (
|
||||
<div key={ri} className={f.row}>
|
||||
{row.map((p) => (
|
||||
<div key={p.key} className={f.field}>
|
||||
<label className={f.label}>
|
||||
{p.label}
|
||||
{p.key === 'discordHandle' && (
|
||||
<span style={{ fontWeight: 400, color: 'var(--color-text-faint)' }}> (handle)</span>
|
||||
)}
|
||||
</label>
|
||||
{p.display ? (
|
||||
<div className={f.inputPrefix}>
|
||||
<span className={f.prefix}>{p.display}</span>
|
||||
<input
|
||||
className={f.input}
|
||||
value={usernames[p.key]}
|
||||
onChange={(e) =>
|
||||
setUsername(
|
||||
p.key,
|
||||
e.target.value.replace(
|
||||
new RegExp(`^https?://(www\\.)?${p.display.replace(/\//g, '\\/')}`, 'i'),
|
||||
'',
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className={f.input}
|
||||
value={usernames[p.key]}
|
||||
onChange={(e) => setUsername(p.key, e.target.value)}
|
||||
placeholder="username"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className={f.field}>
|
||||
<label className={f.label}>Aliases (comma-separated)</label>
|
||||
<input
|
||||
className={f.input}
|
||||
value={aliasInput}
|
||||
onChange={(e) => setAliasInput(e.target.value)}
|
||||
placeholder="alias1, alias2"
|
||||
/>
|
||||
</div>
|
||||
<div className={f.field}>
|
||||
<label className={f.label}>Notes</label>
|
||||
<textarea
|
||||
className={f.textarea}
|
||||
value={form.notes ?? ''}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
placeholder="Optional notes about this collaborator"
|
||||
/>
|
||||
</div>
|
||||
<label className={f.toggle}>
|
||||
<input type="checkbox" checked={form.active} onChange={(e) => set('active', e.target.checked)} />
|
||||
Active
|
||||
</label>
|
||||
{initial && (
|
||||
<UsagePanel
|
||||
isLoading={usageLoading}
|
||||
groups={[
|
||||
{
|
||||
heading: 'Videos',
|
||||
items: usageVideos.map((v) => ({ id: v.id, label: v.title, href: `/videos/${v.id}` })),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{mutation.isError && (
|
||||
<p style={{ color: 'var(--color-error)', fontSize: 'var(--text-xs)' }}>
|
||||
Save failed — check the backend logs.
|
||||
</p>
|
||||
)}
|
||||
<div className={f.actions}>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || !form.name.trim()}
|
||||
>
|
||||
{mutation.isPending ? <Loader2 size={14} /> : null}
|
||||
{initial ? 'Save Changes' : 'Add Collaborator'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user