feat: webowa wersja Video & Sound Downloader Pro (NestJS + React)
Some checks failed
deploy / deploy (push) Failing after 1m44s
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:
335
apps/web/src/App.tsx
Normal file
335
apps/web/src/App.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/main.tsx
Normal file
10
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
136
apps/web/src/styles.css
Normal file
136
apps/web/src/styles.css
Normal file
@@ -0,0 +1,136 @@
|
||||
:root {
|
||||
--bg: #0b1220;
|
||||
--card: #111a2b;
|
||||
--panel: #0e1626;
|
||||
--panel2: #16223a;
|
||||
--border: #24324d;
|
||||
--text: #e6edf7;
|
||||
--muted: #8aa0bd;
|
||||
--blue: #2563eb;
|
||||
--blue-bright: #3b82f6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Segoe UI', Roboto, system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px 28px 20px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.head { display: flex; align-items: center; gap: 16px; margin-bottom: 22px; }
|
||||
.logo {
|
||||
width: 54px; height: 54px; border-radius: 12px;
|
||||
background: var(--panel2); border: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 26px; color: var(--blue-bright);
|
||||
}
|
||||
.head-text { flex: 1; }
|
||||
.head-text h1 { margin: 0; font-size: 24px; font-weight: 700; }
|
||||
.head-text p { margin: 3px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.badge {
|
||||
background: var(--panel2); color: var(--blue-bright);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 5px 10px; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
|
||||
.field-label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px; }
|
||||
|
||||
.link-row { display: flex; gap: 10px; }
|
||||
.link-input {
|
||||
flex: 1; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 13px 14px; color: var(--text); font-size: 14px;
|
||||
}
|
||||
.link-input:focus { outline: none; border-color: var(--blue-bright); }
|
||||
.icon-btn {
|
||||
width: 56px; background: var(--panel2); border: 1px solid var(--border);
|
||||
border-radius: 10px; color: var(--blue-bright); font-size: 18px; cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover { border-color: var(--blue-bright); }
|
||||
|
||||
.modes { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.mode {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 16px 10px; cursor: pointer; color: var(--text);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.mode:hover { background: var(--panel2); }
|
||||
.mode.active { background: #15233c; box-shadow: 0 0 0 1px rgba(59,130,246,.4) inset; }
|
||||
.mode-icon { font-size: 26px; line-height: 1; }
|
||||
.mode-title { font-weight: 700; font-size: 15px; }
|
||||
.mode-sub { font-size: 11px; color: var(--muted); }
|
||||
|
||||
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.col { display: flex; flex-direction: column; }
|
||||
.select {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 14px; color: var(--text); font-size: 14px; appearance: none;
|
||||
}
|
||||
.select:focus { outline: none; border-color: var(--blue-bright); }
|
||||
.save-to {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 10px 14px; color: var(--muted); font-size: 13px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
}
|
||||
.subs { display: flex; align-items: center; gap: 6px; color: var(--text); font-size: 13px; cursor: pointer; }
|
||||
|
||||
.actions { display: flex; gap: 12px; margin-top: 18px; }
|
||||
.download-btn {
|
||||
flex: 1; background: var(--blue); border: none; border-radius: 10px;
|
||||
color: #fff; font-size: 16px; font-weight: 700; padding: 16px; cursor: pointer;
|
||||
}
|
||||
.download-btn:hover:not(:disabled) { background: var(--blue-bright); }
|
||||
.stop-btn {
|
||||
width: 140px; background: #3a1d22; border: 1px solid #6b2b33; border-radius: 10px;
|
||||
color: #ff8a8a; font-size: 15px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.stop-btn:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.progress { margin-top: 12px; height: 6px; background: var(--panel); border-radius: 4px; overflow: hidden; }
|
||||
.progress-bar { height: 100%; background: var(--blue-bright); transition: width .2s; }
|
||||
|
||||
.trial {
|
||||
margin-top: 14px; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 12px 14px; color: var(--muted); font-size: 13px;
|
||||
}
|
||||
|
||||
.logbox { margin-top: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 14px; }
|
||||
.logbox-title { font-weight: 700; margin-bottom: 8px; }
|
||||
.logbox-body {
|
||||
background: #0a1120; border: 1px solid var(--border); border-radius: 8px;
|
||||
height: 140px; overflow-y: auto; padding: 10px 12px;
|
||||
font-family: 'Consolas', 'Courier New', monospace; font-size: 12.5px; color: #b9c6db;
|
||||
}
|
||||
.logline { white-space: pre-wrap; word-break: break-all; line-height: 1.5; }
|
||||
|
||||
.foot { display: flex; justify-content: space-between; margin-top: 16px; }
|
||||
.ghost {
|
||||
background: transparent; border: 1px solid var(--border); border-radius: 8px;
|
||||
color: var(--text); padding: 9px 18px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.ghost:hover { border-color: var(--blue-bright); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.modes { grid-template-columns: repeat(2, 1fr); }
|
||||
.row2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
Reference in New Issue
Block a user