feat: Created AddUrlModal with two-step preview/confirm flow, useAdhocD…

- "src/frontend/src/components/AddUrlModal.tsx"
- "src/frontend/src/api/hooks/useAdhocDownload.ts"
- "src/frontend/src/components/Sidebar.tsx"

GSD-Task: S01/T04
This commit is contained in:
jlightner 2026-04-04 05:15:28 +00:00
parent 22077e0eb1
commit 61105a74b0
3 changed files with 514 additions and 0 deletions

View file

@ -0,0 +1,52 @@
import { useMutation } from '@tanstack/react-query';
import { apiClient } from '../client';
import type { ContentType } from '@shared/types/index';
// ── Types ──
export interface UrlPreviewResponse {
title: string;
thumbnail: string | null;
duration: number | null;
platform: string;
channelName: string | null;
contentType: ContentType;
platformContentId: string;
url: string;
}
interface ConfirmRequest {
url: string;
title: string;
platform: string;
platformContentId: string;
contentType: string;
channelName?: string;
duration?: number | null;
thumbnailUrl?: string | null;
formatProfileId?: number;
}
interface ConfirmResponse {
contentItemId: number;
queueItemId: number;
status: string;
}
// ── Mutations ──
/** Resolve URL metadata via yt-dlp (preview step). */
export function useUrlPreview() {
return useMutation({
mutationFn: (url: string) =>
apiClient.post<UrlPreviewResponse>('/api/v1/download/url/preview', { url }),
});
}
/** Confirm ad-hoc download — creates content item and enqueues. */
export function useUrlConfirm() {
return useMutation({
mutationFn: (data: ConfirmRequest) =>
apiClient.post<ConfirmResponse>('/api/v1/download/url/confirm', data),
});
}

View file

@ -0,0 +1,425 @@
import { useState, useCallback } from 'react';
import { Modal } from './Modal';
import { useUrlPreview, useUrlConfirm } from '../api/hooks/useAdhocDownload';
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
import { useToast } from './Toast';
import { Loader, Download, Clock, Film, Music, Radio } from 'lucide-react';
import type { UrlPreviewResponse } from '../api/hooks/useAdhocDownload';
// ── Helpers ──
function formatDuration(seconds: number | null): string {
if (seconds === null || seconds <= 0) return '—';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
const PLATFORM_LABELS: Record<string, string> = {
youtube: 'YouTube',
soundcloud: 'SoundCloud',
generic: 'Generic',
};
function ContentTypeIcon({ type }: { type: string }) {
switch (type) {
case 'video':
return <Film size={14} aria-hidden="true" />;
case 'audio':
return <Music size={14} aria-hidden="true" />;
case 'livestream':
return <Radio size={14} aria-hidden="true" />;
default:
return <Film size={14} aria-hidden="true" />;
}
}
// ── Component ──
interface AddUrlModalProps {
open: boolean;
onClose: () => void;
}
export function AddUrlModal({ open, onClose }: AddUrlModalProps) {
const [url, setUrl] = useState('');
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
const [preview, setPreview] = useState<UrlPreviewResponse | null>(null);
const urlPreview = useUrlPreview();
const urlConfirm = useUrlConfirm();
const { data: formatProfiles } = useFormatProfiles();
const { toast } = useToast();
const resetForm = useCallback(() => {
setUrl('');
setFormatProfileId(undefined);
setPreview(null);
urlPreview.reset();
urlConfirm.reset();
}, [urlPreview, urlConfirm]);
const handleClose = useCallback(() => {
if (!urlPreview.isPending && !urlConfirm.isPending) {
resetForm();
onClose();
}
}, [urlPreview.isPending, urlConfirm.isPending, resetForm, onClose]);
const handlePreview = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!url.trim()) return;
urlConfirm.reset();
urlPreview.mutate(url.trim(), {
onSuccess: (data) => {
setPreview(data);
},
});
},
[url, urlPreview, urlConfirm],
);
const handleConfirm = useCallback(() => {
if (!preview) return;
urlConfirm.mutate(
{
url: preview.url,
title: preview.title,
platform: preview.platform,
platformContentId: preview.platformContentId,
contentType: preview.contentType,
channelName: preview.channelName ?? undefined,
duration: preview.duration,
thumbnailUrl: preview.thumbnail,
formatProfileId,
},
{
onSuccess: () => {
toast('Download queued', 'success');
resetForm();
onClose();
},
},
);
}, [preview, formatProfileId, urlConfirm, toast, resetForm, onClose]);
const handleBack = useCallback(() => {
setPreview(null);
urlPreview.reset();
urlConfirm.reset();
}, [urlPreview, urlConfirm]);
const isPending = urlPreview.isPending || urlConfirm.isPending;
return (
<Modal title="Download URL" open={open} onClose={handleClose} width={520}>
{!preview ? (
/* ── Step 1: URL input ── */
<form onSubmit={handlePreview}>
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="adhoc-url"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Paste a video or audio URL
</label>
<input
id="adhoc-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
required
disabled={isPending}
style={{ width: '100%' }}
autoFocus
/>
</div>
{/* Error display */}
{urlPreview.isError && (
<div
role="alert"
style={{
padding: 'var(--space-3)',
marginBottom: 'var(--space-4)',
backgroundColor: 'var(--danger-bg)',
border: '1px solid var(--danger)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
color: 'var(--danger)',
}}
>
{urlPreview.error instanceof Error
? urlPreview.error.message
: 'Failed to resolve URL'}
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
<button
type="button"
onClick={handleClose}
disabled={isPending}
style={{
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-hover)',
color: 'var(--text-secondary)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
}}
>
Cancel
</button>
<button
type="submit"
disabled={!url.trim() || isPending}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--accent)',
color: 'var(--text-inverse)',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
opacity: !url.trim() || isPending ? 0.6 : 1,
}}
>
{urlPreview.isPending && (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} aria-hidden="true" />
)}
{urlPreview.isPending ? 'Resolving…' : 'Preview'}
</button>
</div>
</form>
) : (
/* ── Step 2: Preview & confirm ── */
<div>
{/* Preview card */}
<div
style={{
display: 'flex',
gap: 'var(--space-4)',
padding: 'var(--space-4)',
backgroundColor: 'var(--bg-hover)',
borderRadius: 'var(--radius-md)',
marginBottom: 'var(--space-4)',
}}
>
{/* Thumbnail */}
{preview.thumbnail ? (
<img
src={preview.thumbnail}
alt=""
style={{
width: 160,
height: 90,
objectFit: 'cover',
borderRadius: 'var(--radius-sm)',
flexShrink: 0,
backgroundColor: 'var(--bg-card)',
}}
/>
) : (
<div
style={{
width: 160,
height: 90,
borderRadius: 'var(--radius-sm)',
flexShrink: 0,
backgroundColor: 'var(--bg-card)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<ContentTypeIcon type={preview.contentType} />
</div>
)}
{/* Metadata */}
<div style={{ flex: 1, minWidth: 0 }}>
<h3
style={{
margin: 0,
fontSize: 'var(--font-size-base)',
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{preview.title}
</h3>
{preview.channelName && (
<div
style={{
marginTop: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
color: 'var(--text-muted)',
}}
>
{preview.channelName}
</div>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
marginTop: 'var(--space-2)',
fontSize: 'var(--font-size-sm)',
color: 'var(--text-muted)',
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
}}
>
<ContentTypeIcon type={preview.contentType} />
{preview.contentType}
</span>
{preview.duration !== null && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
}}
>
<Clock size={14} aria-hidden="true" />
{formatDuration(preview.duration)}
</span>
)}
<span>{PLATFORM_LABELS[preview.platform] ?? preview.platform}</span>
</div>
</div>
</div>
{/* Format profile selector */}
{formatProfiles && formatProfiles.length > 0 && (
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="adhoc-format-profile"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Format Profile
</label>
<select
id="adhoc-format-profile"
value={formatProfileId ?? ''}
onChange={(e) =>
setFormatProfileId(e.target.value ? Number(e.target.value) : undefined)
}
disabled={isPending}
style={{ width: '100%' }}
>
<option value="">None (use default)</option>
{formatProfiles.map((fp) => (
<option key={fp.id} value={fp.id}>
{fp.name}
</option>
))}
</select>
</div>
)}
{/* Error display */}
{urlConfirm.isError && (
<div
role="alert"
style={{
padding: 'var(--space-3)',
marginBottom: 'var(--space-4)',
backgroundColor: 'var(--danger-bg)',
border: '1px solid var(--danger)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
color: 'var(--danger)',
}}
>
{urlConfirm.error instanceof Error
? urlConfirm.error.message
: 'Failed to start download'}
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
<button
type="button"
onClick={handleBack}
disabled={isPending}
style={{
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-hover)',
color: 'var(--text-secondary)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
}}
>
Back
</button>
<button
type="button"
onClick={handleConfirm}
disabled={isPending}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--accent)',
color: 'var(--text-inverse)',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
opacity: isPending ? 0.6 : 1,
}}
>
{urlConfirm.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} aria-hidden="true" />
) : (
<Download size={14} aria-hidden="true" />
)}
{urlConfirm.isPending ? 'Queuing…' : 'Download'}
</button>
</div>
</div>
)}
</Modal>
);
}

View file

@ -8,10 +8,12 @@ import {
Server,
ChevronLeft,
ChevronRight,
Link2,
} from 'lucide-react';
import { useState, useEffect } from 'react';
import { TubearrLogo } from './TubearrLogo';
import { useDownloadProgressConnection } from '../contexts/DownloadProgressContext';
import { AddUrlModal } from './AddUrlModal';
const NAV_ITEMS = [
{ to: '/', icon: Radio, label: 'Channels' },
@ -24,6 +26,7 @@ const NAV_ITEMS = [
export function Sidebar() {
const wsConnected = useDownloadProgressConnection();
const [showAddUrl, setShowAddUrl] = useState(false);
const [collapsed, setCollapsed] = useState(() => {
try {
return localStorage.getItem('tubearr-sidebar-collapsed') === 'true';
@ -129,6 +132,38 @@ export function Sidebar() {
))}
</div>
{/* Add URL button */}
<div
style={{
padding: collapsed ? 'var(--space-2) var(--space-2)' : 'var(--space-2) var(--space-3)',
borderTop: '1px solid var(--border)',
}}
>
<button
onClick={() => setShowAddUrl(true)}
title={collapsed ? 'Download URL' : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
width: '100%',
padding: `var(--space-2) ${collapsed ? 'var(--space-2)' : 'var(--space-3)'}`,
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--accent)',
color: 'var(--text-inverse)',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
border: 'none',
cursor: 'pointer',
justifyContent: collapsed ? 'center' : 'flex-start',
transition: 'opacity var(--transition-fast)',
}}
>
<Link2 size={16} style={{ flexShrink: 0 }} />
{!collapsed && <span>Add URL</span>}
</button>
</div>
{/* WebSocket connection status */}
<div
style={{
@ -163,6 +198,8 @@ export function Sidebar() {
</span>
)}
</div>
<AddUrlModal open={showAddUrl} onClose={() => setShowAddUrl(false)} />
</nav>
);
}