Add files via upload

This commit is contained in:
Savsis 2026-06-04 13:41:12 +05:00 • committed by GitHub
parent e978f1a25a
commit f6fd82e934
7 changed files with 1088 additions and 0 deletions

121
README.md Normal file
View file

@ -0,0 +1,121 @@
# nodex.pw converter
конвертирует всё что возможно — картинки, видео, аудио, архивы
веб-интерфейс, drag & drop, ctrl+v, автоопределение форматов
---
## что нужно
- python 3.10+
- ffmpeg (для видео и аудио, см. ниже)
---
## установка
```
pip install -r requirements.txt
```
ffmpeg на **windows** — скачается сам при первом запуске (кнопка в шапке)
или вручную:
```
python app.py setup-ffmpeg
```
на **linux/mac**:
```
sudo apt install ffmpeg
# или
brew install ffmpeg
```
---
## запуск
```
python app.py
```
открывай `http://localhost:5000`
порт можно поменять через `PORT=8080 python app.py`
---
## что конвертирует
**изображения**
| формат | → |
|--------|---|
| jpg, png, webp, bmp, gif, tiff, ico, avif, heic | любой из этих |
webp → png, heic → jpg, gif → webp — всё работает через pillow
**видео**
| формат | → |
|--------|---|
| mp4, avi, mov, mkv, webm, flv, wmv, m4v... | mp4, avi, mov, mkv, webm, gif |
| любое видео | mp3, wav, flac, aac, opus (вытащить аудио) |
**аудио**
| формат | → |
|--------|---|
| mp3, wav, flac, ogg, m4a, opus, aac, wma, aiff | любой из этих |
**архивы**
| формат | → |
|--------|---|
| zip, tar.gz, tar.bz2, tar.xz, 7z, rar | zip, tar.gz, tar.bz2, 7z |
для rar нужен системный `unrar` — `apt install unrar`
7z работает без внешних зависимостей (py7zr)
---
## как использовать
1. открываешь браузер
2. кидаешь файл в зону или нажимаешь "выбери файлы"
3. ctrl+v — вставить картинку из буфера (скриншоты работают)
4. выбираешь целевой формат
5. жмёшь конвертировать, скачиваешь
---
## структура
```
app.py — сервер flask, эндпоинты upload/convert/download
converter.py — логика конвертации (pillow + ffmpeg + архивы)
templates/
index.html — фронт
static/
style.css
app.js
uploads/ — временные загрузки
outputs/ — результаты
ffmpeg/ — ffmpeg.exe / ffprobe.exe (windows)
requirements.txt
```
---
## зависимости
```
flask — веб-сервер
pillow — изображения
py7zr — 7z архивы (чистый python)
rarfile — rar архивы (нужен системный unrar)
```
ffmpeg ставится отдельно — через пакетный менеджер или кнопку в интерфейсе

163
app.py Normal file
View file

@ -0,0 +1,163 @@
import os
import sys
import uuid
import zipfile
import platform
import urllib.request
from pathlib import Path
from flask import Flask, render_template, request, jsonify, send_file
import converter
FFMPEG_ZIP_URL = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.0.1-essentials_build.zip"
FFMPEG_EXES = {"ffmpeg.exe", "ffprobe.exe"}
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024 * 1024
UPLOAD_DIR = Path("uploads")
OUTPUT_DIR = Path("outputs")
FFMPEG_DIR = Path("ffmpeg")
UPLOAD_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)
def get_ffmpeg():
# на windows ищем локальный бинарь
if platform.system() == "Windows":
local = FFMPEG_DIR / "ffmpeg.exe"
if local.exists():
return str(local)
return "ffmpeg"
def ffmpeg_ok():
import subprocess
try:
subprocess.run([get_ffmpeg(), "-version"], capture_output=True, check=True, timeout=5)
return True
except Exception:
return False
def do_setup_ffmpeg():
if platform.system() != "Windows":
return False, "поставь через apt install ffmpeg или brew install ffmpeg"
FFMPEG_DIR.mkdir(exist_ok=True)
zip_path = FFMPEG_DIR / "ffmpeg_dl.zip"
print("качаю ffmpeg...", flush=True)
urllib.request.urlretrieve(FFMPEG_ZIP_URL, zip_path)
print("распаковываю...", flush=True)
with zipfile.ZipFile(zip_path, "r") as z:
for name in z.namelist():
fname = Path(name).name
if fname in FFMPEG_EXES:
(FFMPEG_DIR / fname).write_bytes(z.read(name))
zip_path.unlink()
return True, "готово"
@app.route("/")
def index():
return render_template("index.html", ffmpeg_ready=ffmpeg_ok())
@app.route("/api/upload", methods=["POST"])
def upload():
if "file" not in request.files:
return jsonify({"error": "нет файла"}), 400
f = request.files["file"]
if not f.filename:
return jsonify({"error": "имя файла пустое"}), 400
uid = str(uuid.uuid4())[:8]
ext = Path(f.filename).suffix
save_name = uid + ext
save_path = UPLOAD_DIR / save_name
f.save(save_path)
detected = converter.detect_type(save_path)
targets = converter.get_targets(detected)
return jsonify({
"id": uid,
"filename": f.filename,
"saved": save_name,
"type": detected,
"size": save_path.stat().st_size,
"targets": targets,
})
# тут все доступные форматы для валидации
ALL_TARGETS = set(
converter.IMAGE_TARGETS
+ converter.VIDEO_TARGETS
+ converter.VIDEO_TO_AUDIO
+ converter.AUDIO_TARGETS
+ converter.ARCHIVE_TARGETS
)
@app.route("/api/convert", methods=["POST"])
def convert_file():
data = request.json or {}
uid = data.get("id", "")
saved = data.get("saved", "")
target = data.get("target", "")
original = data.get("filename", "file")
if not all([uid, saved, target]):
return jsonify({"error": "плохие параметры"}), 400
if target not in ALL_TARGETS:
return jsonify({"error": "недопустимый формат"}), 400
src = UPLOAD_DIR / Path(saved).name
if not src.exists():
return jsonify({"error": "файл не найден"}), 404
out_name = f"{uid}_out.{target}"
out_path = OUTPUT_DIR / out_name
ok, err = converter.convert(src, out_path, target, get_ffmpeg())
if not ok:
return jsonify({"error": err}), 500
# чистое имя для скачивания — убираем все суффиксы кроме последнего
base = Path(Path(original).stem).stem
result_filename = f"{base}.{target}"
return jsonify({"result": out_name, "filename": result_filename})
@app.route("/api/download/<path:filename>")
def download(filename):
# только имя файла, без traversal
safe = Path(filename).name
path = OUTPUT_DIR / safe
if not path.exists():
return "не найдено", 404
return send_file(path, as_attachment=True)
@app.route("/api/setup-ffmpeg", methods=["POST"])
def setup_ffmpeg_route():
ok, msg = do_setup_ffmpeg()
return jsonify({"ok": ok, "msg": msg, "ffmpeg": ffmpeg_ok()})
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "setup-ffmpeg":
ok, msg = do_setup_ffmpeg()
print(msg)
sys.exit(0 if ok else 1)
port = int(os.environ.get("PORT", 5000))
print(f"http://localhost:{port}")
app.run(host="0.0.0.0", port=port, debug=False)

210
converter.py Normal file
View file

@ -0,0 +1,210 @@
import subprocess
import shutil
import tempfile
from pathlib import Path
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tiff", ".tif", ".ico", ".avif", ".heic", ".heif"}
VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv", ".wmv", ".m4v", ".3gp", ".ts", ".mts", ".mpeg", ".mpg"}
AUDIO_EXTS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".opus", ".aac", ".wma", ".aiff", ".alac"}
ARCHIVE_EXTS = {".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".xz"}
IMAGE_TARGETS = ["png", "jpg", "webp", "bmp", "gif", "tiff", "ico"]
VIDEO_TARGETS = ["mp4", "avi", "mov", "mkv", "webm", "gif"]
VIDEO_TO_AUDIO = ["mp3", "wav", "flac", "aac", "opus"]
AUDIO_TARGETS = ["mp3", "wav", "flac", "ogg", "m4a", "opus", "aac"]
ARCHIVE_TARGETS = ["zip", "tar.gz", "tar.bz2", "7z"]
def detect_type(path: Path) -> str:
name = path.name.lower()
# двойные суффиксы — tar.gz и т.д.
if name.endswith((".tar.gz", ".tar.bz2", ".tar.xz", ".tgz", ".tbz2")):
return "archive"
suf = path.suffix.lower()
if suf in IMAGE_EXTS:
return "image"
if suf in VIDEO_EXTS:
return "video"
if suf in AUDIO_EXTS:
return "audio"
if suf in ARCHIVE_EXTS:
return "archive"
return "unknown"
def get_targets(file_type: str) -> list:
if file_type == "image":
return IMAGE_TARGETS
if file_type == "video":
return VIDEO_TARGETS + VIDEO_TO_AUDIO
if file_type == "audio":
return AUDIO_TARGETS
if file_type == "archive":
return ARCHIVE_TARGETS
return []
def _convert_image(src: Path, dst: Path) -> tuple[bool, str]:
try:
from PIL import Image
img = Image.open(src)
ext = dst.suffix.lower()
# jpeg не понимает прозрачность — заливаем белым
if ext in (".jpg", ".jpeg"):
if img.mode in ("RGBA", "LA", "P"):
bg = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
mask = img.split()[-1] if img.mode in ("RGBA", "LA") else None
bg.paste(img, mask=mask)
img = bg
elif img.mode != "RGB":
img = img.convert("RGB")
if ext == ".ico":
img.save(dst, sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128)])
return True, ""
kwargs = {}
if ext in (".jpg", ".jpeg"):
kwargs["quality"] = 95
elif ext == ".webp":
kwargs["quality"] = 90
img.save(dst, **kwargs)
return True, ""
except Exception as e:
return False, str(e)
def _convert_ffmpeg(src: Path, dst: Path, ffmpeg_bin: str) -> tuple[bool, str]:
ext = dst.suffix.lower()
# gif из видео нормальный — с палитрой
if ext == ".gif":
cmd = [
ffmpeg_bin, "-i", str(src), "-y",
"-vf", "fps=12,scale=640:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
str(dst),
]
else:
cmd = [ffmpeg_bin, "-i", str(src), "-y"]
if ext == ".mp3":
cmd += ["-codec:a", "libmp3lame", "-qscale:a", "2", "-vn"]
elif ext == ".flac":
cmd += ["-codec:a", "flac", "-vn"]
elif ext == ".ogg":
cmd += ["-codec:a", "libvorbis", "-qscale:a", "6", "-vn"]
elif ext == ".opus":
cmd += ["-codec:a", "libopus", "-b:a", "192k", "-vn"]
elif ext == ".aac":
cmd += ["-codec:a", "aac", "-b:a", "256k", "-vn"]
elif ext == ".wav":
cmd += ["-codec:a", "pcm_s16le", "-vn"]
elif ext == ".m4a":
cmd += ["-codec:a", "aac", "-b:a", "256k", "-vn"]
cmd.append(str(dst))
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if r.returncode != 0:
return False, r.stderr[-1000:]
return True, ""
except subprocess.TimeoutExpired:
return False, "таймаут конвертации"
except FileNotFoundError:
return False, "ffmpeg не найден — запусти setup-ffmpeg или поставь через пакетный менеджер"
except Exception as e:
return False, str(e)
def _convert_archive(src: Path, dst: Path) -> tuple[bool, str]:
import zipfile
import tarfile
tmp = Path(tempfile.mkdtemp())
try:
src_name = src.name.lower()
# распаковываем что пришло
if src_name.endswith(".zip"):
with zipfile.ZipFile(src) as z:
z.extractall(tmp)
elif src_name.endswith((".tar.gz", ".tgz")):
with tarfile.open(src, "r:gz") as t:
t.extractall(tmp)
elif src_name.endswith((".tar.bz2", ".tbz2")):
with tarfile.open(src, "r:bz2") as t:
t.extractall(tmp)
elif src_name.endswith((".tar.xz", ".txz")):
with tarfile.open(src, "r:xz") as t:
t.extractall(tmp)
elif src_name.endswith(".tar"):
with tarfile.open(src, "r:") as t:
t.extractall(tmp)
elif src_name.endswith(".7z"):
import py7zr
with py7zr.SevenZipFile(src, mode="r") as z:
z.extractall(tmp)
elif src_name.endswith(".rar"):
import rarfile
with rarfile.RarFile(src) as r:
r.extractall(tmp)
else:
return False, "неизвестный формат архива"
# пакуем в нужное
dst_name = dst.name.lower()
if dst_name.endswith(".zip"):
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as z:
for f in tmp.rglob("*"):
if f.is_file():
z.write(f, f.relative_to(tmp))
elif dst_name.endswith(".tar.gz"):
with tarfile.open(dst, "w:gz") as t:
for f in tmp.rglob("*"):
if f.is_file():
t.add(f, arcname=str(f.relative_to(tmp)))
elif dst_name.endswith(".tar.bz2"):
with tarfile.open(dst, "w:bz2") as t:
for f in tmp.rglob("*"):
if f.is_file():
t.add(f, arcname=str(f.relative_to(tmp)))
elif dst_name.endswith(".7z"):
import py7zr
with py7zr.SevenZipFile(dst, "w") as z:
for f in tmp.rglob("*"):
if f.is_file():
z.write(f, str(f.relative_to(tmp)))
else:
return False, "неизвестный целевой формат"
return True, ""
except Exception as e:
return False, str(e)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def convert(src: Path, dst: Path, target: str, ffmpeg_bin: str = "ffmpeg") -> tuple[bool, str]:
file_type = detect_type(src)
if file_type == "image":
return _convert_image(src, dst)
if file_type in ("video", "audio"):
return _convert_ffmpeg(src, dst, ffmpeg_bin)
if file_type == "archive":
return _convert_archive(src, dst)
# хуй знает что за файл — пусть ffmpeg попробует
return _convert_ffmpeg(src, dst, ffmpeg_bin)

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
flask
pillow
py7zr
rarfile

