Compare commits
10 commits
master
...
milestone/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecbfd5afa7 | ||
|
|
cca396a7e8 | ||
|
|
49ac76c379 | ||
|
|
538f9ec69b | ||
|
|
3355326526 | ||
|
|
a0906f3cdb | ||
|
|
91b0b74dcb | ||
|
|
76e6b9727e | ||
|
|
ac8905ca38 | ||
|
|
c057b6a286 |
25 changed files with 1840 additions and 1071 deletions
|
|
@ -1,22 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -52,3 +52,4 @@ venv/
|
|||
target/
|
||||
vendor/
|
||||
config/
|
||||
media/
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ services:
|
|||
ports:
|
||||
- "8989:8989"
|
||||
volumes:
|
||||
- ./config:/config
|
||||
- tubearr-config:/config
|
||||
- ./media:/media
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
|
@ -18,3 +18,6 @@ services:
|
|||
retries: 3
|
||||
start_period: 15s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
tubearr-config:
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ describe('DownloadService', () => {
|
|||
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||
expect(args).toContain('-f');
|
||||
const fIdx = args.indexOf('-f');
|
||||
expect(args[fIdx + 1]).toBe('bestvideo[height<=1080]+bestaudio/best[height<=1080]');
|
||||
expect(args[fIdx + 1]).toBe('bestvideo[height<=1080]+bestaudio/bestvideo[height<=1080]*+bestaudio/best[height<=1080]/bestvideo+bestaudio/best');
|
||||
expect(args).toContain('--merge-output-format');
|
||||
const moIdx = args.indexOf('--merge-output-format');
|
||||
expect(args[moIdx + 1]).toBe('mkv');
|
||||
|
|
@ -431,7 +431,7 @@ describe('DownloadService', () => {
|
|||
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||
expect(args).toContain('-f');
|
||||
const fIdx = args.indexOf('-f');
|
||||
expect(args[fIdx + 1]).toBe('best');
|
||||
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best');
|
||||
});
|
||||
|
||||
it('falls back to -f "bestaudio" for audio when no format profile', async () => {
|
||||
|
|
@ -652,7 +652,7 @@ describe('DownloadService', () => {
|
|||
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||
const fIdx = args.indexOf('-f');
|
||||
expect(fIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best');
|
||||
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/bestvideo*+bestaudio/best');
|
||||
// Should default to mp4 merge format when containerFormat is null
|
||||
expect(args).toContain('--merge-output-format');
|
||||
const moIdx = args.indexOf('--merge-output-format');
|
||||
|
|
@ -695,7 +695,7 @@ describe('DownloadService', () => {
|
|||
|
||||
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||
const fIdx = args.indexOf('-f');
|
||||
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best');
|
||||
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/bestvideo*+bestaudio/best');
|
||||
const moIdx = args.indexOf('--merge-output-format');
|
||||
expect(args[moIdx + 1]).toBe('mkv');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -92,6 +92,81 @@ export async function getContentByChannelId(
|
|||
return rows.map(mapRow);
|
||||
}
|
||||
|
||||
/** Optional filters for channel content queries. */
|
||||
export interface ChannelContentFilters {
|
||||
search?: string;
|
||||
status?: ContentStatus;
|
||||
contentType?: ContentType;
|
||||
sortBy?: 'title' | 'publishedAt' | 'status' | 'duration' | 'fileSize' | 'downloadedAt';
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated content items for a channel with optional search, filter, and sort.
|
||||
* Returns items and total count for pagination.
|
||||
*/
|
||||
export async function getChannelContentPaginated(
|
||||
db: Db,
|
||||
channelId: number,
|
||||
filters?: ChannelContentFilters,
|
||||
page = 1,
|
||||
pageSize = 50
|
||||
): Promise<PaginatedContentResult> {
|
||||
const conditions = [eq(contentItems.channelId, channelId)];
|
||||
|
||||
if (filters?.search) {
|
||||
conditions.push(like(contentItems.title, `%${filters.search}%`));
|
||||
}
|
||||
if (filters?.status) {
|
||||
conditions.push(eq(contentItems.status, filters.status));
|
||||
}
|
||||
if (filters?.contentType) {
|
||||
conditions.push(eq(contentItems.contentType, filters.contentType));
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// Count total matching records
|
||||
const countResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(contentItems)
|
||||
.where(whereClause);
|
||||
|
||||
const total = Number(countResult[0].count);
|
||||
|
||||
// Build sort order
|
||||
const sortCol = resolveSortColumn(filters?.sortBy);
|
||||
const sortDir = filters?.sortDirection === 'asc' ? sortCol : desc(sortCol);
|
||||
|
||||
// Fetch paginated results
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(contentItems)
|
||||
.where(whereClause)
|
||||
.orderBy(sortDir, desc(contentItems.id))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items: rows.map(mapRow),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve sort column name to Drizzle column reference. */
|
||||
function resolveSortColumn(sortBy?: string) {
|
||||
switch (sortBy) {
|
||||
case 'title': return contentItems.title;
|
||||
case 'publishedAt': return contentItems.publishedAt;
|
||||
case 'status': return contentItems.status;
|
||||
case 'duration': return contentItems.duration;
|
||||
case 'fileSize': return contentItems.fileSize;
|
||||
case 'downloadedAt': return contentItems.downloadedAt;
|
||||
default: return contentItems.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a specific content item exists for a channel. Returns the item or null. */
|
||||
export async function getContentByPlatformContentId(
|
||||
db: Db,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { Channels } from './pages/Channels';
|
||||
import { ChannelDetail } from './pages/ChannelDetail';
|
||||
import { Library } from './pages/Library';
|
||||
|
|
@ -37,8 +38,10 @@ function AuthenticatedLayout() {
|
|||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/*" element={<AuthenticatedLayout />} />
|
||||
</Routes>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="/*" element={<AuthenticatedLayout />} />
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { apiClient } from '../client';
|
||||
import { queueKeys } from './useQueue';
|
||||
import type { ContentItem } from '@shared/types/index';
|
||||
import type { ApiResponse } from '@shared/types/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '@shared/types/api';
|
||||
|
||||
// ── Collect Types ──
|
||||
|
||||
|
|
@ -13,15 +13,29 @@ export interface CollectResult {
|
|||
items: Array<{ contentItemId: number; status: string }>;
|
||||
}
|
||||
|
||||
// ── Channel Content Filter Types ──
|
||||
|
||||
export interface ChannelContentFilters {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
contentType?: string;
|
||||
sortBy?: string;
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// ── Query Keys ──
|
||||
|
||||
export const contentKeys = {
|
||||
byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
|
||||
byChannelPaginated: (channelId: number, filters: ChannelContentFilters) =>
|
||||
['content', 'channel', channelId, 'paginated', filters] as const,
|
||||
};
|
||||
|
||||
// ── Queries ──
|
||||
|
||||
/** Fetch content items for a specific channel. */
|
||||
/** Fetch content items for a specific channel (legacy — all items). */
|
||||
export function useChannelContent(channelId: number) {
|
||||
return useQuery({
|
||||
queryKey: contentKeys.byChannel(channelId),
|
||||
|
|
@ -35,6 +49,30 @@ export function useChannelContent(channelId: number) {
|
|||
});
|
||||
}
|
||||
|
||||
/** Fetch paginated content items for a channel with search/filter/sort. */
|
||||
export function useChannelContentPaginated(channelId: number, filters: ChannelContentFilters) {
|
||||
return useQuery({
|
||||
queryKey: contentKeys.byChannelPaginated(channelId, filters),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.page) params.set('page', String(filters.page));
|
||||
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.contentType) params.set('contentType', filters.contentType);
|
||||
if (filters.sortBy) params.set('sortBy', filters.sortBy);
|
||||
if (filters.sortDirection) params.set('sortDirection', filters.sortDirection);
|
||||
|
||||
const response = await apiClient.get<PaginatedResponse<ContentItem>>(
|
||||
`/api/v1/channel/${channelId}/content?${params.toString()}`,
|
||||
);
|
||||
return response;
|
||||
},
|
||||
enabled: channelId > 0,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mutations ──
|
||||
|
||||
/** Enqueue a content item for download. Returns 202 with queue item. */
|
||||
|
|
|
|||
273
src/frontend/src/components/ContentCard.tsx
Normal file
273
src/frontend/src/components/ContentCard.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { DownloadProgressBar } from './DownloadProgressBar';
|
||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||
import type { ContentItem } from '@shared/types/index';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null) 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')}`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoString: string | null): string {
|
||||
if (!isoString) return '';
|
||||
const delta = Date.now() - Date.parse(isoString);
|
||||
if (delta < 0) return 'just now';
|
||||
const seconds = Math.floor(delta / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
return `${Math.floor(months / 12)}y ago`;
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
interface ContentCardProps {
|
||||
item: ContentItem;
|
||||
selected: boolean;
|
||||
onSelect: (id: number) => void;
|
||||
onToggleMonitored: (id: number, monitored: boolean) => void;
|
||||
onDownload: (id: number) => void;
|
||||
}
|
||||
|
||||
export function ContentCard({ item, selected, onSelect, onToggleMonitored, onDownload }: ContentCardProps) {
|
||||
const progress = useDownloadProgress(item.id);
|
||||
const duration = formatDuration(item.duration);
|
||||
const published = formatRelativeTime(item.publishedAt);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
backgroundColor: selected ? 'var(--bg-selected)' : 'var(--bg-card-solid)',
|
||||
border: selected ? '1px solid var(--accent)' : '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all var(--transition-fast)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => onSelect(item.id)}
|
||||
onMouseEnter={(e) => {
|
||||
if (!selected) e.currentTarget.style.borderColor = 'var(--border-light)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!selected) e.currentTarget.style.borderColor = 'var(--border)';
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div style={{ position: 'relative', aspectRatio: '16/9', backgroundColor: 'var(--bg-input)' }}>
|
||||
{item.thumbnailUrl ? (
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{item.contentType === 'audio' ? <Music size={32} /> : <Film size={32} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration badge */}
|
||||
{duration && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 500,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Selection checkbox */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
left: 6,
|
||||
opacity: selected ? 1 : 0,
|
||||
transition: 'opacity var(--transition-fast)',
|
||||
}}
|
||||
className="card-checkbox"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Select ${item.title}`}
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
cursor: 'pointer',
|
||||
accentColor: 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Download progress overlay */}
|
||||
{item.status === 'downloading' && progress && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}>
|
||||
<DownloadProgressBar progress={progress} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div style={{ padding: 'var(--space-3)' }}>
|
||||
{/* Title */}
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'block',
|
||||
fontWeight: 500,
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.4,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
|
||||
{/* Meta row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 'var(--space-2)',
|
||||
gap: 'var(--space-2)',
|
||||
}}
|
||||
>
|
||||
<StatusBadge status={item.status} />
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-xs)' }}>
|
||||
{published}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-1)',
|
||||
marginTop: 'var(--space-2)',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleMonitored(item.id, !item.monitored);
|
||||
}}
|
||||
title={item.monitored ? 'Unmonitor' : 'Monitor'}
|
||||
aria-label={item.monitored ? `Unmonitor ${item.title}` : `Monitor ${item.title}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: item.monitored ? 'var(--accent)' : 'var(--text-muted)',
|
||||
transition: 'color var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
{item.monitored ? <Bookmark size={14} fill="currentColor" /> : <BookmarkPlus size={14} />}
|
||||
</button>
|
||||
|
||||
{item.status !== 'downloaded' && item.status !== 'downloading' && item.status !== 'queued' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload(item.id);
|
||||
}}
|
||||
title="Download"
|
||||
aria-label={`Download ${item.title}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-muted)',
|
||||
transition: 'color var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Open on YouTube"
|
||||
aria-label={`Open ${item.title} on YouTube`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-muted)',
|
||||
transition: 'color var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -112,6 +112,7 @@ export function Modal({ title, open, onClose, children, width = 480 }: ModalProp
|
|||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
animation: 'modal-enter 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
|
|
@ -137,24 +138,7 @@ export function Modal({ title, open, onClose, children, width = 480 }: ModalProp
|
|||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-muted)',
|
||||
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-primary)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--bg-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
className="btn-icon"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -78,26 +78,11 @@ export function Sidebar() {
|
|||
</NavLink>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="btn-icon"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 'var(--space-1)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
|
||||
marginLeft: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-secondary)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--bg-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
|
|
|
|||
259
src/frontend/src/components/Skeleton.tsx
Normal file
259
src/frontend/src/components/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* Skeleton loading placeholder components.
|
||||
* Uses the .skeleton CSS class for shimmer animation.
|
||||
*/
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
borderRadius?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/** Generic skeleton block. */
|
||||
export function Skeleton({ width = '100%', height = 16, borderRadius, style }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
borderRadius: borderRadius ?? 'var(--radius-md)',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for a table row. */
|
||||
export function SkeletonRow({ columns = 6 }: { columns?: number }) {
|
||||
return (
|
||||
<tr>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<td key={i}>
|
||||
<Skeleton
|
||||
width={i === 0 ? 32 : i === 1 ? '70%' : '50%'}
|
||||
height={i === 0 ? 32 : 14}
|
||||
borderRadius={i === 0 ? '50%' : undefined}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for the full content table. */
|
||||
export function SkeletonTable({ rows = 8, columns = 6 }: { rows?: number; columns?: number }) {
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-4)' }}>
|
||||
<table style={{ width: '100%' }}>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<SkeletonRow key={i} columns={columns} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for the channel detail header. */
|
||||
export function SkeletonChannelHeader() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 'var(--space-5)',
|
||||
padding: 'var(--space-5)',
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
border: '1px solid var(--border)',
|
||||
marginBottom: 'var(--space-6)',
|
||||
}}
|
||||
>
|
||||
<Skeleton width={80} height={80} borderRadius="50%" />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
<Skeleton width={200} height={24} />
|
||||
<Skeleton width={300} height={14} />
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
|
||||
<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)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for the queue list page. */
|
||||
export function SkeletonQueueList({ rows = 6 }: { rows?: number }) {
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-4)' }}>
|
||||
{/* Tab row placeholder */}
|
||||
<div style={{ display: 'flex', gap: '2px', marginBottom: 'var(--space-4)' }}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} width={80} height={32} borderRadius="var(--radius-md) var(--radius-md) 0 0" />
|
||||
))}
|
||||
</div>
|
||||
<SkeletonTable rows={rows} columns={7} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for the library page. */
|
||||
export function SkeletonLibrary({ rows = 8 }: { rows?: number }) {
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-4)' }}>
|
||||
{/* Search + filters placeholder */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-4)', marginBottom: 'var(--space-4)' }}>
|
||||
<Skeleton width={240} height={36} borderRadius="var(--radius-md)" />
|
||||
<Skeleton width={120} height={36} borderRadius="var(--radius-md)" />
|
||||
<Skeleton width={100} height={36} borderRadius="var(--radius-md)" />
|
||||
<Skeleton width={130} height={36} borderRadius="var(--radius-md)" />
|
||||
</div>
|
||||
<SkeletonTable rows={rows} columns={9} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for the activity page. */
|
||||
export function SkeletonActivityList({ rows = 6 }: { rows?: number }) {
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-4)' }}>
|
||||
{/* Tab row placeholder */}
|
||||
<div style={{ display: 'flex', gap: '2px', marginBottom: 'var(--space-4)' }}>
|
||||
<Skeleton width={80} height={32} borderRadius="var(--radius-md) var(--radius-md) 0 0" />
|
||||
<Skeleton width={80} height={32} borderRadius="var(--radius-md) var(--radius-md) 0 0" />
|
||||
</div>
|
||||
<SkeletonTable rows={rows} columns={5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for the system page. */
|
||||
export function SkeletonSystem() {
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-4)' }}>
|
||||
<Skeleton width={120} height={24} style={{ marginBottom: 'var(--space-6)' }} />
|
||||
{/* Health card placeholder */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: 'var(--space-4)',
|
||||
marginBottom: 'var(--space-6)',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', marginBottom: i < 2 ? 'var(--space-3)' : 0 }}>
|
||||
<Skeleton width={14} height={14} borderRadius="50%" />
|
||||
<Skeleton width={100} height={14} />
|
||||
<Skeleton width={60} height={20} borderRadius="var(--radius-md)" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Status table placeholder */}
|
||||
<Skeleton width={100} height={24} style={{ marginBottom: 'var(--space-4)' }} />
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: 'var(--space-4)',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 'var(--space-4)', marginBottom: i < 4 ? 'var(--space-3)' : 0 }}>
|
||||
<Skeleton width={140} height={14} />
|
||||
<Skeleton width={200} height={14} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for the settings page. */
|
||||
export function SkeletonSettings() {
|
||||
return (
|
||||
<div>
|
||||
<Skeleton width={120} height={28} style={{ marginBottom: 'var(--space-6)' }} />
|
||||
|
||||
{/* General section */}
|
||||
<div style={{ marginBottom: 'var(--space-8)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-4)' }}>
|
||||
<Skeleton width={20} height={20} borderRadius="var(--radius-sm)" />
|
||||
<Skeleton width={80} height={20} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: 'var(--space-4)',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 'var(--space-4)', marginBottom: i < 3 ? 'var(--space-4)' : 0, alignItems: 'center' }}>
|
||||
<Skeleton width={140} height={14} />
|
||||
<Skeleton width={250} height={32} borderRadius="var(--radius-md)" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Settings section */}
|
||||
<div style={{ marginBottom: 'var(--space-8)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-4)' }}>
|
||||
<Skeleton width={20} height={20} borderRadius="var(--radius-sm)" />
|
||||
<Skeleton width={140} height={20} />
|
||||
</div>
|
||||
<SkeletonTable rows={2} columns={6} />
|
||||
</div>
|
||||
|
||||
{/* Format Profiles section */}
|
||||
<div style={{ marginBottom: 'var(--space-8)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
||||
<Skeleton width={130} height={20} />
|
||||
<Skeleton width={110} height={32} borderRadius="var(--radius-md)" />
|
||||
</div>
|
||||
<SkeletonTable rows={3} columns={6} />
|
||||
</div>
|
||||
|
||||
{/* Notifications section */}
|
||||
<div style={{ marginBottom: 'var(--space-8)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
||||
<Skeleton width={120} height={20} />
|
||||
<Skeleton width={110} height={32} borderRadius="var(--radius-md)" />
|
||||
</div>
|
||||
<SkeletonTable rows={2} columns={5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for the channels list page. */
|
||||
export function SkeletonChannelsList({ rows = 4 }: { rows?: number }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-6)' }}>
|
||||
<Skeleton width={120} height={28} />
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
|
||||
<Skeleton width={110} height={34} borderRadius="var(--radius-md)" />
|
||||
<Skeleton width={140} height={34} borderRadius="var(--radius-md)" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<SkeletonTable rows={rows} columns={7} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/frontend/src/components/Toast.tsx
Normal file
92
src/frontend/src/components/Toast.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
type ToastVariant = 'success' | 'error' | 'info';
|
||||
|
||||
interface ToastEntry {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (message: string, variant?: ToastVariant) => void;
|
||||
}
|
||||
|
||||
// ── Context ──
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ── Provider ──
|
||||
|
||||
const TOAST_DURATION = 5000;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastEntry[]>([]);
|
||||
const nextId = useRef(0);
|
||||
|
||||
const toast = useCallback((message: string, variant: ToastVariant = 'info') => {
|
||||
const id = ++nextId.current;
|
||||
setToasts((prev) => [...prev, { id, message, variant }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, TOAST_DURATION);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
{children}
|
||||
{/* Toast container */}
|
||||
{toasts.length > 0 && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 'var(--space-6)',
|
||||
right: 'var(--space-6)',
|
||||
zIndex: 1100,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-2)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
role={t.variant === 'error' ? 'alert' : 'status'}
|
||||
className="toast-enter"
|
||||
style={{
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: t.variant === 'error' ? 'var(--danger-bg)' : t.variant === 'success' ? 'var(--success-bg)' : 'var(--bg-card)',
|
||||
border: `1px solid ${t.variant === 'error' ? 'var(--danger)' : t.variant === 'success' ? 'var(--success)' : 'var(--border)'}`,
|
||||
color: t.variant === 'error' ? 'var(--danger)' : t.variant === 'success' ? 'var(--success)' : 'var(--text-primary)',
|
||||
animation: 'toast-slide-in 0.25s ease-out',
|
||||
}}
|
||||
onClick={() => dismiss(t.id)}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { ActivityIcon, Clock, Loader, RefreshCw } from 'lucide-react';
|
||||
import { ActivityIcon, Clock, RefreshCw } from 'lucide-react';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||
import { SkeletonActivityList } from '../components/Skeleton';
|
||||
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
|
||||
import type { DownloadHistoryRecord } from '@shared/types/index';
|
||||
|
||||
|
|
@ -278,18 +279,7 @@ export function ActivityPage() {
|
|||
<button
|
||||
onClick={() => refetchHistory()}
|
||||
aria-label="Retry"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
|
|
@ -298,12 +288,7 @@ export function ActivityPage() {
|
|||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{historyLoading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
|
||||
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||
Loading history…
|
||||
</div>
|
||||
)}
|
||||
{historyLoading && <SkeletonActivityList />}
|
||||
|
||||
{/* Table */}
|
||||
{!historyLoading && (
|
||||
|
|
@ -351,18 +336,7 @@ export function ActivityPage() {
|
|||
<button
|
||||
onClick={() => refetchRecent()}
|
||||
aria-label="Retry"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
|
|
@ -371,12 +345,7 @@ export function ActivityPage() {
|
|||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{recentLoading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
|
||||
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||
Loading recent activity…
|
||||
</div>
|
||||
)}
|
||||
{recentLoading && <SkeletonActivityList />}
|
||||
|
||||
{/* Activity feed */}
|
||||
{!recentLoading && (
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Loader, RefreshCw, Search } from 'lucide-react';
|
||||
import { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
|
||||
|
|
@ -8,6 +8,8 @@ import { PlatformBadge } from '../components/PlatformBadge';
|
|||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { ProgressBar } from '../components/ProgressBar';
|
||||
import { AddChannelModal } from '../components/AddChannelModal';
|
||||
import { SkeletonChannelsList } from '../components/Skeleton';
|
||||
import { useToast } from '../components/Toast';
|
||||
import type { ChannelWithCounts } from '@shared/types/api';
|
||||
|
||||
// ── Helpers ──
|
||||
|
|
@ -30,19 +32,12 @@ function formatRelativeTime(dateStr: string | null): string {
|
|||
export function Channels() {
|
||||
const navigate = useNavigate();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: channels, isLoading, error, refetch } = useChannels();
|
||||
const scanAll = useScanAllChannels();
|
||||
const collectAll = useCollectAllMonitored();
|
||||
|
||||
// Auto-dismiss scan result toast after 5 seconds
|
||||
useEffect(() => {
|
||||
if (!scanResult) return;
|
||||
const timer = setTimeout(() => setScanResult(null), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [scanResult]);
|
||||
|
||||
const handleScanAll = useCallback(() => {
|
||||
scanAll.mutate(undefined, {
|
||||
onSuccess: (result) => {
|
||||
|
|
@ -50,16 +45,13 @@ export function Channels() {
|
|||
if (result.summary.errors > 0) {
|
||||
msg += ` (${result.summary.errors} error${result.summary.errors === 1 ? '' : 's'})`;
|
||||
}
|
||||
setScanResult({ message: msg, isError: result.summary.errors > 0 });
|
||||
toast(msg, result.summary.errors > 0 ? 'error' : 'success');
|
||||
},
|
||||
onError: (err) => {
|
||||
setScanResult({
|
||||
message: err instanceof Error ? err.message : 'Scan failed',
|
||||
isError: true,
|
||||
});
|
||||
toast(err instanceof Error ? err.message : 'Scan failed', 'error');
|
||||
},
|
||||
});
|
||||
}, [scanAll]);
|
||||
}, [scanAll, toast]);
|
||||
|
||||
const handleCollectAll = useCallback(() => {
|
||||
collectAll.mutate(undefined, {
|
||||
|
|
@ -69,16 +61,13 @@ export function Channels() {
|
|||
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
|
||||
if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`);
|
||||
const msg = parts.length > 0 ? parts.join(', ') : 'No items to collect';
|
||||
setScanResult({ message: msg, isError: result.errors > 0 });
|
||||
toast(msg, result.errors > 0 ? 'error' : 'success');
|
||||
},
|
||||
onError: (err) => {
|
||||
setScanResult({
|
||||
message: err instanceof Error ? err.message : 'Collect failed',
|
||||
isError: true,
|
||||
});
|
||||
toast(err instanceof Error ? err.message : 'Collect failed', 'error');
|
||||
},
|
||||
});
|
||||
}, [collectAll]);
|
||||
}, [collectAll, toast]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(channel: ChannelWithCounts) => {
|
||||
|
|
@ -184,12 +173,7 @@ export function Channels() {
|
|||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-12)', color: 'var(--text-muted)' }}>
|
||||
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||
Loading channels...
|
||||
</div>
|
||||
);
|
||||
return <SkeletonChannelsList />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
|
@ -213,18 +197,7 @@ export function Channels() {
|
|||
<button
|
||||
onClick={() => refetch()}
|
||||
aria-label="Retry"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
|
|
@ -260,20 +233,8 @@ export function Channels() {
|
|||
onClick={handleScanAll}
|
||||
disabled={scanAll.isPending}
|
||||
title="Refresh All"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
transition: 'all var(--transition-fast)',
|
||||
opacity: scanAll.isPending ? 0.6 : 1,
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
style={{ opacity: scanAll.isPending ? 0.6 : 1 }}
|
||||
>
|
||||
{scanAll.isPending ? (
|
||||
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
|
|
@ -287,20 +248,8 @@ export function Channels() {
|
|||
onClick={handleCollectAll}
|
||||
disabled={collectAll.isPending}
|
||||
title="Collect All Monitored"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
transition: 'all var(--transition-fast)',
|
||||
opacity: collectAll.isPending ? 0.6 : 1,
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
style={{ opacity: collectAll.isPending ? 0.6 : 1 }}
|
||||
>
|
||||
{collectAll.isPending ? (
|
||||
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
|
|
@ -313,21 +262,8 @@ export function Channels() {
|
|||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={scanAll.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,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
opacity: scanAll.isPending ? 0.6 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!scanAll.isPending) e.currentTarget.style.backgroundColor = 'var(--accent-hover)'; }}
|
||||
onMouseLeave={(e) => { if (!scanAll.isPending) e.currentTarget.style.backgroundColor = 'var(--accent)'; }}
|
||||
className="btn btn-primary"
|
||||
style={{ opacity: scanAll.isPending ? 0.6 : 1 }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Channel
|
||||
|
|
@ -339,7 +275,7 @@ export function Channels() {
|
|||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
|
|
@ -355,28 +291,6 @@ export function Channels() {
|
|||
|
||||
{/* Add Channel modal */}
|
||||
<AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} />
|
||||
|
||||
{/* Scan result toast */}
|
||||
{scanResult && (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 'var(--space-6)',
|
||||
right: 'var(--space-6)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
backgroundColor: scanResult.isError ? 'var(--danger-bg)' : 'var(--success-bg)',
|
||||
border: `1px solid ${scanResult.isError ? 'var(--danger)' : 'var(--success)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: scanResult.isError ? 'var(--danger)' : 'var(--success)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
zIndex: 1001,
|
||||
}}
|
||||
>
|
||||
{scanResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Library as LibraryIcon, Loader, RefreshCw, Film, Music } from 'lucide-react';
|
||||
import { Library as LibraryIcon, RefreshCw, Film, Music } from 'lucide-react';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { QualityLabel } from '../components/QualityLabel';
|
||||
|
|
@ -8,6 +8,7 @@ import { PlatformBadge } from '../components/PlatformBadge';
|
|||
import { Pagination } from '../components/Pagination';
|
||||
import { SearchBar } from '../components/SearchBar';
|
||||
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||
import { SkeletonLibrary } from '../components/Skeleton';
|
||||
import { useLibraryContent, type LibraryFilters } from '../api/hooks/useLibrary';
|
||||
import { useChannels } from '../api/hooks/useChannels';
|
||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
||||
|
|
@ -335,18 +336,7 @@ export function Library() {
|
|||
<button
|
||||
onClick={() => refetch()}
|
||||
aria-label="Retry"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
|
|
@ -355,12 +345,7 @@ export function Library() {
|
|||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
|
||||
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||
Loading library…
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <SkeletonLibrary />}
|
||||
|
||||
{/* Content table */}
|
||||
{!isLoading && !error && (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { ListOrdered, RotateCcw, X, Loader, RefreshCw } from 'lucide-react';
|
||||
import { ListOrdered, RotateCcw, X, RefreshCw } from 'lucide-react';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { SkeletonQueueList } from '../components/Skeleton';
|
||||
import { useQueue, useRetryQueueItem, useCancelQueueItem } from '../api/hooks/useQueue';
|
||||
import type { QueueItem, QueueStatus } from '@shared/types/index';
|
||||
|
||||
|
|
@ -163,20 +164,8 @@ export function Queue() {
|
|||
disabled={retryMutation.isPending}
|
||||
title="Retry"
|
||||
aria-label="Retry failed item"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
padding: 0,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-input)',
|
||||
color: 'var(--warning)',
|
||||
cursor: retryMutation.isPending ? 'wait' : 'pointer',
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
}}
|
||||
className="btn-icon"
|
||||
style={{ color: 'var(--warning)' }}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
|
|
@ -190,20 +179,8 @@ export function Queue() {
|
|||
disabled={cancelMutation.isPending}
|
||||
title="Cancel"
|
||||
aria-label="Cancel pending item"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
padding: 0,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-input)',
|
||||
color: 'var(--danger)',
|
||||
cursor: cancelMutation.isPending ? 'wait' : 'pointer',
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
}}
|
||||
className="btn-icon"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
|
@ -294,18 +271,7 @@ export function Queue() {
|
|||
<button
|
||||
onClick={() => refetch()}
|
||||
aria-label="Retry"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
|
|
@ -336,12 +302,7 @@ export function Queue() {
|
|||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
|
||||
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||
Loading queue…
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <SkeletonQueueList />}
|
||||
|
||||
{/* Queue table */}
|
||||
{!isLoading && (
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { Modal } from '../components/Modal';
|
|||
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
|
||||
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
|
||||
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
|
||||
import { SkeletonSettings } from '../components/Skeleton';
|
||||
import type { FormatProfile, PlatformSettings } from '@shared/types/index';
|
||||
|
||||
// ── Badge styles ──
|
||||
|
|
@ -40,17 +41,6 @@ const badgeBase: React.CSSProperties = {
|
|||
letterSpacing: '0.04em',
|
||||
};
|
||||
|
||||
const iconButtonBase: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-muted)',
|
||||
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
|
||||
};
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function SettingsPage() {
|
||||
|
|
@ -297,15 +287,7 @@ export function SettingsPage() {
|
|||
onClick={(e) => { e.stopPropagation(); setEditingPlatform(row.platform); }}
|
||||
title={`Edit ${row.label} settings`}
|
||||
aria-label={`Edit ${row.label} settings`}
|
||||
style={iconButtonBase}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
className="btn-icon btn-icon-edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
|
|
@ -479,15 +461,7 @@ export function SettingsPage() {
|
|||
onClick={(e) => { e.stopPropagation(); setEditingProfile(p); }}
|
||||
title="Edit profile"
|
||||
aria-label={`Edit ${p.name}`}
|
||||
style={iconButtonBase}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
className="btn-icon btn-icon-edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
|
|
@ -496,15 +470,7 @@ export function SettingsPage() {
|
|||
onClick={(e) => { e.stopPropagation(); setDeletingProfile(p); }}
|
||||
title="Delete profile"
|
||||
aria-label={`Delete ${p.name}`}
|
||||
style={iconButtonBase}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--danger)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--danger-bg)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
className="btn-icon btn-icon-delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
|
@ -587,18 +553,8 @@ export function SettingsPage() {
|
|||
title="Send test notification"
|
||||
aria-label={`Test ${n.name}`}
|
||||
disabled={result === 'loading'}
|
||||
style={{
|
||||
...iconButtonBase,
|
||||
opacity: result === 'loading' ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--success)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--success-bg)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
className="btn-icon btn-icon-test"
|
||||
style={{ opacity: result === 'loading' ? 0.5 : 1 }}
|
||||
>
|
||||
{result === 'loading'
|
||||
? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
|
|
@ -611,15 +567,7 @@ export function SettingsPage() {
|
|||
onClick={(e) => { e.stopPropagation(); setEditingNotification(n); }}
|
||||
title="Edit channel"
|
||||
aria-label={`Edit ${n.name}`}
|
||||
style={iconButtonBase}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
className="btn-icon btn-icon-edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
|
|
@ -629,15 +577,7 @@ export function SettingsPage() {
|
|||
onClick={(e) => { e.stopPropagation(); setDeletingNotification(n); }}
|
||||
title="Delete channel"
|
||||
aria-label={`Delete ${n.name}`}
|
||||
style={iconButtonBase}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--danger)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--danger-bg)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
className="btn-icon btn-icon-delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
|
@ -652,12 +592,7 @@ export function SettingsPage() {
|
|||
// ── Loading state ──
|
||||
|
||||
if (profilesLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-12)', color: 'var(--text-muted)' }}>
|
||||
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||
Loading settings...
|
||||
</div>
|
||||
);
|
||||
return <SkeletonSettings />;
|
||||
}
|
||||
|
||||
// ── Error state ──
|
||||
|
|
@ -683,17 +618,7 @@ export function SettingsPage() {
|
|||
<button
|
||||
onClick={() => refetchProfiles()}
|
||||
aria-label="Retry"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
|
|
@ -725,7 +650,7 @@ export function SettingsPage() {
|
|||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
|
|
@ -774,15 +699,7 @@ export function SettingsPage() {
|
|||
onClick={() => setShowApiKey((v) => !v)}
|
||||
title={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
style={iconButtonBase}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
className="btn-icon btn-icon-edit"
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
|
|
@ -792,22 +709,8 @@ export function SettingsPage() {
|
|||
onClick={handleCopyApiKey}
|
||||
title={copySuccess ? 'Copied!' : 'Copy to clipboard'}
|
||||
aria-label="Copy API key to clipboard"
|
||||
style={{
|
||||
...iconButtonBase,
|
||||
color: copySuccess ? 'var(--success)' : 'var(--text-muted)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!copySuccess) {
|
||||
e.currentTarget.style.color = 'var(--accent)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!copySuccess) {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
className="btn-icon btn-icon-edit"
|
||||
style={copySuccess ? { color: 'var(--success)' } : undefined}
|
||||
>
|
||||
{copySuccess ? <CheckCircle size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
|
|
@ -817,15 +720,7 @@ export function SettingsPage() {
|
|||
onClick={() => setShowRegenerateConfirm(true)}
|
||||
title="Regenerate API key"
|
||||
aria-label="Regenerate API key"
|
||||
style={iconButtonBase}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--warning)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--warning-bg)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
className="btn-icon btn-icon-warning"
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
</button>
|
||||
|
|
@ -911,17 +806,10 @@ export function SettingsPage() {
|
|||
<button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={!settingsDirty || !settingsValid || updateAppSettingsMutation.isPending}
|
||||
className={`btn ${settingsSaveFlash ? 'btn-primary' : 'btn-primary'}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: settingsSaveFlash ? 'var(--success)' : 'var(--accent)',
|
||||
color: 'var(--text-inverse)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
transition: 'background-color var(--transition-fast), opacity var(--transition-fast)',
|
||||
backgroundColor: settingsSaveFlash ? 'var(--success)' : undefined,
|
||||
borderColor: settingsSaveFlash ? 'var(--success)' : undefined,
|
||||
opacity: !settingsDirty || !settingsValid ? 0.5 : 1,
|
||||
cursor: !settingsDirty || !settingsValid ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
|
|
@ -963,7 +851,7 @@ export function SettingsPage() {
|
|||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
|
|
@ -992,20 +880,7 @@ export function SettingsPage() {
|
|||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateProfileModal(true)}
|
||||
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,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent)')}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Profile
|
||||
|
|
@ -1015,7 +890,7 @@ export function SettingsPage() {
|
|||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
|
|
@ -1045,20 +920,7 @@ export function SettingsPage() {
|
|||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateNotifModal(true)}
|
||||
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,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent)')}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Channel
|
||||
|
|
@ -1068,7 +930,7 @@ export function SettingsPage() {
|
|||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
|
|
@ -1148,36 +1010,15 @@ export function SettingsPage() {
|
|||
<button
|
||||
onClick={() => setDeletingProfile(null)}
|
||||
disabled={deleteProfileMutation.isPending}
|
||||
style={{
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-hover)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 500,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-selected)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteProfile}
|
||||
disabled={deleteProfileMutation.isPending}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
opacity: deleteProfileMutation.isPending ? 0.6 : 1,
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
style={{ opacity: deleteProfileMutation.isPending ? 0.6 : 1 }}
|
||||
>
|
||||
{deleteProfileMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
||||
Delete
|
||||
|
|
@ -1271,36 +1112,15 @@ export function SettingsPage() {
|
|||
<button
|
||||
onClick={() => setDeletingNotification(null)}
|
||||
disabled={deleteNotifMutation.isPending}
|
||||
style={{
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-hover)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 500,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-selected)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteNotification}
|
||||
disabled={deleteNotifMutation.isPending}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
opacity: deleteNotifMutation.isPending ? 0.6 : 1,
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
style={{ opacity: deleteNotifMutation.isPending ? 0.6 : 1 }}
|
||||
>
|
||||
{deleteNotifMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
||||
Delete
|
||||
|
|
@ -1338,36 +1158,15 @@ export function SettingsPage() {
|
|||
<button
|
||||
onClick={() => setShowRegenerateConfirm(false)}
|
||||
disabled={regenerateApiKeyMutation.isPending}
|
||||
style={{
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-hover)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 500,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-selected)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRegenerateApiKey}
|
||||
disabled={regenerateApiKeyMutation.isPending}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--warning)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
opacity: regenerateApiKeyMutation.isPending ? 0.6 : 1,
|
||||
}}
|
||||
className="btn btn-warning"
|
||||
style={{ opacity: regenerateApiKeyMutation.isPending ? 0.6 : 1 }}
|
||||
>
|
||||
{regenerateApiKeyMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
||||
Regenerate
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Loader, RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react';
|
||||
import { RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react';
|
||||
import { useSystemStatus, useHealth } from '../api/hooks/useSystem';
|
||||
import { HealthStatus } from '../components/HealthStatus';
|
||||
import { SkeletonSystem } from '../components/Skeleton';
|
||||
import { formatBytes } from '../utils/format';
|
||||
|
||||
// ── Helpers ──
|
||||
|
|
@ -26,12 +27,7 @@ export function SystemPage() {
|
|||
const isLoading = healthLoading || statusLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-12)', color: 'var(--text-muted)' }}>
|
||||
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||
Loading system info...
|
||||
</div>
|
||||
);
|
||||
return <SkeletonSystem />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -52,25 +48,7 @@ export function SystemPage() {
|
|||
onClick={() => refetchHealth()}
|
||||
title="Refresh health status"
|
||||
aria-label="Refresh health status"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-primary)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--bg-selected)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-secondary)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--bg-hover)';
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
|
|
@ -95,17 +73,7 @@ export function SystemPage() {
|
|||
<button
|
||||
onClick={() => refetchHealth()}
|
||||
aria-label="Retry"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
|
|
@ -127,25 +95,7 @@ export function SystemPage() {
|
|||
onClick={() => refetchStatus()}
|
||||
title="Refresh system status"
|
||||
aria-label="Refresh system status"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-primary)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--bg-selected)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-secondary)';
|
||||
e.currentTarget.style.backgroundColor = 'var(--bg-hover)';
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
|
|
@ -170,17 +120,7 @@ export function SystemPage() {
|
|||
<button
|
||||
onClick={() => refetchStatus()}
|
||||
aria-label="Retry"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--danger)',
|
||||
color: '#fff',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/* ── Global Reset & Base Styles ── */
|
||||
@import './theme.css';
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
*,
|
||||
*::before,
|
||||
|
|
@ -13,6 +14,7 @@ html {
|
|||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -22,6 +24,7 @@ body {
|
|||
background-color: var(--bg-main);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
|
@ -32,6 +35,7 @@ body {
|
|||
a {
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
|
|
@ -42,25 +46,26 @@ a:hover {
|
|||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Scrollbar styling ── */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-main);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-light);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* ── Buttons base ── */
|
||||
|
|
@ -71,6 +76,7 @@ button {
|
|||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
/* ── Inputs base ── */
|
||||
|
|
@ -81,15 +87,17 @@ select {
|
|||
font-size: inherit;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
|
@ -106,10 +114,10 @@ table {
|
|||
th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
letter-spacing: 0.06em;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
|
@ -136,6 +144,161 @@ tr:hover {
|
|||
border-width: 0;
|
||||
}
|
||||
|
||||
/* ── Glassmorphism card ── */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
/* ── Skeleton loader ── */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-input) 25%,
|
||||
rgba(255, 255, 255, 0.04) 50%,
|
||||
var(--bg-input) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.8s ease-in-out infinite;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ── Status badge glow ── */
|
||||
.badge-pulse {
|
||||
animation: badge-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes badge-glow {
|
||||
0%, 100% { box-shadow: none; }
|
||||
50% { box-shadow: 0 0 8px var(--accent-glow); }
|
||||
}
|
||||
|
||||
/* ── Button utility classes ── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background-color: var(--bg-selected);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger);
|
||||
color: #fff;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.btn-icon-edit:hover {
|
||||
color: var(--accent);
|
||||
background-color: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn-icon-delete:hover {
|
||||
color: var(--danger);
|
||||
background-color: var(--danger-bg);
|
||||
}
|
||||
|
||||
.btn-icon-test:hover {
|
||||
color: var(--success);
|
||||
background-color: var(--success-bg);
|
||||
}
|
||||
|
||||
.btn-icon-warning:hover {
|
||||
color: var(--warning);
|
||||
background-color: var(--warning-bg);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--warning);
|
||||
color: #fff;
|
||||
border-color: var(--warning);
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
/* ── Modal animation ── */
|
||||
@keyframes modal-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
|
@ -146,3 +309,51 @@ tr:hover {
|
|||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Page transition ── */
|
||||
main {
|
||||
animation: fade-in 200ms ease-out;
|
||||
}
|
||||
|
||||
/* ── Table row transitions ── */
|
||||
tbody tr {
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ── Card checkbox visibility on hover ── */
|
||||
div:hover > .card-checkbox,
|
||||
.card-checkbox:has(input:checked) {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@keyframes toast-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--sidebar-width: 0px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +1,60 @@
|
|||
/* ── *arr Dark Theme ──
|
||||
* Color palette matching Sonarr/Radarr aesthetic.
|
||||
* Color palette matching Sonarr/Radarr aesthetic with modern glassmorphism touches.
|
||||
* All UI components reference these custom properties.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ── Backgrounds ── */
|
||||
--bg-main: #1a1d23;
|
||||
--bg-sidebar: #14161a;
|
||||
--bg-card: #242731;
|
||||
--bg-input: #2a2e38;
|
||||
--bg-hover: #2f3341;
|
||||
--bg-selected: #35394a;
|
||||
--bg-header: #1e2029;
|
||||
--bg-toolbar: #1e2129;
|
||||
--bg-modal-overlay: rgba(0, 0, 0, 0.6);
|
||||
--bg-main: #0f1117;
|
||||
--bg-sidebar: #0a0c10;
|
||||
--bg-card: rgba(30, 33, 44, 0.8);
|
||||
--bg-card-solid: #1e212c;
|
||||
--bg-input: #1a1d26;
|
||||
--bg-hover: rgba(255, 255, 255, 0.04);
|
||||
--bg-selected: rgba(255, 255, 255, 0.08);
|
||||
--bg-header: #13151c;
|
||||
--bg-toolbar: #13151c;
|
||||
--bg-modal-overlay: rgba(0, 0, 0, 0.7);
|
||||
--bg-glass: rgba(20, 22, 30, 0.6);
|
||||
|
||||
/* ── Accent ── */
|
||||
--accent: #e05d44;
|
||||
--accent-hover: #c94e38;
|
||||
--accent-subtle: rgba(224, 93, 68, 0.12);
|
||||
--accent-hover: #f06a51;
|
||||
--accent-subtle: rgba(224, 93, 68, 0.1);
|
||||
--accent-glow: rgba(224, 93, 68, 0.25);
|
||||
|
||||
/* ── Text ── */
|
||||
--text-primary: #e1e2e6;
|
||||
--text-primary: #e8e9ed;
|
||||
--text-secondary: #8b8d97;
|
||||
--text-muted: #5d5f69;
|
||||
--text-inverse: #14161a;
|
||||
--text-muted: #4d5060;
|
||||
--text-inverse: #0f1117;
|
||||
--text-link: #e05d44;
|
||||
|
||||
/* ── Status colors ── */
|
||||
--success: #27c24c;
|
||||
--success-bg: rgba(39, 194, 76, 0.12);
|
||||
--warning: #ff902b;
|
||||
--warning-bg: rgba(255, 144, 43, 0.12);
|
||||
--success: #34d058;
|
||||
--success-bg: rgba(52, 208, 88, 0.1);
|
||||
--warning: #ff9f43;
|
||||
--warning-bg: rgba(255, 159, 67, 0.1);
|
||||
--danger: #f05050;
|
||||
--danger-bg: rgba(240, 80, 80, 0.12);
|
||||
--danger-bg: rgba(240, 80, 80, 0.1);
|
||||
--info: #e05d44;
|
||||
--info-bg: rgba(224, 93, 68, 0.12);
|
||||
--info-bg: rgba(224, 93, 68, 0.1);
|
||||
|
||||
/* ── Borders ── */
|
||||
--border: #2d3040;
|
||||
--border-light: #373b4e;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--border-light: rgba(255, 255, 255, 0.1);
|
||||
--border-accent: rgba(224, 93, 68, 0.3);
|
||||
|
||||
/* ── Typography ── */
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
--font-size-xs: 0.6875rem;
|
||||
--font-size-sm: 0.8125rem;
|
||||
--font-size-base: 0.875rem;
|
||||
--font-size-md: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
--font-size-xl: 1.375rem;
|
||||
--font-size-2xl: 1.75rem;
|
||||
|
||||
/* ── Spacing ── */
|
||||
--space-1: 0.25rem;
|
||||
|
|
@ -64,22 +68,32 @@
|
|||
--space-12: 3rem;
|
||||
|
||||
/* ── Border Radius ── */
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
--radius-xl: 8px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 14px;
|
||||
--radius-2xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ── Shadows ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 20px rgba(224, 93, 68, 0.15);
|
||||
|
||||
/* ── Layout ── */
|
||||
--sidebar-width: 210px;
|
||||
--sidebar-collapsed: 50px;
|
||||
--header-height: 55px;
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-collapsed: 56px;
|
||||
--header-height: 56px;
|
||||
|
||||
/* ── Transitions ── */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* ── Glassmorphism ── */
|
||||
--glass-blur: 12px;
|
||||
--glass-bg: rgba(20, 22, 30, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
|
|
|||
20
src/index.ts
20
src/index.ts
|
|
@ -23,6 +23,7 @@ import { PlatformRegistry } from './sources/platform-source';
|
|||
import { YouTubeSource } from './sources/youtube';
|
||||
import { SoundCloudSource } from './sources/soundcloud';
|
||||
import { Platform } from './types/index';
|
||||
import { getYtDlpVersion, updateYtDlp } from './sources/yt-dlp';
|
||||
import type { ViteDevServer } from 'vite';
|
||||
|
||||
const APP_NAME = 'Tubearr';
|
||||
|
|
@ -44,6 +45,25 @@ async function main(): Promise<void> {
|
|||
await seedAppDefaults(db);
|
||||
console.log(`[${APP_NAME}] App settings seeded`);
|
||||
|
||||
// 2d. Check yt-dlp version and auto-update if configured
|
||||
try {
|
||||
const version = await getYtDlpVersion();
|
||||
if (version) {
|
||||
console.log(`[${APP_NAME}] yt-dlp version: ${version}`);
|
||||
// Auto-update on startup (non-blocking — continue if it fails)
|
||||
if (appConfig.nodeEnv === 'production') {
|
||||
const result = await updateYtDlp();
|
||||
if (result.updated) {
|
||||
console.log(`[${APP_NAME}] yt-dlp updated: ${result.previousVersion} → ${result.version}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`[${APP_NAME}] yt-dlp not found on PATH — downloads will fail`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[${APP_NAME}] yt-dlp check failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// 3. Build and configure Fastify server
|
||||
// In dev mode, embed Vite for HMR — single port, no separate frontend process
|
||||
let vite: ViteDevServer | undefined;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { type FastifyInstance } from 'fastify';
|
|||
import {
|
||||
getAllContentItems,
|
||||
getContentByChannelId,
|
||||
getChannelContentPaginated,
|
||||
setMonitored,
|
||||
bulkSetMonitored,
|
||||
} from '../../db/repositories/content-repository';
|
||||
|
|
@ -202,6 +203,15 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
|||
|
||||
fastify.get<{
|
||||
Params: { id: string };
|
||||
Querystring: {
|
||||
page?: string;
|
||||
pageSize?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
contentType?: string;
|
||||
sortBy?: string;
|
||||
sortDirection?: string;
|
||||
};
|
||||
}>('/api/v1/channel/:id/content', async (request, reply) => {
|
||||
const channelId = parseInt(request.params.id, 10);
|
||||
|
||||
|
|
@ -213,12 +223,50 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await getContentByChannelId(fastify.db, channelId);
|
||||
const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1);
|
||||
const pageSize = Math.min(
|
||||
200,
|
||||
Math.max(1, parseInt(request.query.pageSize ?? '50', 10) || 50)
|
||||
);
|
||||
|
||||
const response: ApiResponse<ContentItem[]> = {
|
||||
// If no pagination params provided, return all items (backwards-compatible)
|
||||
const hasPaginationParams = request.query.page || request.query.pageSize || request.query.search || request.query.status || request.query.contentType || request.query.sortBy;
|
||||
|
||||
try {
|
||||
if (!hasPaginationParams) {
|
||||
// Legacy mode: return all items as flat array (backwards-compatible)
|
||||
const items = await getContentByChannelId(fastify.db, channelId);
|
||||
const response: ApiResponse<ContentItem[]> = {
|
||||
success: true,
|
||||
data: items,
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
// Paginated mode with filters
|
||||
const result = await getChannelContentPaginated(
|
||||
fastify.db,
|
||||
channelId,
|
||||
{
|
||||
search: request.query.search || undefined,
|
||||
status: (request.query.status as ContentStatus) || undefined,
|
||||
contentType: (request.query.contentType as ContentType) || undefined,
|
||||
sortBy: request.query.sortBy as 'title' | 'publishedAt' | 'status' | 'duration' | 'fileSize' | 'downloadedAt' | undefined,
|
||||
sortDirection: (request.query.sortDirection as 'asc' | 'desc') || undefined,
|
||||
},
|
||||
page,
|
||||
pageSize
|
||||
);
|
||||
|
||||
const response: PaginatedResponse<ContentItem> = {
|
||||
success: true,
|
||||
data: items,
|
||||
data: result.items,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
totalItems: result.total,
|
||||
totalPages: Math.ceil(result.total / pageSize),
|
||||
},
|
||||
};
|
||||
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { extname } from 'node:path';
|
|||
import { createInterface } from 'node:readline';
|
||||
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||
import type * as schema from '../db/schema/index';
|
||||
import { execYtDlp, spawnYtDlp, YtDlpError } from '../sources/yt-dlp';
|
||||
import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp';
|
||||
import { updateContentItem } from '../db/repositories/content-repository';
|
||||
import { parseProgressLine } from './progress-parser';
|
||||
import type { DownloadEventBus } from './event-bus';
|
||||
|
|
@ -137,16 +137,22 @@ export class DownloadService {
|
|||
// Report error to rate limiter
|
||||
this.rateLimiter.reportError(channel.platform as Platform);
|
||||
|
||||
// Classify the error for better retry decisions
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const stderr = err instanceof YtDlpError ? err.stderr : '';
|
||||
const errorCategory = classifyYtDlpError(stderr || errorMsg);
|
||||
|
||||
// Update status to failed
|
||||
await updateContentItem(this.db, contentItem.id, { status: 'failed' });
|
||||
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.log(`${logPrefix} status=failed error="${errorMsg.slice(0, 200)}"`);
|
||||
console.log(
|
||||
`${logPrefix} status=failed category=${errorCategory} error="${errorMsg.slice(0, 200)}"`
|
||||
);
|
||||
|
||||
// Emit download:failed event
|
||||
// Emit download:failed event with error category
|
||||
this.eventBus?.emitDownload('download:failed', {
|
||||
contentItemId: contentItem.id,
|
||||
error: errorMsg.slice(0, 200),
|
||||
error: `[${errorCategory}] ${errorMsg.slice(0, 200)}`,
|
||||
});
|
||||
|
||||
throw err;
|
||||
|
|
@ -288,32 +294,32 @@ export class DownloadService {
|
|||
|
||||
/**
|
||||
* Build format args for video content.
|
||||
* Uses a fallback chain: preferred resolution → best available → any.
|
||||
* yt-dlp supports `/` as a fallback separator: `format1/format2/format3`.
|
||||
*/
|
||||
private buildVideoArgs(formatProfile?: FormatProfile): string[] {
|
||||
const args: string[] = [];
|
||||
const container = formatProfile?.containerFormat ?? 'mp4';
|
||||
|
||||
if (formatProfile?.videoResolution === 'Best') {
|
||||
// "Best" selects separate best-quality video + audio streams, merged together.
|
||||
// This is higher quality than `-f best` which picks a single combined format.
|
||||
args.push('-f', 'bestvideo+bestaudio/best');
|
||||
const container = formatProfile.containerFormat ?? 'mp4';
|
||||
// Best quality: separate streams merged
|
||||
args.push('-f', 'bestvideo+bestaudio/bestvideo*+bestaudio/best');
|
||||
args.push('--merge-output-format', container);
|
||||
} else if (formatProfile?.videoResolution) {
|
||||
const height = parseResolutionHeight(formatProfile.videoResolution);
|
||||
if (height) {
|
||||
// Fallback chain: exact res → best under res → single best stream → any
|
||||
args.push(
|
||||
'-f',
|
||||
`bestvideo[height<=${height}]+bestaudio/best[height<=${height}]`
|
||||
`bestvideo[height<=${height}]+bestaudio/bestvideo[height<=${height}]*+bestaudio/best[height<=${height}]/bestvideo+bestaudio/best`
|
||||
);
|
||||
} else {
|
||||
args.push('-f', 'best');
|
||||
args.push('-f', 'bestvideo+bestaudio/best');
|
||||
}
|
||||
|
||||
// Container format for merge
|
||||
const container = formatProfile.containerFormat ?? 'mp4';
|
||||
args.push('--merge-output-format', container);
|
||||
} else {
|
||||
args.push('-f', 'best');
|
||||
args.push('-f', 'bestvideo+bestaudio/best');
|
||||
args.push('--merge-output-format', container);
|
||||
}
|
||||
|
||||
return args;
|
||||
|
|
@ -367,18 +373,33 @@ export class DownloadService {
|
|||
|
||||
/**
|
||||
* Parse the final file path from yt-dlp stdout.
|
||||
* The `--print after_move:filepath` flag makes yt-dlp output the final path
|
||||
* as the last line of stdout.
|
||||
* The `--print after_move:filepath` flag makes yt-dlp output the final path.
|
||||
*
|
||||
* Strategy: walk backwards through lines, skipping known yt-dlp output prefixes
|
||||
* (e.g. [download], [Merger], [ExtractAudio], Deleting).
|
||||
* A valid path line should be an absolute path or at least contain a file extension.
|
||||
*/
|
||||
private parseFinalPath(stdout: string, fallbackPath: string): string {
|
||||
const lines = stdout.trim().split('\n');
|
||||
// The filepath from --print is typically the last non-empty line
|
||||
|
||||
// Known non-path prefixes from yt-dlp output
|
||||
const NON_PATH_PREFIXES = ['[', 'Deleting', 'WARNING:', 'ERROR:', 'Merging', 'Post-process'];
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (line && !line.startsWith('[') && !line.startsWith('Deleting')) {
|
||||
if (!line) continue;
|
||||
|
||||
// Skip known yt-dlp output lines
|
||||
const isNonPath = NON_PATH_PREFIXES.some((prefix) => line.startsWith(prefix));
|
||||
if (isNonPath) continue;
|
||||
|
||||
// A valid path should have a file extension or start with /
|
||||
if (line.startsWith('/') || /\.\w{2,5}$/.test(line)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('[download] Could not parse final path from yt-dlp output, using fallback');
|
||||
return fallbackPath;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -212,3 +212,88 @@ export async function getYtDlpVersion(): Promise<string | null> {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-Update ──
|
||||
|
||||
/**
|
||||
* Update yt-dlp to the latest version.
|
||||
* Uses `yt-dlp -U` which handles self-update on most installations.
|
||||
* For pip-based installs (Alpine/Docker), falls back to `pip install -U yt-dlp`.
|
||||
*
|
||||
* Returns { updated, version, previousVersion } on success.
|
||||
*/
|
||||
export async function updateYtDlp(): Promise<{
|
||||
updated: boolean;
|
||||
version: string | null;
|
||||
previousVersion: string | null;
|
||||
}> {
|
||||
const previousVersion = await getYtDlpVersion();
|
||||
|
||||
try {
|
||||
// Try native self-update first
|
||||
const { stderr } = await execFileAsync('yt-dlp', ['-U'], {
|
||||
timeout: 120_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
// Check if it actually updated
|
||||
const newVersion = await getYtDlpVersion();
|
||||
const didUpdate = newVersion !== previousVersion;
|
||||
|
||||
if (didUpdate) {
|
||||
console.log(`[yt-dlp] Updated from ${previousVersion} to ${newVersion}`);
|
||||
} else if (stderr.toLowerCase().includes('up to date')) {
|
||||
console.log(`[yt-dlp] Already up to date (${newVersion})`);
|
||||
}
|
||||
|
||||
return { updated: didUpdate, version: newVersion, previousVersion };
|
||||
} catch (err) {
|
||||
// Self-update may not work in pip-based installs — try pip
|
||||
try {
|
||||
await execFileAsync('pip', ['install', '-U', 'yt-dlp'], {
|
||||
timeout: 120_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const newVersion = await getYtDlpVersion();
|
||||
const didUpdate = newVersion !== previousVersion;
|
||||
console.log(
|
||||
`[yt-dlp] pip update: ${previousVersion} → ${newVersion}${didUpdate ? '' : ' (no change)'}`
|
||||
);
|
||||
|
||||
return { updated: didUpdate, version: newVersion, previousVersion };
|
||||
} catch (pipErr) {
|
||||
console.warn(
|
||||
`[yt-dlp] Auto-update failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return { updated: false, version: previousVersion, previousVersion };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error Classification ──
|
||||
|
||||
/**
|
||||
* Classify a yt-dlp error into a category for better retry/fallback decisions.
|
||||
*/
|
||||
export type YtDlpErrorCategory =
|
||||
| 'rate_limit' // 429, too many requests
|
||||
| 'format_unavailable' // requested format not available
|
||||
| 'geo_blocked' // geo-restriction
|
||||
| 'age_restricted' // age-gated content
|
||||
| 'private' // private or removed video
|
||||
| 'network' // DNS, connection, timeout
|
||||
| 'unknown';
|
||||
|
||||
export function classifyYtDlpError(stderr: string): YtDlpErrorCategory {
|
||||
const lower = stderr.toLowerCase();
|
||||
|
||||
if (lower.includes('429') || lower.includes('too many requests')) return 'rate_limit';
|
||||
if (lower.includes('requested format') || lower.includes('format is not available')) return 'format_unavailable';
|
||||
if (lower.includes('not available in your country') || lower.includes('geo')) return 'geo_blocked';
|
||||
if (lower.includes('age') && (lower.includes('restricted') || lower.includes('verify'))) return 'age_restricted';
|
||||
if (lower.includes('private video') || lower.includes('video unavailable') || lower.includes('been removed')) return 'private';
|
||||
if (lower.includes('unable to download') || lower.includes('connection') || lower.includes('timed out') || lower.includes('urlopen error')) return 'network';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue