feat: add rename and delete functionality with improved ui
This commit is contained in:
+422
-147
@@ -6,12 +6,17 @@ import os
|
||||
import uuid
|
||||
import json
|
||||
import time
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "/var/lib/droplify"))
|
||||
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8080")
|
||||
PORT = int(os.environ.get("PORT", "8080"))
|
||||
|
||||
FAVICON_SVG = """<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'><rect width='96' height='96' rx='22' fill='%236366f1'/><path d='M48 18C48 18 28 42 28 56C28 67.6 37.4 77 49 77C60.6 77 70 67.6 70 56C70 42 50 18 48 18Z' fill='white' opacity='.95'/><line x1='48' y1='64' x2='48' y2='38' stroke='%236366f1' stroke-width='4.5' stroke-linecap='round'/><polyline points='40,47 48,37 56,47' fill='none' stroke='%236366f1' stroke-width='4.5' stroke-linecap='round' stroke-linejoin='round'/></svg>"""
|
||||
|
||||
OG_IMAGE_SVG = """<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 630'><rect width='1200' height='630' fill='%23fafaf8'/><rect x='504' y='219' width='192' height='192' rx='44' fill='%236366f1'/><path d='M600 255C600 255 564 303 564 327C564 348.2 580.8 366 602 366C623.2 366 640 348.2 640 327C640 303 602 255 600 255Z' fill='white' opacity='.95'/><line x1='600' y1='350' x2='600' y2='298' stroke='%236366f1' stroke-width='9' stroke-linecap='round'/><polyline points='584,314 600,296 616,314' fill='none' stroke='%236366f1' stroke-width='9' stroke-linecap='round' stroke-linejoin='round'/><text x='600' y='462' font-family='monospace' font-size='64' font-weight='700' fill='%231a1a1a' text-anchor='middle' letter-spacing='-1'>drop</text><text x='756' y='462' font-family='monospace' font-size='64' font-weight='700' fill='%236366f1' text-anchor='start' letter-spacing='-1'>lify</text></svg>"""
|
||||
|
||||
|
||||
def parse_multipart(fp, boundary: str, content_length: int) -> dict:
|
||||
data = fp.read(content_length)
|
||||
@@ -42,10 +47,24 @@ def parse_multipart(fp, boundary: str, content_length: int) -> dict:
|
||||
if item.startswith("filename="):
|
||||
filename = item[9:].strip('"')
|
||||
if name:
|
||||
fields[name] = {"data": body, "filename": filename or "index.html"}
|
||||
fields[name] = {"data": body, "filename": filename or ""}
|
||||
return fields
|
||||
|
||||
|
||||
def read_meta(slug_dir: Path) -> dict:
|
||||
meta_path = slug_dir / "meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
return json.loads(meta_path.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def write_meta(slug_dir: Path, meta: dict):
|
||||
(slug_dir / "meta.json").write_text(json.dumps(meta))
|
||||
|
||||
|
||||
def list_files():
|
||||
entries = []
|
||||
if not DATA_DIR.exists():
|
||||
@@ -53,31 +72,39 @@ def list_files():
|
||||
for slug_dir in sorted(DATA_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
if not slug_dir.is_dir():
|
||||
continue
|
||||
index = slug_dir / "index.html"
|
||||
if not index.exists():
|
||||
if not (slug_dir / "index.html").exists():
|
||||
continue
|
||||
meta_path = slug_dir / "meta.json"
|
||||
meta = {}
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
meta = read_meta(slug_dir)
|
||||
entries.append({
|
||||
"slug": slug_dir.name,
|
||||
"url": f"{BASE_URL}/{slug_dir.name}",
|
||||
"filename": meta.get("filename", "index.html"),
|
||||
"name": meta.get("name", meta.get("filename", slug_dir.name)),
|
||||
"uploaded_at": meta.get("uploaded_at", int(slug_dir.stat().st_mtime)),
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>droplify</title>
|
||||
<meta name="description" content="Self-hosted HTML file hosting. Drop a file, get a URL.">
|
||||
|
||||
<!-- Open Graph / bookmark preview -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="droplify">
|
||||
<meta property="og:description" content="Self-hosted HTML file hosting. Drop a file, get a URL.">
|
||||
<meta property="og:image" content="/og-image.svg">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:image" content="/og-image.svg">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;500&display=swap');
|
||||
|
||||
@@ -89,6 +116,7 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
--accent2: #6366f1;
|
||||
--muted: #9e9b93;
|
||||
--success: #16a34a;
|
||||
--danger: #dc2626;
|
||||
--radius: 6px;
|
||||
}
|
||||
|
||||
@@ -108,7 +136,7 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
header {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
padding: 18px 32px;
|
||||
padding: 16px 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
@@ -117,9 +145,14 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 28px; height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
@@ -128,7 +161,7 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
|
||||
.badge {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
font-size: 0.63rem;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
padding: 2px 6px;
|
||||
@@ -140,31 +173,22 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
display: grid;
|
||||
grid-template-columns: 340px 1fr;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 700px) { .layout { grid-template-columns: 1fr; } }
|
||||
|
||||
/* --- LEFT PANEL --- */
|
||||
.left { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.left { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
h1 { font-size: 1.35rem; font-weight: 500; letter-spacing: -0.03em; line-height: 1.25; }
|
||||
h1 em { font-style: normal; color: var(--muted); }
|
||||
|
||||
#dropzone {
|
||||
border: 1.5px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px 24px;
|
||||
padding: 36px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
@@ -172,19 +196,75 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#dropzone:hover, #dropzone.over {
|
||||
border-color: var(--accent2);
|
||||
background: #f5f5ff;
|
||||
}
|
||||
|
||||
#dropzone:hover, #dropzone.over { border-color: var(--accent2); background: #f5f5ff; }
|
||||
#dropzone.over { border-style: solid; }
|
||||
|
||||
.drop-icon { font-size: 1.8rem; margin-bottom: 10px; display: block; opacity: 0.4; }
|
||||
.drop-label { font-size: 0.9rem; font-weight: 500; margin-bottom: 4px; }
|
||||
.drop-sub { font-size: 0.78rem; color: var(--muted); }
|
||||
.drop-label { font-size: 0.88rem; font-weight: 500; margin-bottom: 4px; }
|
||||
.drop-sub { font-size: 0.76rem; color: var(--muted); }
|
||||
|
||||
input[type=file] { display: none; }
|
||||
|
||||
#confirm-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
#confirm-panel.visible { display: flex; }
|
||||
|
||||
.confirm-file { font-size: 0.8rem; color: var(--muted); display: flex; align-items: center; gap: 6px; }
|
||||
.confirm-file strong { color: var(--accent); font-weight: 500; }
|
||||
|
||||
.name-row { display: flex; gap: 8px; }
|
||||
|
||||
.name-input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.84rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--accent);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.name-input:focus { border-color: var(--accent2); }
|
||||
|
||||
.btn-upload {
|
||||
padding: 7px 14px;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
background: var(--accent2);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-upload:hover { opacity: 0.85; }
|
||||
|
||||
.btn-cancel-upload {
|
||||
padding: 7px 10px;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel-upload:hover { color: var(--accent); }
|
||||
|
||||
#status {
|
||||
font-size: 0.82rem;
|
||||
padding: 10px 14px;
|
||||
@@ -199,47 +279,22 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
#status.visible { display: flex; }
|
||||
#status.loading { color: var(--muted); }
|
||||
#status.success { border-color: #bbf7d0; background: #f0fdf4; color: var(--success); }
|
||||
#status.error { border-color: #fecaca; background: #fef2f2; color: #dc2626; }
|
||||
#status.error { border-color: #fecaca; background: #fef2f2; color: var(--danger); }
|
||||
#status a { font-family: 'Space Mono', monospace; font-size: 0.74rem; color: var(--accent2); text-decoration: none; word-break: break-all; }
|
||||
|
||||
#status a {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent2);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* --- RIGHT PANEL --- */
|
||||
.right { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
|
||||
.panel-title {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel-title { font-family: 'Space Mono', monospace; font-size: 0.7rem; color: var(--muted); letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
|
||||
.count-badge {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
background: var(--border);
|
||||
color: var(--muted);
|
||||
padding: 2px 7px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
.count-badge { font-family: 'Space Mono', monospace; font-size: 0.63rem; background: var(--border); color: var(--muted); padding: 2px 7px; border-radius: 99px; }
|
||||
|
||||
#search {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.84rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
@@ -251,15 +306,7 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
#search:focus { border-color: var(--accent2); }
|
||||
#search::placeholder { color: var(--muted); }
|
||||
|
||||
#file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
#file-list { display: flex; flex-direction: column; gap: 6px; max-height: 62vh; overflow-y: auto; padding-right: 2px; }
|
||||
#file-list::-webkit-scrollbar { width: 4px; }
|
||||
#file-list::-webkit-scrollbar-track { background: transparent; }
|
||||
#file-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
@@ -279,8 +326,8 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
|
||||
.file-info { flex: 1; min-width: 0; }
|
||||
|
||||
.file-name {
|
||||
font-size: 0.85rem;
|
||||
.file-name-display {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -288,17 +335,31 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
.file-name-edit {
|
||||
display: none;
|
||||
width: 100%;
|
||||
padding: 3px 6px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--accent2);
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
color: var(--accent);
|
||||
outline: none;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.file-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.file-item.renaming .file-name-display { display: none; }
|
||||
.file-item.renaming .file-name-edit { display: block; }
|
||||
|
||||
.file-meta { font-family: 'Space Mono', monospace; font-size: 0.66rem; color: var(--muted); }
|
||||
|
||||
.file-actions { display: flex; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
|
||||
.btn-small {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
font-size: 0.63rem;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
@@ -313,111 +374,254 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
|
||||
.btn-small:hover { background: var(--border); }
|
||||
.btn-small.copied { color: var(--success); border-color: #bbf7d0; }
|
||||
.btn-small.rename-ok { color: var(--success); border-color: #bbf7d0; background: #f0fdf4; }
|
||||
.btn-small.cancel-r { color: var(--muted); }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
.btn-delete {
|
||||
color: var(--danger);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 1.5rem; margin-bottom: 8px; opacity: 0.3; }
|
||||
.btn-delete:hover { background: #fef2f2; }
|
||||
|
||||
.btn-delete.confirming {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.empty { text-align: center; padding: 40px 0; color: var(--muted); font-size: 0.84rem; }
|
||||
.empty-icon { font-size: 1.4rem; margin-bottom: 8px; opacity: 0.3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<svg class="logo-icon" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="96" height="96" rx="22" fill="#6366f1"/>
|
||||
<path d="M48 18C48 18 28 42 28 56C28 67.6 37.4 77 49 77C60.6 77 70 67.6 70 56C70 42 50 18 48 18Z" fill="white" opacity=".95"/>
|
||||
<line x1="48" y1="64" x2="48" y2="38" stroke="#6366f1" stroke-width="4.5" stroke-linecap="round"/>
|
||||
<polyline points="40,47 48,37 56,47" fill="none" stroke="#6366f1" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="logo">drop<span>lify</span></span>
|
||||
<span class="badge">SELF-HOSTED</span>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="left">
|
||||
<h1>Glisse un fichier HTML<br><em>obtiens une URL.</em></h1>
|
||||
<h1>Drop an HTML file,<br><em>get a URL.</em></h1>
|
||||
|
||||
<div id="dropzone">
|
||||
<span class="drop-icon">⬆</span>
|
||||
<div class="drop-label">Glisse ici ou clique</div>
|
||||
<div class="drop-sub">.html · .htm</div>
|
||||
<div class="drop-label">Drop here or click to browse</div>
|
||||
<div class="drop-sub">.html · .htm</div>
|
||||
</div>
|
||||
<input type="file" id="file" accept=".html,.htm">
|
||||
|
||||
<div id="confirm-panel">
|
||||
<div class="confirm-file">📄 <strong id="confirm-filename"></strong></div>
|
||||
<div class="name-row">
|
||||
<input type="text" class="name-input" id="upload-name" placeholder="Display name…">
|
||||
<button class="btn-upload" id="btn-confirm-upload">Host it</button>
|
||||
<button class="btn-cancel-upload" id="btn-cancel-upload">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Fichiers hébergés</span>
|
||||
<span class="panel-title">Hosted files</span>
|
||||
<span class="count-badge" id="count">0</span>
|
||||
</div>
|
||||
<input type="text" id="search" placeholder="Filtrer par nom…">
|
||||
<input type="text" id="search" placeholder="Filter by name…">
|
||||
<div id="file-list">
|
||||
<div class="empty"><div class="empty-icon">📭</div>Aucun fichier encore</div>
|
||||
<div class="empty"><div class="empty-icon">📙</div>No files yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
const input = document.getElementById('file');
|
||||
const status = document.getElementById('status');
|
||||
const fileList = document.getElementById('file-list');
|
||||
const countEl = document.getElementById('count');
|
||||
const search = document.getElementById('search');
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
const fileInput = document.getElementById('file');
|
||||
const status = document.getElementById('status');
|
||||
const fileList = document.getElementById('file-list');
|
||||
const countEl = document.getElementById('count');
|
||||
const search = document.getElementById('search');
|
||||
const confirmPanel = document.getElementById('confirm-panel');
|
||||
const confirmName = document.getElementById('confirm-filename');
|
||||
const uploadName = document.getElementById('upload-name');
|
||||
const btnConfirm = document.getElementById('btn-confirm-upload');
|
||||
const btnCancel = document.getElementById('btn-cancel-upload');
|
||||
|
||||
let allFiles = [];
|
||||
let allFiles = [];
|
||||
let pendingFile = null;
|
||||
|
||||
dropzone.addEventListener('click', () => input.click());
|
||||
dropzone.addEventListener('click', () => fileInput.click());
|
||||
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('over'); });
|
||||
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('over'));
|
||||
dropzone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('over');
|
||||
upload(e.dataTransfer.files[0]);
|
||||
});
|
||||
input.addEventListener('change', () => upload(input.files[0]));
|
||||
search.addEventListener('input', () => renderList(allFiles));
|
||||
dropzone.addEventListener('drop', e => { e.preventDefault(); dropzone.classList.remove('over'); stage(e.dataTransfer.files[0]); });
|
||||
fileInput.addEventListener('change', () => stage(fileInput.files[0]));
|
||||
|
||||
function stage(file) {
|
||||
if (!file) return;
|
||||
pendingFile = file;
|
||||
confirmName.textContent = file.name;
|
||||
uploadName.value = file.name.replace(/\.html?$/i, '');
|
||||
confirmPanel.classList.add('visible');
|
||||
uploadName.focus();
|
||||
uploadName.select();
|
||||
setStatus('', '');
|
||||
}
|
||||
|
||||
function clearStage() {
|
||||
pendingFile = null;
|
||||
confirmPanel.classList.remove('visible');
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
btnCancel.addEventListener('click', clearStage);
|
||||
uploadName.addEventListener('keydown', e => { if (e.key === 'Enter') btnConfirm.click(); if (e.key === 'Escape') clearStage(); });
|
||||
btnConfirm.addEventListener('click', () => { if (pendingFile) upload(pendingFile, uploadName.value.trim() || pendingFile.name); });
|
||||
|
||||
function setStatus(type, html) {
|
||||
if (!type) { status.className = ''; status.innerHTML = ''; return; }
|
||||
status.className = 'visible ' + type;
|
||||
status.innerHTML = html;
|
||||
}
|
||||
|
||||
function fmtDate(ts) {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: '2-digit' })
|
||||
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: '2-digit' })
|
||||
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function renderList(files) {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const filtered = q ? files.filter(f => f.filename.toLowerCase().includes(q) || f.slug.includes(q)) : files;
|
||||
const filtered = q ? files.filter(f => f.name.toLowerCase().includes(q) || f.slug.includes(q)) : files;
|
||||
countEl.textContent = files.length;
|
||||
|
||||
if (!filtered.length) {
|
||||
fileList.innerHTML = '<div class="empty"><div class="empty-icon">🔍</div>Aucun résultat</div>';
|
||||
fileList.innerHTML = files.length
|
||||
? '<div class="empty"><div class="empty-icon">🔍</div>No results</div>'
|
||||
: '<div class="empty"><div class="empty-icon">📙</div>No files yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
fileList.innerHTML = filtered.map(f => `
|
||||
<div class="file-item">
|
||||
fileList.innerHTML = '';
|
||||
filtered.forEach(f => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'file-item';
|
||||
item.dataset.slug = f.slug;
|
||||
item.innerHTML = `
|
||||
<div class="file-info">
|
||||
<div class="file-name" title="${f.filename}">${f.filename}</div>
|
||||
<div class="file-meta">${f.slug} · ${fmtDate(f.uploaded_at)}</div>
|
||||
<div class="file-name-display" title="${f.name}">${f.name}</div>
|
||||
<input type="text" class="file-name-edit" value="${f.name}">
|
||||
<div class="file-meta">${f.slug} · ${fmtDate(f.uploaded_at)}</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<a class="btn-small" href="${f.url}" target="_blank">ouvrir</a>
|
||||
<button class="btn-small" onclick="copyUrl('${f.url}', this)">copy</button>
|
||||
<a class="btn-small" href="${f.url}" target="_blank">open</a>
|
||||
<button class="btn-small btn-copy" data-url="${f.url}">copy</button>
|
||||
<button class="btn-small btn-rename-toggle">rename</button>
|
||||
<button class="btn-small rename-ok" style="display:none">✓</button>
|
||||
<button class="btn-small cancel-r btn-rename-cancel" style="display:none">✕</button>
|
||||
<button class="btn-small btn-delete" data-slug="${f.slug}">delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
|
||||
const editInput = item.querySelector('.file-name-edit');
|
||||
const display = item.querySelector('.file-name-display');
|
||||
const btnToggle = item.querySelector('.btn-rename-toggle');
|
||||
const btnOk = item.querySelector('.rename-ok');
|
||||
const btnCancelR = item.querySelector('.btn-rename-cancel');
|
||||
const btnCopy = item.querySelector('.btn-copy');
|
||||
const btnDel = item.querySelector('.btn-delete');
|
||||
|
||||
// copy
|
||||
btnCopy.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(f.url);
|
||||
btnCopy.textContent = '\u2713';
|
||||
btnCopy.classList.add('copied');
|
||||
setTimeout(() => { btnCopy.textContent = 'copy'; btnCopy.classList.remove('copied'); }, 1500);
|
||||
});
|
||||
|
||||
// rename
|
||||
function startRename() {
|
||||
item.classList.add('renaming');
|
||||
btnToggle.style.display = 'none';
|
||||
btnOk.style.display = 'inline-flex';
|
||||
btnCancelR.style.display = 'inline-flex';
|
||||
btnDel.style.display = 'none';
|
||||
editInput.focus();
|
||||
editInput.select();
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
item.classList.remove('renaming');
|
||||
editInput.value = display.textContent;
|
||||
btnToggle.style.display = 'inline-flex';
|
||||
btnOk.style.display = 'none';
|
||||
btnCancelR.style.display = 'none';
|
||||
btnDel.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
async function confirmRename() {
|
||||
const newName = editInput.value.trim();
|
||||
if (!newName || newName === display.textContent) { cancelRename(); return; }
|
||||
try {
|
||||
const res = await fetch('/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: f.slug, name: newName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) { display.textContent = newName; f.name = newName; }
|
||||
} catch (e) {}
|
||||
cancelRename();
|
||||
}
|
||||
|
||||
btnToggle.addEventListener('click', startRename);
|
||||
btnOk.addEventListener('click', confirmRename);
|
||||
btnCancelR.addEventListener('click', cancelRename);
|
||||
editInput.addEventListener('keydown', e => { if (e.key === 'Enter') confirmRename(); if (e.key === 'Escape') cancelRename(); });
|
||||
|
||||
// delete (two-click confirmation)
|
||||
let deleteTimer = null;
|
||||
|
||||
btnDel.addEventListener('click', async () => {
|
||||
if (!btnDel.classList.contains('confirming')) {
|
||||
btnDel.classList.add('confirming');
|
||||
btnDel.textContent = 'sure?';
|
||||
deleteTimer = setTimeout(() => {
|
||||
btnDel.classList.remove('confirming');
|
||||
btnDel.textContent = 'delete';
|
||||
}, 2500);
|
||||
return;
|
||||
}
|
||||
clearTimeout(deleteTimer);
|
||||
try {
|
||||
const res = await fetch('/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: f.slug }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
item.style.opacity = '0';
|
||||
item.style.transition = 'opacity 0.2s';
|
||||
setTimeout(() => {
|
||||
allFiles = allFiles.filter(x => x.slug !== f.slug);
|
||||
renderList(allFiles);
|
||||
}, 200);
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
fileList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function copyUrl(url, btn) {
|
||||
navigator.clipboard.writeText(url);
|
||||
btn.textContent = '✓';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('copied'); }, 1500);
|
||||
}
|
||||
search.addEventListener('input', () => renderList(allFiles));
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
@@ -425,29 +629,30 @@ UPLOAD_PAGE = """<!DOCTYPE html>
|
||||
allFiles = await res.json();
|
||||
renderList(allFiles);
|
||||
} catch (e) {
|
||||
fileList.innerHTML = '<div class="empty">Erreur de chargement</div>';
|
||||
fileList.innerHTML = '<div class="empty">Failed to load files</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(file) {
|
||||
if (!file) return;
|
||||
setStatus('loading', '<span>Upload en cours…</span>');
|
||||
async function upload(file, name) {
|
||||
setStatus('loading', '<span>Uploading…</span>');
|
||||
clearStage();
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('name', name);
|
||||
try {
|
||||
const res = await fetch('/upload', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
setStatus('success',
|
||||
`<span>Hébergé :</span> <a href="${data.url}" target="_blank">${data.url}</a>
|
||||
<button class="btn-small" style="margin-left:auto" onclick="navigator.clipboard.writeText('${data.url}');this.textContent='✓'">copy</button>`
|
||||
`<span>Hosted:</span> <a href="${data.url}" target="_blank">${data.url}</a>
|
||||
<button class="btn-small" style="margin-left:auto" onclick="navigator.clipboard.writeText('${data.url}');this.textContent='\u2713'">copy</button>`
|
||||
);
|
||||
await loadFiles();
|
||||
} else {
|
||||
setStatus('error', `<span>Erreur : ${data.error || 'inconnue'}</span>`);
|
||||
setStatus('error', `<span>Error: ${data.error || 'unknown'}</span>`);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus('error', '<span>Erreur réseau</span>');
|
||||
setStatus('error', '<span>Network error</span>');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,9 +673,16 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
self._respond(200, "text/html", UPLOAD_PAGE.encode())
|
||||
return
|
||||
|
||||
if path == "favicon.svg":
|
||||
self._respond(200, "image/svg+xml", FAVICON_SVG.encode())
|
||||
return
|
||||
|
||||
if path == "og-image.svg":
|
||||
self._respond(200, "image/svg+xml", OG_IMAGE_SVG.encode())
|
||||
return
|
||||
|
||||
if path == "files":
|
||||
body = json.dumps(list_files()).encode()
|
||||
self._respond(200, "application/json", body)
|
||||
self._respond(200, "application/json", json.dumps(list_files()).encode())
|
||||
return
|
||||
|
||||
parts = path.split("/", 1)
|
||||
@@ -486,10 +698,16 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
self._respond(200, ctype, fpath.read_bytes())
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/upload":
|
||||
if self.path == "/upload":
|
||||
self._handle_upload()
|
||||
elif self.path == "/rename":
|
||||
self._handle_rename()
|
||||
elif self.path == "/delete":
|
||||
self._handle_delete()
|
||||
else:
|
||||
self._respond(404, "text/plain", b"Not found")
|
||||
return
|
||||
|
||||
def _handle_upload(self):
|
||||
ct = self.headers.get("Content-Type", "")
|
||||
if "multipart/form-data" not in ct:
|
||||
self._json(400, {"error": "multipart required"})
|
||||
@@ -502,7 +720,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
boundary = part[9:].strip('"')
|
||||
|
||||
if not boundary:
|
||||
self._json(400, {"error": "boundary manquant"})
|
||||
self._json(400, {"error": "missing boundary"})
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
@@ -510,19 +728,76 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
field = fields.get("file")
|
||||
if field is None:
|
||||
self._json(400, {"error": "champ 'file' manquant"})
|
||||
self._json(400, {"error": "missing 'file' field"})
|
||||
return
|
||||
|
||||
name_field = fields.get("name")
|
||||
name = name_field["data"].decode("utf-8", errors="replace").strip() if name_field else ""
|
||||
if not name:
|
||||
name = field["filename"] or "untitled"
|
||||
|
||||
slug = uuid.uuid4().hex[:8]
|
||||
dest = DATA_DIR / slug
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
(dest / "index.html").write_bytes(field["data"])
|
||||
(dest / "meta.json").write_text(json.dumps({
|
||||
write_meta(dest, {
|
||||
"name": name,
|
||||
"filename": field["filename"],
|
||||
"uploaded_at": int(time.time()),
|
||||
}))
|
||||
})
|
||||
|
||||
self._json(200, {"url": f"{BASE_URL}/{slug}"})
|
||||
self._json(200, {"url": f"{BASE_URL}/{slug}", "slug": slug})
|
||||
|
||||
def _handle_rename(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
try:
|
||||
data = json.loads(self.rfile.read(length))
|
||||
except Exception:
|
||||
self._json(400, {"error": "invalid JSON"})
|
||||
return
|
||||
|
||||
slug = data.get("slug", "").strip()
|
||||
name = data.get("name", "").strip()
|
||||
|
||||
if not slug or not name:
|
||||
self._json(400, {"error": "slug and name required"})
|
||||
return
|
||||
|
||||
slug_dir = DATA_DIR / slug
|
||||
if not slug_dir.is_dir():
|
||||
self._json(404, {"error": "slug not found"})
|
||||
return
|
||||
|
||||
meta = read_meta(slug_dir)
|
||||
meta["name"] = name
|
||||
write_meta(slug_dir, meta)
|
||||
self._json(200, {"ok": True})
|
||||
|
||||
def _handle_delete(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
try:
|
||||
data = json.loads(self.rfile.read(length))
|
||||
except Exception:
|
||||
self._json(400, {"error": "invalid JSON"})
|
||||
return
|
||||
|
||||
slug = data.get("slug", "").strip()
|
||||
if not slug:
|
||||
self._json(400, {"error": "slug required"})
|
||||
return
|
||||
|
||||
# safety: slug must be hex only (prevents path traversal)
|
||||
if not all(c in "0123456789abcdef" for c in slug):
|
||||
self._json(400, {"error": "invalid slug"})
|
||||
return
|
||||
|
||||
slug_dir = DATA_DIR / slug
|
||||
if not slug_dir.is_dir():
|
||||
self._json(404, {"error": "slug not found"})
|
||||
return
|
||||
|
||||
shutil.rmtree(slug_dir)
|
||||
self._json(200, {"ok": True})
|
||||
|
||||
def _respond(self, code, ctype, body):
|
||||
self.send_response(code)
|
||||
@@ -539,5 +814,5 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if __name__ == "__main__":
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
||||
print(f"droplify sur {BASE_URL} (port {PORT})")
|
||||
print(f"droplify running at {BASE_URL} (port {PORT})")
|
||||
server.serve_forever()
|
||||
|
||||
Reference in New Issue
Block a user