Добавлено описание проекта PVideoDl, включая функциональность, стек технологий, архитектуру, инструкции по запуску и API. Обновлён README.md для лучшего понимания проекта.

This commit is contained in:
Leonid Pershin
2026-06-19 12:07:55 +03:00
parent e15d0af056
commit 3281fa2a5d
43 changed files with 4699 additions and 0 deletions
+2155
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+51
View File
@@ -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;
}
+13
View File
@@ -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>
+38
View File
@@ -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="Вставьте ссылки — по одной на строку…&#10;https://example.com/file.zip&#10;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>
+28
View File
@@ -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} мин`;
}
+44
View File
@@ -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);
}
+65
View File
@@ -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);
+23
View File
@@ -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; // 0100
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;
}
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}
+3
View File
@@ -0,0 +1,3 @@
// SPA-режим: без серверного рендера, всё в браузере.
export const ssr = false;
export const prerender = false;
+96
View File
@@ -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>
+6
View File
@@ -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

+17
View File
@@ -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;
+14
View File
@@ -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"
}
}
+16
View File
@@ -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
}
}
}
});