214
static/app.js Normal file
View file

@ -0,0 +1,214 @@
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const setupBtn = document.getElementById('setupFfmpegBtn');
const TYPE_ICONS = {
image: '🖼️',
video: '🎬',
audio: '🎵',
archive: '📦',
unknown: '📄',
};
// drag & drop
dropzone.addEventListener('dragover', e => {
e.preventDefault();
dropzone.classList.add('drag-over');
});
dropzone.addEventListener('dragleave', e => {
if (!dropzone.contains(e.relatedTarget)) {
dropzone.classList.remove('drag-over');
}
});
dropzone.addEventListener('drop', e => {
e.preventDefault();
dropzone.classList.remove('drag-over');
handleFiles(Array.from(e.dataTransfer.files));
});
// клик на зону — открываем файловый диалог
dropzone.addEventListener('click', e => {
if (e.target.tagName === 'LABEL' || e.target.closest('label')) return;
fileInput.click();
});
fileInput.addEventListener('change', () => {
handleFiles(Array.from(fileInput.files));
fileInput.value = '';
});
// ctrl+v — вставка картинки из буфера (скриншоты и т.д.)
document.addEventListener('paste', e => {
const items = Array.from(e.clipboardData?.items || []);
const files = items
.filter(i => i.kind === 'file')
.map(i => i.getAsFile())
.filter(Boolean);
if (files.length > 0) handleFiles(files);
});
function handleFiles(files) {
if (files.length === 0) return;
fileList.classList.remove('hidden');
files.forEach(f => uploadFile(f));
}
async function uploadFile(file) {
const card = makeCard(file.name, 'unknown');
fileList.prepend(card);
const status = card.querySelector('.file-status');
const fd = new FormData();
fd.append('file', file);
let data;
try {
const r = await fetch('/api/upload', { method: 'POST', body: fd });
data = await r.json();
if (!r.ok) throw new Error(data.error || r.statusText);
} catch (err) {
setStatus(status, 'ошибка: ' + err.message, 'error');
return;
}
// обновляем иконку и мета-инфо
const icon = card.querySelector('.file-icon');
icon.textContent = TYPE_ICONS[data.type] || '📄';
icon.className = `file-icon ${data.type}`;
card.querySelector('.file-meta').textContent = data.type + ' · ' + fmtSize(file.size);
if (data.targets.length === 0) {
setStatus(status, 'формат не поддерживается', 'error');
return;
}
// строим select + кнопку конвертации
const actions = card.querySelector('.file-actions');
actions.innerHTML = '';
const sel = document.createElement('select');
sel.className = 'format-select';
data.targets.forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
sel.appendChild(opt);
});
const btn = document.createElement('button');
btn.className = 'btn btn-primary';
btn.textContent = 'конвертировать';
btn.onclick = () => doConvert(data, sel.value, card);
setStatus(status, 'готов');
actions.appendChild(sel);
actions.appendChild(btn);
}
async function doConvert(uploadData, target, card) {
const btn = card.querySelector('.btn-primary');
const status = card.querySelector('.file-status');
btn.disabled = true;
status.innerHTML = '<span class="spinner"></span>конвертирую...';
status.className = 'file-status';
// берём актуальное значение select на момент нажатия
const sel = card.querySelector('.format-select');
const actualTarget = sel ? sel.value : target;
let result;
try {
const r = await fetch('/api/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: uploadData.id,
saved: uploadData.saved,
target: actualTarget,
filename: uploadData.filename,
}),
});
result = await r.json();
if (!r.ok) throw new Error(result.error || r.statusText);
} catch (err) {
const msg = err.message.slice(0, 220);
setStatus(status, 'ошибка: ' + msg, 'error');
btn.disabled = false;
return;
}
setStatus(status, 'готово', 'success');
btn.disabled = false;
// кнопка скачать — заменяем если уже есть
const actions = card.querySelector('.file-actions');
actions.querySelector('.btn-download')?.remove();
const dl = document.createElement('a');
dl.href = '/api/download/' + encodeURIComponent(result.result);
dl.download = result.filename;
dl.className = 'btn btn-download';
dl.textContent = 'скачать';
actions.appendChild(dl);
}
function makeCard(filename, type) {
const card = document.createElement('div');
card.className = 'file-card';
card.innerHTML = `
<div class="file-icon ${type}">${TYPE_ICONS[type] || '📄'}</div>
<div class="file-info">
<div class="file-name" title="${escHtml(filename)}">${escHtml(filename)}</div>
<div class="file-meta">загрузка...</div>
</div>
<div class="file-actions"></div>
<div class="file-status">—</div>
`;
return card;
}
function setStatus(el, text, cls = '') {
el.textContent = text;
el.className = 'file-status' + (cls ? ' ' + cls : '');
}
function fmtSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 ** 3) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
return (bytes / 1024 ** 3).toFixed(2) + ' GB';
}
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// кнопка setup ffmpeg (только на windows, показывается в шапке)
if (setupBtn) {
setupBtn.addEventListener('click', async () => {
setupBtn.textContent = 'качаю...';
setupBtn.disabled = true;
try {
const r = await fetch('/api/setup-ffmpeg', { method: 'POST' });
const d = await r.json();
if (d.ffmpeg) {
setupBtn.closest('.ffmpeg-warn').textContent = '✓ ffmpeg готов — перезагрузи страницу';
} else {
setupBtn.textContent = d.msg || 'ошибка';
setupBtn.disabled = false;
}
} catch (e) {
setupBtn.textContent = 'ошибка сети';
setupBtn.disabled = false;
}
});
}

326
static/style.css Normal file
View file

@ -0,0 +1,326 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--blue: #1a73e8;
--blue-hover: #1557b0;
--green: #0f9d58;
--green-hover: #0b8043;
--red: #d93025;
--bg: #f8f9fa;
--border: #dadce0;
--muted: #5f6368;
--soft: #9aa0a6;
--text: #202124;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
}
/* шапка */
header {
background: #fff;
border-bottom: 1px solid var(--border);
padding: 13px 24px;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.logo {
font-size: 19px;
font-weight: 500;
letter-spacing: -0.3px;
line-height: 1;
}
.logo .n { color: #4285f4; }
.logo .o { color: #ea4335; }
.logo .d { color: #fbbc04; }
.logo .e { color: #4285f4; }
.logo .x { color: #34a853; }
.logo .dot { color: #ea4335; }
.logo .pw { color: #34a853; }
.logo .sep { color: var(--border); margin: 0 6px; font-weight: 300; }
.logo .name { color: var(--muted); font-weight: 400; font-size: 16px; }
.ffmpeg-warn {
font-size: 12px;
color: #7a5c00;
background: #fef9c3;
padding: 6px 12px;
border-radius: 4px;
border: 1px solid #f5c400;
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.link-btn {
background: none;
border: none;
color: var(--blue);
cursor: pointer;
font-size: inherit;
padding: 0;
text-decoration: underline;
font-family: inherit;
}
.link-btn:disabled {
color: var(--muted);
cursor: default;
text-decoration: none;
}
code {
font-family: 'Roboto Mono', 'Consolas', monospace;
font-size: 11px;
background: #f1f3f4;
padding: 2px 5px;
border-radius: 3px;
color: var(--text);
}
/* основная область */
main {
max-width: 860px;
margin: 40px auto;
padding: 0 16px 60px;
}
/* зона загрузки */
.dropzone {
background: #fff;
border: 2px dashed var(--border);
border-radius: 12px;
padding: 56px 40px 48px;
text-align: center;
cursor: pointer;
transition: border-color .18s, background .18s;
user-select: none;
}
.dropzone:hover,
.dropzone.drag-over {
border-color: var(--blue);
background: #f0f6ff;
}
.dropzone.drag-over .drop-icon {
color: var(--blue);
transform: translateY(-4px);
}
.drop-icon {
color: #c9cdd0;
margin-bottom: 14px;
transition: color .18s, transform .18s;
display: inline-block;
}
.drop-title {
font-size: 17px;
font-weight: 500;
color: var(--text);
margin-bottom: 8px;
}
.drop-sub {
font-size: 13px;
color: var(--muted);
margin-bottom: 14px;
}
.file-label {
color: var(--blue);
cursor: pointer;
text-decoration: underline;
}
.drop-formats {
font-size: 11px;
color: var(--soft);
letter-spacing: .2px;
}
/* список файлов */
.file-list {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* карточка файла */
.file-card {
background: #fff;
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 18px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
transition: box-shadow .15s;
}
.file-card:hover {
box-shadow: 0 1px 6px rgba(0,0,0,.08);
}
.file-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.file-icon.image { background: #e8f5e9; }
.file-icon.video { background: #fce4ec; }
.file-icon.audio { background: #e3f2fd; }
.file-icon.archive { background: #fff8e1; }
.file-icon.unknown { background: #f3e5f5; }
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
/* действия */
.file-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.format-select {
height: 34px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
color: var(--text);
background: #fff;
cursor: pointer;
outline: none;
font-family: inherit;
transition: border-color .15s;
}
.format-select:focus {
border-color: var(--blue);
}
/* кнопки */
.btn {
height: 34px;
padding: 0 18px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
letter-spacing: .2px;
transition: background .15s;
text-decoration: none;
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.btn-primary {
background: var(--blue);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--blue-hover);
}
.btn-primary:disabled {
background: #e0e0e0;
color: var(--muted);
cursor: not-allowed;
}
.btn-download {
background: var(--green);
color: #fff;
}
.btn-download:hover {
background: var(--green-hover);
}
/* статус */
.file-status {
font-size: 11px;
color: var(--muted);
min-width: 80px;
}
.file-status.error {
color: var(--red);
}
.file-status.success {
color: var(--green);
}
/* спиннер */
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #e0e0e0;
border-top-color: var(--blue);
border-radius: 50%;
animation: spin .65s linear infinite;
vertical-align: middle;
margin-right: 4px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.hidden { display: none !important; }
/* адаптив */
@media (max-width: 600px) {
main { margin: 20px auto; }
.dropzone { padding: 40px 20px; }
.file-card { flex-direction: column; align-items: flex-start; }
.file-actions { width: 100%; }
}

50
templates/index.html Normal file
View file

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nodex.pw Converter</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<div class="logo">
<span class="n">N</span><span class="o">o</span><span class="d">d</span><span class="e">e</span><span class="x">x</span><span class="dot">.</span><span class="pw">pw</span>
<span class="sep">/</span>
<span class="name">converter</span>
</div>
{% if not ffmpeg_ready %}
<div class="ffmpeg-warn">
ffmpeg не найден —
<button id="setupFfmpegBtn" class="link-btn">скачать автоматически (Windows)</button>
&nbsp;или&nbsp; <code>apt install ffmpeg</code> / <code>brew install ffmpeg</code>
</div>
{% endif %}
</header>
<main>
<div id="dropzone" class="dropzone">
<div class="drop-icon">
<svg viewBox="0 0 24 24" width="44" height="44" fill="none">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" fill="currentColor" opacity=".3"/>
<path d="M14 13v4h-4v-4H7l5-5 5 5h-3z" fill="currentColor"/>
</svg>
</div>
<p class="drop-title">перетащи файлы сюда</p>
<p class="drop-sub">или <label for="fileInput" class="file-label">выбери файлы</label> &nbsp;·&nbsp; ctrl+v чтобы вставить</p>
<input type="file" id="fileInput" multiple hidden>
<p class="drop-formats">изображения &nbsp;·&nbsp; видео &nbsp;·&nbsp; аудио &nbsp;·&nbsp; архивы &nbsp;·&nbsp; webp &nbsp;·&nbsp; и всё остальное</p>
</div>
<div id="fileList" class="file-list hidden"></div>
</main>
<script>
window.FFMPEG_READY = {{ 'true' if ffmpeg_ready else 'false' }};
</script>
<script src="/static/app.js"></script>
</body>
</html>