feat: webowa wersja Video & Sound Downloader Pro (NestJS + React)
Some checks failed
deploy / deploy (push) Failing after 1m44s

Odwzorowanie desktopowego v6.5.6: tryby MP3/MP4/PLAYLISTA/FACEBOOK,
jakość/format, napisy, log na żywo (SSE), STOP. Docker + Gitea CI/CD,
deploy na videodownloader.naurecki.pl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 10:45:22 +02:00
commit fbc4fb0834
29 changed files with 8129 additions and 0 deletions

335
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,335 @@
import { useEffect, useRef, useState } from 'react';
type Mode = 'mp3' | 'mp4' | 'playlist' | 'facebook';
interface ModeDef {
key: Mode;
title: string;
subtitle: string;
icon: string;
accent: string;
button: string;
formats: { value: string; label: string }[];
}
const MODES: ModeDef[] = [
{
key: 'mp3',
title: 'MP3',
subtitle: 'Pobierz dźwięk',
icon: '♫',
accent: '#a855f7',
button: 'POBIERZ DŹWIĘK',
formats: [
{ value: '320', label: 'MP3 320 kbps' },
{ value: '256', label: 'MP3 256 kbps' },
{ value: '192', label: 'MP3 192 kbps' },
{ value: '128', label: 'MP3 128 kbps' },
],
},
{
key: 'mp4',
title: 'MP4',
subtitle: 'Pobierz wideo',
icon: '■',
accent: '#3b82f6',
button: 'POBIERZ WIDEO',
formats: [
{ value: 'best', label: 'MP4 najlepsza' },
{ value: '2160', label: 'MP4 2160p (4K)' },
{ value: '1440', label: 'MP4 1440p' },
{ value: '1080', label: 'MP4 1080p' },
{ value: '720', label: 'MP4 720p' },
{ value: '480', label: 'MP4 480p' },
],
},
{
key: 'playlist',
title: 'PLAYLISTA',
subtitle: 'Pobierz całą listę',
icon: '▦',
accent: '#22c55e',
button: 'POBIERZ PLAYLISTĘ',
formats: [
{ value: 'video-1080', label: 'Wideo MP4 1080p' },
{ value: 'video-720', label: 'Wideo MP4 720p' },
{ value: 'audio-320', label: 'Audio MP3 320 kbps' },
],
},
{
key: 'facebook',
title: 'FACEBOOK',
subtitle: 'Pobierz wideo',
icon: 'f',
accent: '#60a5fa',
button: 'POBIERZ WIDEO',
formats: [
{ value: 'best', label: 'MP4 najlepsza' },
{ value: '1080', label: 'MP4 1080p' },
{ value: '720', label: 'MP4 720p' },
{ value: '480', label: 'MP4 480p' },
],
},
];
const VERSION = '6.5.6';
export default function App() {
const [url, setUrl] = useState('');
const [mode, setMode] = useState<Mode>('mp3');
const [format, setFormat] = useState('320');
const [subtitles, setSubtitles] = useState(false);
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState(0);
const [log, setLog] = useState<string[]>(['OK: wszystko gotowe do pobierania.']);
const [trial, setTrial] = useState<{ daysLeft: number | null; expires: string | null } | null>(null);
const esRef = useRef<EventSource | null>(null);
const jobRef = useRef<string | null>(null);
const logEndRef = useRef<HTMLDivElement | null>(null);
const def = MODES.find((m) => m.key === mode)!;
useEffect(() => {
// przy zmianie trybu ustaw domyślny format
setFormat(MODES.find((m) => m.key === mode)!.formats[0].value);
}, [mode]);
useEffect(() => {
fetch('/api/health')
.then((r) => r.json())
.then((h) => {
if (h?.trial?.enabled) setTrial({ daysLeft: h.trial.daysLeft, expires: h.trial.expires });
})
.catch(() => {});
}, []);
useEffect(() => {
logEndRef.current?.scrollIntoView({ block: 'end' });
}, [log]);
function appendLog(line: string) {
setLog((l) => [...l.slice(-400), line]);
}
async function paste() {
try {
const t = await navigator.clipboard.readText();
if (t) setUrl(t.trim());
} catch {
appendLog('Nie udało się odczytać schowka (brak uprawnień przeglądarki).');
}
}
function reset() {
esRef.current?.close();
esRef.current = null;
jobRef.current = null;
setBusy(false);
}
async function start() {
if (!url.trim()) {
appendLog('BŁĄD: wklej najpierw link.');
return;
}
setBusy(true);
setProgress(0);
setLog([`Start: ${def.title}${url.trim()}`]);
try {
const res = await fetch('/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url.trim(), mode, format, subtitles }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { id } = await res.json();
jobRef.current = id;
const es = new EventSource(`/api/jobs/${id}/events`);
esRef.current = es;
es.onmessage = (e) => handleEvent(e.data);
es.onerror = () => {
// strumień zamknięty (koniec joba lub błąd sieci)
es.close();
};
} catch (e: any) {
appendLog(`BŁĄD: ${e?.message || e}`);
reset();
}
}
function handleEvent(data: string) {
let ev: any;
try {
ev = JSON.parse(data);
} catch {
return;
}
switch (ev.kind) {
case 'log':
appendLog(ev.line);
break;
case 'progress':
setProgress(ev.percent);
break;
case 'done': {
setProgress(100);
appendLog(`Pobieranie zakończone: ${ev.file?.name}`);
// wywołaj pobranie pliku przez przeglądarkę
const a = document.createElement('a');
a.href = ev.downloadUrl;
a.download = ev.file?.name || '';
document.body.appendChild(a);
a.click();
a.remove();
reset();
break;
}
case 'error':
appendLog(`BŁĄD: ${ev.message}`);
reset();
break;
default:
break;
}
}
async function stop() {
const id = jobRef.current;
if (id) {
try {
await fetch(`/api/jobs/${id}`, { method: 'DELETE' });
} catch {
/* ignore */
}
}
appendLog('Zatrzymano.');
reset();
}
return (
<div className="app">
<div className="card">
<header className="head">
<div className="logo"></div>
<div className="head-text">
<h1>Video &amp; Sound Downloader Pro</h1>
<p>Pobieraj audio, wideo i playlisty z jednego panelu</p>
</div>
<span className="badge">v{VERSION}</span>
</header>
<label className="field-label">Link:</label>
<div className="link-row">
<input
className="link-input"
placeholder="Wklej link (YouTube, YouTube Music, Shorts, Facebook, LinkedIn, ARTE TV…)"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<button className="icon-btn" title="Wklej ze schowka" onClick={paste}>
{'\u{1F517}'}
</button>
</div>
<label className="field-label">Wybierz opcję:</label>
<div className="modes">
{MODES.map((m) => (
<button
key={m.key}
className={`mode ${mode === m.key ? 'active' : ''}`}
style={mode === m.key ? { borderColor: m.accent } : undefined}
onClick={() => setMode(m.key)}
>
<span className="mode-icon" style={{ color: m.accent }}>
{m.icon}
</span>
<span className="mode-title">{m.title}</span>
<span className="mode-sub">{m.subtitle}</span>
</button>
))}
</div>
<div className="row2">
<div className="col">
<label className="field-label">Jakość / Format:</label>
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
{def.formats.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div>
<div className="col">
<label className="field-label">Zapisz do:</label>
<div className="save-to">
<span>Pobranie przez przeglądarkę (folder Pobrane)</span>
<label className="subs">
<input
type="checkbox"
checked={subtitles}
onChange={(e) => setSubtitles(e.target.checked)}
disabled={mode === 'mp3'}
/>
Napisy
</label>
</div>
</div>
</div>
<div className="actions">
<button
className="download-btn"
onClick={start}
disabled={busy}
style={{ opacity: busy ? 0.6 : 1 }}
>
{busy ? `Pobieram… ${Math.round(progress)}%` : `${def.button}`}
</button>
<button className="stop-btn" onClick={stop} disabled={!busy}>
STOP
</button>
</div>
{busy && (
<div className="progress">
<div className="progress-bar" style={{ width: `${progress}%` }} />
</div>
)}
{trial && (
<div className="trial">
Tryb testowy: zostało {trial.daysLeft} dni. Wygasa: {trial.expires}.
</div>
)}
<div className="logbox">
<div className="logbox-title">Log</div>
<div className="logbox-body">
{log.map((l, i) => (
<div key={i} className="logline">
{l}
</div>
))}
<div ref={logEndRef} />
</div>
</div>
<footer className="foot">
<button className="ghost" onClick={() => alert('Wsparcie: napisz na support@naurecki.pl')}>
Wsparcie
</button>
<button
className="ghost"
onClick={() =>
alert('Video & Sound Downloader Pro — webowa wersja oparta o yt-dlp + FFmpeg.')
}
>
O mnie
</button>
</footer>
</div>
</div>
);
}