219 lines
7.9 KiB
TypeScript
219 lines
7.9 KiB
TypeScript
'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>
|
|
);
|
|
}
|