Добавлено описание проекта PVideoDl, включая функциональность, стек технологий, архитектуру, инструкции по запуску и API. Обновлён README.md для лучшего понимания проекта.
This commit is contained in:
Generated
+2155
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "pvideodl-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^22.19.21",
|
||||
"svelte": "^5.1.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-svelte": "^0.460.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Тёмная тема по умолчанию. Палитра в духе shadcn (zinc/slate), задаётся
|
||||
через CSS-переменные, чтобы при желании добавить светлую тему одним классом. */
|
||||
@theme {
|
||||
--color-bg: #09090b;
|
||||
--color-surface: #18181b;
|
||||
--color-surface-2: #27272a;
|
||||
--color-border: #27272a;
|
||||
--color-muted: #a1a1aa;
|
||||
--color-fg: #fafafa;
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-fg: #ffffff;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Кастомный тонкий скроллбар под тёмную тему. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-surface-2) transparent;
|
||||
}
|
||||
|
||||
/* Пульсация статуса «качается». */
|
||||
@keyframes pulse-soft {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
.animate-pulse-soft {
|
||||
animation: pulse-soft 1.4s ease-in-out infinite;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PVideoDl</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
// Клиент к бэкенду. Базовый путь относительный — и в деве (через Vite-прокси),
|
||||
// и в проде (FastAPI отдаёт статику с того же origin) работает одинаково.
|
||||
import type { Download } from './types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function handle<T>(resp: Response): Promise<T> {
|
||||
if (!resp.ok) {
|
||||
let detail = resp.statusText;
|
||||
try {
|
||||
const body = await resp.json();
|
||||
detail = body.detail ?? detail;
|
||||
} catch {
|
||||
/* тело не JSON — оставляем statusText */
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (resp.status === 204) return undefined as T;
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listDownloads(): Promise<Download[]> {
|
||||
return handle(await fetch(`${BASE}/downloads`));
|
||||
}
|
||||
|
||||
export async function addDownloads(urls: string[]): Promise<Download[]> {
|
||||
return handle(
|
||||
await fetch(`${BASE}/downloads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ urls })
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteDownload(id: string): Promise<void> {
|
||||
return handle(await fetch(`${BASE}/downloads/${id}`, { method: 'DELETE' }));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { Download, Loader2 } from 'lucide-svelte';
|
||||
|
||||
let { onSubmit }: { onSubmit: (urls: string[]) => Promise<void> } = $props();
|
||||
|
||||
let text = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
const urlCount = $derived(
|
||||
text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean).length
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
const urls = text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (!urls.length || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await onSubmit(urls);
|
||||
text = '';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Ctrl/Cmd+Enter — быстрая отправка.
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/60 p-4">
|
||||
<textarea
|
||||
bind:value={text}
|
||||
onkeydown={onKeydown}
|
||||
rows="4"
|
||||
placeholder="Вставьте ссылки — по одной на строку… https://example.com/file.zip https://youtube.com/watch?v=…"
|
||||
class="w-full resize-y rounded-lg border border-zinc-800 bg-zinc-950/60 px-3 py-2.5 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-indigo-500/60 focus:ring-2 focus:ring-indigo-500/20 focus:outline-none"
|
||||
></textarea>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<span class="text-xs text-zinc-500">
|
||||
{urlCount
|
||||
? `${urlCount} ${urlCount === 1 ? 'ссылка' : 'ссылок'} · Ctrl+Enter`
|
||||
: 'По одной ссылке на строку'}
|
||||
</span>
|
||||
<button
|
||||
onclick={submit}
|
||||
disabled={busy || urlCount === 0}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{#if busy}
|
||||
<Loader2 class="size-4 animate-spin" />
|
||||
{:else}
|
||||
<Download class="size-4" />
|
||||
{/if}
|
||||
Скачать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { Download as DownloadIcon, RotateCw, Trash2, Gauge, Clock } from 'lucide-svelte';
|
||||
import type { Download } from '$lib/types';
|
||||
import { formatBytes, formatSpeed, formatEta } from '$lib/format';
|
||||
import ProgressBar from './ProgressBar.svelte';
|
||||
import StatusBadge from './StatusBadge.svelte';
|
||||
|
||||
let {
|
||||
download,
|
||||
onDelete,
|
||||
onRetry
|
||||
}: {
|
||||
download: Download;
|
||||
onDelete: (id: string) => void;
|
||||
onRetry: (d: Download) => void;
|
||||
} = $props();
|
||||
|
||||
const title = $derived(download.filename ?? download.url);
|
||||
const isActive = $derived(download.status === 'downloading');
|
||||
const sizeLabel = $derived(
|
||||
download.size_bytes
|
||||
? `${formatBytes(download.downloaded_bytes)} / ${formatBytes(download.size_bytes)}`
|
||||
: formatBytes(download.downloaded_bytes || null)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-zinc-800 bg-zinc-900/60 p-4 shadow-sm transition-colors hover:border-zinc-700"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 shrink-0 rounded-lg bg-zinc-800 p-2 text-zinc-400">
|
||||
<DownloadIcon class="size-4" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-medium text-zinc-100" title={title}>{title}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-zinc-500" title={download.url}>
|
||||
{download.url}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<StatusBadge status={download.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<ProgressBar value={download.progress} status={download.status} />
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-zinc-400">
|
||||
<span>{sizeLabel}</span>
|
||||
{#if download.status !== 'failed' && download.size_bytes}
|
||||
<span class="text-zinc-500">{download.progress.toFixed(0)}%</span>
|
||||
{/if}
|
||||
{#if isActive}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Gauge class="size-3.5" />{formatSpeed(download.speed)}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Clock class="size-3.5" />{formatEta(download.eta)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if download.status === 'failed' && download.error}
|
||||
<span class="text-red-400" title={download.error}>{download.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if download.status === 'failed'}
|
||||
<button
|
||||
class="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-indigo-300"
|
||||
title="Повторить"
|
||||
onclick={() => onRetry(download)}
|
||||
>
|
||||
<RotateCw class="size-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-red-300"
|
||||
title="Удалить"
|
||||
onclick={() => onDelete(download.id)}
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import type { DownloadStatus } from '$lib/types';
|
||||
|
||||
let { value = 0, status }: { value: number; status: DownloadStatus } = $props();
|
||||
|
||||
const barColor = $derived(
|
||||
status === 'done'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'failed'
|
||||
? 'bg-red-500'
|
||||
: status === 'downloading'
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-zinc-600'
|
||||
);
|
||||
|
||||
// downloading без известного размера — «бегущая» indeterminate-полоса.
|
||||
const indeterminate = $derived(status === 'downloading' && value <= 0);
|
||||
const width = $derived(Math.max(0, Math.min(100, value)));
|
||||
</script>
|
||||
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-zinc-800">
|
||||
{#if indeterminate}
|
||||
<div class="indeterminate h-full w-1/3 rounded-full {barColor}"></div>
|
||||
{:else}
|
||||
<div
|
||||
class="h-full rounded-full {barColor} transition-[width] duration-500 ease-out"
|
||||
style="width: {width}%"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.indeterminate {
|
||||
animation: slide 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes slide {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(300%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { DownloadStatus } from '$lib/types';
|
||||
|
||||
let { status }: { status: DownloadStatus } = $props();
|
||||
|
||||
const map: Record<DownloadStatus, { label: string; cls: string; pulse?: boolean }> = {
|
||||
pending: { label: 'В очереди', cls: 'bg-zinc-700/60 text-zinc-300' },
|
||||
downloading: { label: 'Качается', cls: 'bg-indigo-500/15 text-indigo-300', pulse: true },
|
||||
done: { label: 'Готово', cls: 'bg-emerald-500/15 text-emerald-300' },
|
||||
failed: { label: 'Ошибка', cls: 'bg-red-500/15 text-red-300' },
|
||||
paused: { label: 'Пауза', cls: 'bg-amber-500/15 text-amber-300' }
|
||||
};
|
||||
const info = $derived(map[status]);
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium {info.cls}"
|
||||
>
|
||||
{#if info.pulse}
|
||||
<span class="size-1.5 animate-pulse-soft rounded-full bg-current"></span>
|
||||
{/if}
|
||||
{info.label}
|
||||
</span>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { CheckCircle2, XCircle, Info, X } from 'lucide-svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { toasts, dismissToast } from '$lib/stores';
|
||||
|
||||
const icon = { success: CheckCircle2, error: XCircle, info: Info };
|
||||
const accent = {
|
||||
success: 'border-emerald-500/30 text-emerald-300',
|
||||
error: 'border-red-500/30 text-red-300',
|
||||
info: 'border-zinc-700 text-zinc-300'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="pointer-events-none fixed right-4 bottom-4 z-50 flex w-80 flex-col gap-2">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
{@const Icon = icon[toast.kind]}
|
||||
<div
|
||||
transition:fly={{ y: 16, duration: 200 }}
|
||||
class="pointer-events-auto flex items-start gap-2.5 rounded-lg border bg-zinc-900/95 px-3.5 py-3 shadow-lg backdrop-blur {accent[
|
||||
toast.kind
|
||||
]}"
|
||||
>
|
||||
<Icon class="mt-0.5 size-4 shrink-0" />
|
||||
<p class="flex-1 text-sm text-zinc-200">{toast.message}</p>
|
||||
<button
|
||||
class="shrink-0 text-zinc-500 transition-colors hover:text-zinc-300"
|
||||
onclick={() => dismissToast(toast.id)}
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
export function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes == null) return '—';
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
const units = ['КБ', 'МБ', 'ГБ', 'ТБ'];
|
||||
let value = bytes / 1024;
|
||||
let i = 0;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatSpeed(bytesPerSec: number | null | undefined): string {
|
||||
if (!bytesPerSec) return '—';
|
||||
return `${formatBytes(bytesPerSec)}/с`;
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number | null | undefined): string {
|
||||
if (seconds == null || seconds < 0) return '—';
|
||||
const s = Math.round(seconds);
|
||||
if (s < 60) return `${s} с`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
if (m < 60) return `${m} мин ${rem} с`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h} ч ${m % 60} мин`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Подписка на SSE-поток прогресса. Авто-реконнект через нативный EventSource
|
||||
// (он сам переподключается), плюс наши флаги статуса и тосты на done/failed.
|
||||
import { applyEvent, connected, pushToast } from './stores';
|
||||
import type { Download, DownloadEvent } from './types';
|
||||
|
||||
let source: EventSource | null = null;
|
||||
|
||||
function fileLabel(d: Download | null): string {
|
||||
return d?.filename ?? d?.url ?? 'файл';
|
||||
}
|
||||
|
||||
export function connectSSE(): () => void {
|
||||
if (source) return () => {};
|
||||
source = new EventSource('/api/events');
|
||||
|
||||
source.onopen = () => connected.set(true);
|
||||
source.onerror = () => connected.set(false);
|
||||
|
||||
const handle = (raw: string, fallbackType: DownloadEvent['type']) => {
|
||||
try {
|
||||
const data = JSON.parse(raw) as DownloadEvent;
|
||||
applyEvent(data);
|
||||
if (data.type === 'done') pushToast('success', `Готово: ${fileLabel(data.download)}`);
|
||||
if (data.type === 'failed')
|
||||
pushToast('error', `Ошибка: ${fileLabel(data.download)}`);
|
||||
} catch {
|
||||
void fallbackType;
|
||||
}
|
||||
};
|
||||
|
||||
// Сервер шлёт именованные события (created/progress/done/failed/deleted).
|
||||
for (const type of ['created', 'progress', 'done', 'failed', 'deleted'] as const) {
|
||||
source.addEventListener(type, (e) => handle((e as MessageEvent).data, type));
|
||||
}
|
||||
// ping — keepalive, игнорируем.
|
||||
|
||||
return disconnectSSE;
|
||||
}
|
||||
|
||||
export function disconnectSSE(): void {
|
||||
source?.close();
|
||||
source = null;
|
||||
connected.set(false);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Реактивное состояние приложения на svelte stores.
|
||||
import { derived, writable } from 'svelte/store';
|
||||
import type { Download, DownloadEvent } from './types';
|
||||
|
||||
// Карта загрузок id -> Download. Карта, а не массив — точечные апдейты по id из SSE.
|
||||
const downloadsMap = writable<Map<string, Download>>(new Map());
|
||||
|
||||
// Отсортированный список для рендера (новые сверху).
|
||||
export const downloads = derived(downloadsMap, ($m) =>
|
||||
[...$m.values()].sort((a, b) => b.created_at.localeCompare(a.created_at))
|
||||
);
|
||||
|
||||
export function setDownloads(items: Download[]): void {
|
||||
downloadsMap.set(new Map(items.map((d) => [d.id, d])));
|
||||
}
|
||||
|
||||
function upsert(d: Download): void {
|
||||
downloadsMap.update((m) => {
|
||||
m.set(d.id, d);
|
||||
return new Map(m);
|
||||
});
|
||||
}
|
||||
|
||||
function remove(id: string): void {
|
||||
downloadsMap.update((m) => {
|
||||
m.delete(id);
|
||||
return new Map(m);
|
||||
});
|
||||
}
|
||||
|
||||
/** Применить событие из SSE к состоянию. Возвращает тип для тостов. */
|
||||
export function applyEvent(ev: DownloadEvent): void {
|
||||
if (ev.type === 'deleted' && ev.id) {
|
||||
remove(ev.id);
|
||||
return;
|
||||
}
|
||||
if (ev.download) upsert(ev.download);
|
||||
}
|
||||
|
||||
export function removeLocal(id: string): void {
|
||||
remove(id);
|
||||
}
|
||||
|
||||
// --- Тосты ---
|
||||
export interface Toast {
|
||||
id: number;
|
||||
kind: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const toasts = writable<Toast[]>([]);
|
||||
let toastSeq = 0;
|
||||
|
||||
export function pushToast(kind: Toast['kind'], message: string, ttl = 4000): void {
|
||||
const id = ++toastSeq;
|
||||
toasts.update((list) => [...list, { id, kind, message }]);
|
||||
setTimeout(() => dismissToast(id), ttl);
|
||||
}
|
||||
|
||||
export function dismissToast(id: number): void {
|
||||
toasts.update((list) => list.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
// --- Статус SSE-соединения ---
|
||||
export const connected = writable(false);
|
||||
@@ -0,0 +1,23 @@
|
||||
export type DownloadStatus = 'pending' | 'downloading' | 'done' | 'failed' | 'paused';
|
||||
|
||||
export interface Download {
|
||||
id: string;
|
||||
url: string;
|
||||
filename: string | null;
|
||||
status: DownloadStatus;
|
||||
progress: number; // 0–100
|
||||
size_bytes: number | null;
|
||||
downloaded_bytes: number;
|
||||
speed: number | null; // байт/сек
|
||||
eta: number | null; // секунд
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type DownloadEventType = 'created' | 'progress' | 'done' | 'failed' | 'deleted';
|
||||
|
||||
export interface DownloadEvent {
|
||||
type: DownloadEventType;
|
||||
download: Download | null;
|
||||
id: string | null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPA-режим: без серверного рендера, всё в браузере.
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Inbox, Wifi, WifiOff } from 'lucide-svelte';
|
||||
import * as api from '$lib/api';
|
||||
import {
|
||||
downloads,
|
||||
connected,
|
||||
setDownloads,
|
||||
removeLocal,
|
||||
pushToast
|
||||
} from '$lib/stores';
|
||||
import { connectSSE, disconnectSSE } from '$lib/sse';
|
||||
import type { Download } from '$lib/types';
|
||||
import AddLinksForm from '$lib/components/AddLinksForm.svelte';
|
||||
import DownloadCard from '$lib/components/DownloadCard.svelte';
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
// Гидрация: текущий список + подписка на живые обновления.
|
||||
api
|
||||
.listDownloads()
|
||||
.then(setDownloads)
|
||||
.catch((e) => pushToast('error', `Не удалось загрузить список: ${e.message}`))
|
||||
.finally(() => (loading = false));
|
||||
connectSSE();
|
||||
return disconnectSSE;
|
||||
});
|
||||
|
||||
async function handleAdd(urls: string[]) {
|
||||
try {
|
||||
const created = await api.addDownloads(urls);
|
||||
pushToast('info', `Добавлено в очередь: ${created.length}`);
|
||||
} catch (e) {
|
||||
pushToast('error', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
removeLocal(id); // оптимистично убираем из UI
|
||||
try {
|
||||
await api.deleteDownload(id);
|
||||
} catch (e) {
|
||||
pushToast('error', `Не удалось удалить: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetry(d: Download) {
|
||||
// Ретрая на бэкенде пока нет — пересоздаём задачу с тем же URL.
|
||||
await handleDelete(d.id);
|
||||
await handleAdd([d.url]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>PVideoDl — скачивалка</title></svelte:head>
|
||||
|
||||
<Toaster />
|
||||
|
||||
<main class="mx-auto max-w-3xl px-4 py-10">
|
||||
<header class="mb-8 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-50">PVideoDl</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">Локальная скачивалка файлов и видео</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs {$connected
|
||||
? 'text-emerald-400'
|
||||
: 'text-zinc-500'}"
|
||||
title={$connected ? 'Поток обновлений активен' : 'Нет соединения с сервером'}
|
||||
>
|
||||
{#if $connected}<Wifi class="size-3.5" />онлайн{:else}<WifiOff
|
||||
class="size-3.5"
|
||||
/>оффлайн{/if}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<AddLinksForm onSubmit={handleAdd} />
|
||||
|
||||
<section class="mt-8">
|
||||
{#if loading}
|
||||
<p class="py-12 text-center text-sm text-zinc-500">Загрузка…</p>
|
||||
{:else if $downloads.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-16 text-center text-zinc-500">
|
||||
<Inbox class="size-10 opacity-40" />
|
||||
<p class="text-sm">Пока пусто. Вставьте ссылки выше и нажмите «Скачать».</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each $downloads as download (download.id)}
|
||||
<DownloadCard {download} onDelete={handleDelete} onRetry={handleRetry} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="22" height="22" x="1" y="1" rx="5" fill="#09090b" stroke="none"/>
|
||||
<path d="M12 5v9"/>
|
||||
<path d="m8 11 4 4 4-4"/>
|
||||
<path d="M6 19h12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -0,0 +1,17 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
// SPA: всё рендерится в браузере, FastAPI отдаёт index.html как fallback.
|
||||
adapter: adapter({
|
||||
fallback: 'index.html',
|
||||
pages: 'build',
|
||||
assets: 'build'
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
// В деве фронт на :5173 проксирует /api и SSE на бэкенд :8000.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user