feat: Reordered Add Channel modal fields to URL → Monitoring Mode → For…
- "src/frontend/src/components/AddChannelModal.tsx" - "src/frontend/src/api/hooks/useChannels.ts" GSD-Task: S02/T01
This commit is contained in:
parent
6a5402ce8d
commit
ab7ab3634b
4 changed files with 237 additions and 199 deletions
|
|
@ -39,8 +39,6 @@ interface CreateChannelInput {
|
||||||
monitoringEnabled?: boolean;
|
monitoringEnabled?: boolean;
|
||||||
monitoringMode?: string;
|
monitoringMode?: string;
|
||||||
formatProfileId?: number;
|
formatProfileId?: number;
|
||||||
grabAll?: boolean;
|
|
||||||
grabAllOrder?: 'newest' | 'oldest';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new channel by URL (resolves metadata via backend). */
|
/** Create a new channel by URL (resolves metadata via backend). */
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
const [checkInterval, setCheckInterval] = useState('');
|
const [checkInterval, setCheckInterval] = useState('');
|
||||||
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
|
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
|
||||||
const [monitoringMode, setMonitoringMode] = useState<string>('all');
|
const [monitoringMode, setMonitoringMode] = useState<string>('all');
|
||||||
const [grabAll, setGrabAll] = useState(false);
|
|
||||||
const [grabAllOrder, setGrabAllOrder] = useState<'newest' | 'oldest'>('newest');
|
|
||||||
|
|
||||||
const createChannel = useCreateChannel();
|
const createChannel = useCreateChannel();
|
||||||
const { data: platformSettingsList } = usePlatformSettings();
|
const { data: platformSettingsList } = usePlatformSettings();
|
||||||
|
|
@ -82,16 +80,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
if (settings.defaultMonitoringMode) {
|
if (settings.defaultMonitoringMode) {
|
||||||
setMonitoringMode(settings.defaultMonitoringMode);
|
setMonitoringMode(settings.defaultMonitoringMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-fill grab-all defaults for YouTube
|
|
||||||
if (detectedPlatform === 'youtube') {
|
|
||||||
if (settings.grabAllEnabled) {
|
|
||||||
setGrabAll(true);
|
|
||||||
}
|
|
||||||
if (settings.grabAllOrder) {
|
|
||||||
setGrabAllOrder(settings.grabAllOrder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [detectedPlatform, platformSettingsList]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [detectedPlatform, platformSettingsList]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
|
@ -104,8 +92,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined,
|
checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined,
|
||||||
monitoringMode,
|
monitoringMode,
|
||||||
formatProfileId: formatProfileId ?? undefined,
|
formatProfileId: formatProfileId ?? undefined,
|
||||||
grabAll: detectedPlatform === 'youtube' ? grabAll : undefined,
|
|
||||||
grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (newChannel) => {
|
onSuccess: (newChannel) => {
|
||||||
|
|
@ -128,8 +114,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
setCheckInterval('');
|
setCheckInterval('');
|
||||||
setFormatProfileId(undefined);
|
setFormatProfileId(undefined);
|
||||||
setMonitoringMode('all');
|
setMonitoringMode('all');
|
||||||
setGrabAll(false);
|
|
||||||
setGrabAllOrder('newest');
|
|
||||||
createChannel.reset();
|
createChannel.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -142,7 +126,29 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Add Channel" open={open} onClose={handleClose}>
|
<Modal title="Add Channel" open={open} onClose={handleClose}>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} style={{ position: 'relative' }}>
|
||||||
|
{/* Loading overlay */}
|
||||||
|
{createChannel.isPending && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader size={28} style={{ animation: 'spin 1s linear infinite', color: 'var(--accent)' }} />
|
||||||
|
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 500, color: 'var(--text-primary)' }}>
|
||||||
|
Resolving channel…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* URL input */}
|
{/* URL input */}
|
||||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
<label
|
<label
|
||||||
|
|
@ -188,31 +194,34 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check interval (optional) */}
|
{/* Monitoring Mode — shown when platform detected */}
|
||||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
{detectedPlatform && (
|
||||||
<label
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
htmlFor="check-interval"
|
<label
|
||||||
style={{
|
htmlFor="monitoring-mode"
|
||||||
display: 'block',
|
style={{
|
||||||
marginBottom: 'var(--space-1)',
|
display: 'block',
|
||||||
fontSize: 'var(--font-size-sm)',
|
marginBottom: 'var(--space-1)',
|
||||||
fontWeight: 500,
|
fontSize: 'var(--font-size-sm)',
|
||||||
color: 'var(--text-secondary)',
|
fontWeight: 500,
|
||||||
}}
|
color: 'var(--text-secondary)',
|
||||||
>
|
}}
|
||||||
Check Interval (minutes)
|
>
|
||||||
</label>
|
Monitoring Mode
|
||||||
<input
|
</label>
|
||||||
id="check-interval"
|
<select
|
||||||
type="number"
|
id="monitoring-mode"
|
||||||
min={1}
|
value={monitoringMode}
|
||||||
value={checkInterval}
|
onChange={(e) => setMonitoringMode(e.target.value)}
|
||||||
onChange={(e) => setCheckInterval(e.target.value)}
|
disabled={createChannel.isPending}
|
||||||
placeholder="360 (default: 6 hours)"
|
style={{ width: '100%' }}
|
||||||
disabled={createChannel.isPending}
|
>
|
||||||
style={{ width: '100%' }}
|
<option value="all">Monitor All</option>
|
||||||
/>
|
<option value="future">Future Only</option>
|
||||||
</div>
|
<option value="none">None</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Format Profile (optional, shown when platform detected) */}
|
{/* Format Profile (optional, shown when platform detected) */}
|
||||||
{detectedPlatform && formatProfiles && formatProfiles.length > 0 && (
|
{detectedPlatform && formatProfiles && formatProfiles.length > 0 && (
|
||||||
|
|
@ -248,105 +257,31 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Monitoring Mode — shown when platform detected */}
|
{/* Check interval (optional) */}
|
||||||
{detectedPlatform && (
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
<label
|
||||||
<label
|
htmlFor="check-interval"
|
||||||
htmlFor="monitoring-mode"
|
style={{
|
||||||
style={{
|
display: 'block',
|
||||||
display: 'block',
|
marginBottom: 'var(--space-1)',
|
||||||
marginBottom: 'var(--space-1)',
|
fontSize: 'var(--font-size-sm)',
|
||||||
fontSize: 'var(--font-size-sm)',
|
fontWeight: 500,
|
||||||
fontWeight: 500,
|
color: 'var(--text-secondary)',
|
||||||
color: 'var(--text-secondary)',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Check Interval (minutes)
|
||||||
Monitoring Mode
|
</label>
|
||||||
</label>
|
<input
|
||||||
<select
|
id="check-interval"
|
||||||
id="monitoring-mode"
|
type="number"
|
||||||
value={monitoringMode}
|
min={1}
|
||||||
onChange={(e) => setMonitoringMode(e.target.value)}
|
value={checkInterval}
|
||||||
disabled={createChannel.isPending}
|
onChange={(e) => setCheckInterval(e.target.value)}
|
||||||
style={{ width: '100%' }}
|
placeholder="360 (default: 6 hours)"
|
||||||
>
|
disabled={createChannel.isPending}
|
||||||
<option value="all">Monitor All</option>
|
style={{ width: '100%' }}
|
||||||
<option value="future">Future Only</option>
|
/>
|
||||||
<option value="none">None</option>
|
</div>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Grab All — YouTube only */}
|
|
||||||
{detectedPlatform === 'youtube' && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: grabAll ? 'var(--space-3)' : 'var(--space-4)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 'var(--space-2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="grab-all"
|
|
||||||
type="checkbox"
|
|
||||||
checked={grabAll}
|
|
||||||
onChange={(e) => setGrabAll(e.target.checked)}
|
|
||||||
disabled={createChannel.isPending}
|
|
||||||
style={{ width: 'auto' }}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="grab-all"
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Grab all existing content?
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Download order — shown when grab-all enabled */}
|
|
||||||
{grabAll && (
|
|
||||||
<div style={{ marginBottom: 'var(--space-4)', paddingLeft: 'var(--space-5)' }}>
|
|
||||||
<label
|
|
||||||
htmlFor="grab-all-order"
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
marginBottom: 'var(--space-1)',
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Download Order
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="grab-all-order"
|
|
||||||
value={grabAllOrder}
|
|
||||||
onChange={(e) => setGrabAllOrder(e.target.value as 'newest' | 'oldest')}
|
|
||||||
disabled={createChannel.isPending}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
<option value="newest">Newest first</option>
|
|
||||||
<option value="oldest">Oldest first</option>
|
|
||||||
</select>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: 'var(--space-1) 0 0',
|
|
||||||
fontSize: 'var(--font-size-xs)',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back-catalog items will be enqueued at low priority.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error display */}
|
{/* Error display */}
|
||||||
{createChannel.isError && (
|
{createChannel.isError && (
|
||||||
|
|
@ -362,9 +297,12 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
color: 'var(--danger)',
|
color: 'var(--danger)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createChannel.error instanceof Error
|
{createChannel.error instanceof Error &&
|
||||||
? createChannel.error.message
|
createChannel.error.message.toLowerCase().includes('already exists')
|
||||||
: 'Failed to add channel'}
|
? 'This channel has already been added.'
|
||||||
|
: createChannel.error instanceof Error
|
||||||
|
? createChannel.error.message
|
||||||
|
: 'Failed to add channel'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,20 +62,27 @@ export function SkeletonChannelHeader() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
|
||||||
gap: 'var(--space-5)',
|
|
||||||
padding: 'var(--space-5)',
|
|
||||||
backgroundColor: 'var(--bg-card)',
|
backgroundColor: 'var(--bg-card)',
|
||||||
borderRadius: 'var(--radius-xl)',
|
borderRadius: 'var(--radius-xl)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
marginBottom: 'var(--space-6)',
|
marginBottom: 'var(--space-6)',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Skeleton width={80} height={80} borderRadius="50%" />
|
{/* Banner placeholder */}
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
<Skeleton width="100%" height={160} borderRadius="0" />
|
||||||
<Skeleton width={200} height={24} />
|
{/* Identity + controls */}
|
||||||
<Skeleton width={300} height={14} />
|
<div style={{ padding: 'var(--space-5)', paddingTop: 'var(--space-4)' }}>
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
|
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'center', marginBottom: 'var(--space-4)' }}>
|
||||||
|
<Skeleton width={64} height={64} borderRadius="50%" style={{ marginTop: -32, border: '3px solid var(--bg-card)', flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||||
|
<Skeleton width={200} height={24} />
|
||||||
|
<Skeleton width={120} height={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton width="80%" height={14} style={{ marginBottom: 'var(--space-2)' }} />
|
||||||
|
<Skeleton width="50%" height={14} style={{ marginBottom: 'var(--space-4)' }} />
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
|
||||||
<Skeleton width={120} height={32} borderRadius="var(--radius-md)" />
|
<Skeleton width={120} height={32} borderRadius="var(--radius-md)" />
|
||||||
<Skeleton width={120} height={32} borderRadius="var(--radius-md)" />
|
<Skeleton width={120} height={32} borderRadius="var(--radius-md)" />
|
||||||
<Skeleton width={100} height={32} borderRadius="var(--radius-md)" />
|
<Skeleton width={100} height={32} borderRadius="var(--radius-md)" />
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels';
|
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels';
|
||||||
import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent';
|
import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent';
|
||||||
|
|
@ -86,6 +87,14 @@ function formatRelativeTime(isoString: string | null): string {
|
||||||
return `${years}y ago`;
|
return `${years}y ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSubscriberCount(count: number | null): string | null {
|
||||||
|
if (count == null) return null;
|
||||||
|
if (count < 1000) return `${count}`;
|
||||||
|
if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K`;
|
||||||
|
if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
|
||||||
|
return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`;
|
||||||
|
}
|
||||||
|
|
||||||
const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [
|
const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [
|
||||||
{ value: 'all', label: 'All Content' },
|
{ value: 'all', label: 'All Content' },
|
||||||
{ value: 'future', label: 'Future Only' },
|
{ value: 'future', label: 'Future Only' },
|
||||||
|
|
@ -144,6 +153,7 @@ export function ChannelDetail() {
|
||||||
|
|
||||||
// ── Local state ──
|
// ── Local state ──
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showFullDescription, setShowFullDescription] = useState(false);
|
||||||
const [scanInProgress, setScanInProgress] = useState(false);
|
const [scanInProgress, setScanInProgress] = useState(false);
|
||||||
const [expandedPlaylists, setExpandedPlaylists] = useState<Set<number | 'uncategorized'>>(new Set());
|
const [expandedPlaylists, setExpandedPlaylists] = useState<Set<number | 'uncategorized'>>(new Set());
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
@ -833,6 +843,11 @@ export function ChannelDetail() {
|
||||||
{channel.name}
|
{channel.name}
|
||||||
</span>
|
</span>
|
||||||
<PlatformBadge platform={channel.platform} />
|
<PlatformBadge platform={channel.platform} />
|
||||||
|
{channel.subscriberCount != null && (
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||||
|
{formatSubscriberCount(channel.subscriberCount)} subscribers
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ width: 1, height: 20, backgroundColor: 'var(--border)', flexShrink: 0 }} />
|
<div style={{ width: 1, height: 20, backgroundColor: 'var(--border)', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
|
@ -907,62 +922,141 @@ export function ChannelDetail() {
|
||||||
<div
|
<div
|
||||||
ref={headerRef}
|
ref={headerRef}
|
||||||
style={{
|
style={{
|
||||||
padding: 'var(--space-5)',
|
|
||||||
backgroundColor: 'var(--bg-card)',
|
backgroundColor: 'var(--bg-card)',
|
||||||
borderRadius: 'var(--radius-xl)',
|
borderRadius: 'var(--radius-xl)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
marginBottom: 'var(--space-6)',
|
marginBottom: 'var(--space-6)',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Identity row */}
|
{/* Banner area */}
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'center', marginBottom: 'var(--space-5)' }}>
|
{channel.bannerUrl ? (
|
||||||
<img
|
<div
|
||||||
src={
|
|
||||||
channel.imageUrl ||
|
|
||||||
`https://ui-avatars.com/api/?name=${encodeURIComponent(channel.name)}&background=242731&color=e1e2e6&size=80`
|
|
||||||
}
|
|
||||||
alt={`${channel.name} avatar`}
|
|
||||||
style={{
|
style={{
|
||||||
width: 64,
|
width: '100%',
|
||||||
height: 64,
|
height: 180,
|
||||||
borderRadius: '50%',
|
backgroundImage: `url(${channel.bannerUrl})`,
|
||||||
objectFit: 'cover',
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
backgroundColor: 'var(--bg-hover)',
|
backgroundColor: 'var(--bg-hover)',
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
) : (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
|
<div
|
||||||
<h1 style={{ fontSize: 'var(--font-size-xl)', fontWeight: 600, color: 'var(--text-primary)', margin: 0 }}>
|
style={{
|
||||||
{channel.name}
|
width: '100%',
|
||||||
</h1>
|
height: 80,
|
||||||
<PlatformBadge platform={channel.platform} />
|
background: 'linear-gradient(135deg, var(--bg-hover) 0%, var(--bg-card) 100%)',
|
||||||
</div>
|
}}
|
||||||
<a
|
/>
|
||||||
href={channel.url}
|
)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 'var(--space-1)',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
marginTop: 2,
|
|
||||||
maxWidth: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
title={channel.url}
|
|
||||||
>
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{channel.url}</span>
|
|
||||||
<ExternalLink size={12} style={{ flexShrink: 0 }} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Control groups */}
|
<div style={{ padding: 'var(--space-5)', paddingTop: 0 }}>
|
||||||
|
{/* Identity row — avatar overlaps banner */}
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'flex-end', marginBottom: 'var(--space-4)' }}>
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
channel.imageUrl ||
|
||||||
|
`https://ui-avatars.com/api/?name=${encodeURIComponent(channel.name)}&background=242731&color=e1e2e6&size=80`
|
||||||
|
}
|
||||||
|
alt={`${channel.name} avatar`}
|
||||||
|
style={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: '50%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: -36,
|
||||||
|
border: '3px solid var(--bg-card)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ minWidth: 0, flex: 1, paddingTop: 'var(--space-3)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
|
||||||
|
<h1 style={{ fontSize: 'var(--font-size-xl)', fontWeight: 600, color: 'var(--text-primary)', margin: 0 }}>
|
||||||
|
{channel.name}
|
||||||
|
</h1>
|
||||||
|
<PlatformBadge platform={channel.platform} />
|
||||||
|
{channel.subscriberCount != null && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Users size={14} />
|
||||||
|
{formatSubscriberCount(channel.subscriberCount)} subscribers
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={channel.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
marginTop: 2,
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={channel.url}
|
||||||
|
>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{channel.url}</span>
|
||||||
|
<ExternalLink size={12} style={{ flexShrink: 0 }} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description — collapsible */}
|
||||||
|
{channel.description && (
|
||||||
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
...(
|
||||||
|
!showFullDescription && channel.description.length > 150
|
||||||
|
? { overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const }
|
||||||
|
: {}
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channel.description}
|
||||||
|
</p>
|
||||||
|
{channel.description.length > 150 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFullDescription((prev) => !prev)}
|
||||||
|
style={{
|
||||||
|
display: 'inline',
|
||||||
|
padding: 0,
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginTop: 'var(--space-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showFullDescription ? 'Show less' : 'Show more'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -1139,6 +1233,7 @@ export function ChannelDetail() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content table / playlist groups */}
|
{/* Content table / playlist groups */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue