fix
This commit is contained in:
@@ -1,84 +1,83 @@
|
|||||||
# File Drop
|
# File Drop
|
||||||
|
|
||||||
Un hébergeur de fichiers privé, auto-hébergé et minimal. On dépose un fichier,
|
A minimal, self-hosted private file host. Drop a file, get a secret,
|
||||||
on reçoit un lien secret et intraçable. Les images, vidéos et sons s'affichent
|
unguessable link. Images, videos and audio render directly in the browser;
|
||||||
directement dans le navigateur ; les autres fichiers se téléchargent.
|
everything else downloads.
|
||||||
|
|
||||||
- **Backend** — Rust (Axum + Tokio)
|
- **Backend** — Rust (Axum + Tokio)
|
||||||
- **Frontend** — Svelte (Vite + Tailwind)
|
- **Frontend** — Svelte (Vite + Tailwind)
|
||||||
- **Stockage** — système de fichiers local, sans base de données
|
- **Storage** — local filesystem, no database
|
||||||
|
|
||||||
## Démarrage rapide
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Construire le frontend (produit frontend/dist)
|
# 1. Build the frontend (outputs frontend/dist)
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# 2. Lancer le serveur (depuis la racine du projet)
|
# 2. Run the server (from the project root)
|
||||||
cargo run --release --manifest-path backend/Cargo.toml
|
cargo run --release --manifest-path backend/Cargo.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
Ouvrir ensuite http://localhost:3000.
|
Then open http://localhost:3000.
|
||||||
|
|
||||||
> Les chemins `STORAGE_DIR` et `STATIC_DIR` sont relatifs au dossier de
|
> `STORAGE_DIR` and `STATIC_DIR` are relative to where you run the server.
|
||||||
> lancement. Lance donc le serveur depuis la racine du projet (ou règle ces
|
> Run it from the project root, or set absolute paths in `.env`.
|
||||||
> variables en absolu dans `.env`).
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Copier `backend/.env.example` vers `.env` puis l'adapter.
|
Copy `backend/.env.example` to `.env` and edit it.
|
||||||
|
|
||||||
| Variable | Défaut | Rôle |
|
| Variable | Default | Description |
|
||||||
| ---------------------- | ----------------- | ----------------------------------------- |
|
| ---------------------- | ----------------- | ---------------------------------------------- |
|
||||||
| `HOST` / `PORT` | `0.0.0.0` / `3000`| Adresse d'écoute. |
|
| `HOST` / `PORT` | `0.0.0.0` / `3000`| Bind address. |
|
||||||
| `STORAGE_DIR` | `./storage` | Dossier des fichiers et métadonnées. |
|
| `STORAGE_DIR` | `./storage` | Files and metadata directory. |
|
||||||
| `STATIC_DIR` | `./frontend/dist` | Dossier du frontend construit. |
|
| `STATIC_DIR` | `./frontend/dist` | Built frontend directory. |
|
||||||
| `MAX_FILE_SIZE` | `10GB` | Taille maximale d'envoi. |
|
| `MAX_FILE_SIZE` | `10GB` | Maximum upload size. |
|
||||||
| `FILE_TTL_MS` | `172800000` | Durée de vie d'un fichier (48 h max). |
|
| `FILE_TTL_MS` | `172800000` | File lifetime (48 h max). |
|
||||||
| `KEY_LENGTH` | `8` | Longueur de la clé de partage (`a-z0-9`). |
|
| `KEY_LENGTH` | `8` | Share key length (`a-z0-9`). |
|
||||||
| `PUBLIC_BASE_URL` | — | URL publique pour les liens absolus. |
|
| `PUBLIC_BASE_URL` | — | Public URL for absolute links. |
|
||||||
| `RATE_LIMIT_ENABLED` | `1` | Limite de requêtes par IP. |
|
| `RATE_LIMIT_ENABLED` | `1` | Per-IP rate limiting. |
|
||||||
| `RATE_LIMIT_MAX` | `300` | Requêtes max par fenêtre. |
|
| `RATE_LIMIT_MAX` | `300` | Max requests per window. |
|
||||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Fenêtre en millisecondes. |
|
| `RATE_LIMIT_WINDOW_MS` | `60000` | Window in milliseconds. |
|
||||||
| `TRUST_PROXY` | `0` | Activer derrière un proxy (`X-Forwarded-For`). |
|
| `TRUST_PROXY` | `0` | Enable behind a proxy (`X-Forwarded-For`). |
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
| Méthode | Route | Rôle |
|
| Method | Route | Description |
|
||||||
| --------- | -------------------- | ------------------------------------------- |
|
| -------- | ------------------ | --------------------------------------------- |
|
||||||
| `POST` | `/api/upload` | Envoi d'un fichier (`multipart/form-data`). |
|
| `POST` | `/api/upload` | Upload a file (`multipart/form-data`). |
|
||||||
| `GET` | `/:key` | Téléchargement (inline pour les médias). |
|
| `GET` | `/:key` | Download (inline for media). |
|
||||||
| `POST` | `/api/auth/:key` | Mot de passe d'un fichier protégé. |
|
| `POST` | `/api/auth/:key` | Submit the password for a protected file. |
|
||||||
| `DELETE` | `/api/files/:id` | Suppression (en-tête `X-Delete-Key`). |
|
| `DELETE` | `/api/files/:id` | Delete a file (`X-Delete-Key` header). |
|
||||||
| `GET` | `/api/health` | État du service. |
|
| `GET` | `/api/health` | Service status. |
|
||||||
|
|
||||||
Exemple :
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -F "[email protected]" http://localhost:3000/api/upload
|
curl -F "[email protected]" http://localhost:3000/api/upload
|
||||||
```
|
```
|
||||||
|
|
||||||
Le lien renvoyé est de la forme `http://localhost:3000/<clé>.png`. Ajouter
|
The returned link looks like `http://localhost:3000/<key>.png`. Append `?dl=1`
|
||||||
`?dl=1` force le téléchargement au lieu de l'affichage.
|
to force a download instead of inline display.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/ serveur Rust (Axum)
|
backend/ Rust server (Axum)
|
||||||
frontend/ interface Svelte (Vite + Tailwind)
|
frontend/ Svelte interface (Vite + Tailwind)
|
||||||
scripts/ tests
|
scripts/ tests
|
||||||
storage/ données (généré au premier lancement)
|
storage/ data (created on first run)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build # dans backend/
|
cargo build # in backend/
|
||||||
bash scripts/e2e-test.sh
|
bash scripts/e2e-test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Le test couvre l'envoi, le téléchargement, l'authentification par mot de passe,
|
The test covers upload, download, password auth, invalid keys, name
|
||||||
les clés invalides, la sanitisation des noms et la suppression.
|
sanitization and deletion.
|
||||||
|
|||||||
@@ -106,19 +106,19 @@ pub async fn auth(
|
|||||||
|
|
||||||
pub fn render_password_form(key: &str, filename: &str, error: bool) -> Response {
|
pub fn render_password_form(key: &str, filename: &str, error: bool) -> Response {
|
||||||
let message = if error {
|
let message = if error {
|
||||||
"<p class=\"err\">Mot de passe incorrect. Réessayez.</p>"
|
"<p class=\"err\">Incorrect password. Please try again.</p>"
|
||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
let safe_filename = escape_html(filename);
|
let safe_filename = escape_html(filename);
|
||||||
let html = format!(
|
let html = format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
<html lang="fr">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="referrer" content="no-referrer" />
|
<meta name="referrer" content="no-referrer" />
|
||||||
<title>Fichier protégé</title>
|
<title>Protected file</title>
|
||||||
<style>
|
<style>
|
||||||
* {{ box-sizing: border-box; }}
|
* {{ box-sizing: border-box; }}
|
||||||
body {{
|
body {{
|
||||||
@@ -153,12 +153,12 @@ pub fn render_password_form(key: &str, filename: &str, error: bool) -> Response
|
|||||||
<img src="/logo.svg" alt="logo" />
|
<img src="/logo.svg" alt="logo" />
|
||||||
<span>File Drop</span>
|
<span>File Drop</span>
|
||||||
</div>
|
</div>
|
||||||
<h1>Fichier protégé</h1>
|
<h1>Protected file</h1>
|
||||||
<p>Entrez le mot de passe pour télécharger <strong>{safe_filename}</strong>.</p>
|
<p>Enter the password to download <strong>{safe_filename}</strong>.</p>
|
||||||
{message}
|
{message}
|
||||||
<form method="post" action="/api/auth/{key}">
|
<form method="post" action="/api/auth/{key}">
|
||||||
<input type="password" name="password" placeholder="Mot de passe" autofocus autocomplete="off" />
|
<input type="password" name="password" placeholder="Password" autofocus autocomplete="off" />
|
||||||
<button type="submit">Télécharger</button>
|
<button type="submit">Download</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+10
-1
@@ -4,9 +4,18 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="referrer" content="no-referrer" />
|
<meta name="referrer" content="no-referrer" />
|
||||||
<meta name="theme-color" content="#0b0d12" />
|
<meta name="theme-color" content="#fafafa" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>File Drop</title>
|
<title>File Drop</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('filehost.theme');
|
||||||
|
if (t !== 'dark' && t !== 'light') {
|
||||||
|
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
document.documentElement.classList.toggle('dark', t === 'dark');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+50
-11
@@ -1,11 +1,14 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import Dropzone from './components/Dropzone.svelte';
|
import Dropzone from './components/Dropzone.svelte';
|
||||||
import ResultPanel from './components/ResultPanel.svelte';
|
import ResultPanel from './components/ResultPanel.svelte';
|
||||||
import FileList from './components/FileList.svelte';
|
import FileList from './components/FileList.svelte';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
import { uploadFile } from './lib/api.js';
|
import { uploadFile } from './lib/api.js';
|
||||||
import { addUpload } from './lib/store.js';
|
import { addUpload } from './lib/store.js';
|
||||||
|
import { currentTheme, toggleTheme } from './lib/theme.js';
|
||||||
|
|
||||||
|
let theme = 'light';
|
||||||
let currentFile = null;
|
let currentFile = null;
|
||||||
let uploading = false;
|
let uploading = false;
|
||||||
let progress = 0;
|
let progress = 0;
|
||||||
@@ -13,6 +16,10 @@
|
|||||||
let password = '';
|
let password = '';
|
||||||
let toast = { message: '', kind: 'info', id: 0 };
|
let toast = { message: '', kind: 'info', id: 0 };
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
theme = currentTheme();
|
||||||
|
});
|
||||||
|
|
||||||
function notify(message, kind = 'info') {
|
function notify(message, kind = 'info') {
|
||||||
toast = { message, kind, id: Date.now() };
|
toast = { message, kind, id: Date.now() };
|
||||||
}
|
}
|
||||||
@@ -21,13 +28,17 @@
|
|||||||
const file = e.detail;
|
const file = e.detail;
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (file.size === 0) {
|
if (file.size === 0) {
|
||||||
notify('Les fichiers vides ne sont pas pris en charge.', 'error');
|
notify('Empty files are not supported.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentFile = file;
|
currentFile = file;
|
||||||
notify('', 'info');
|
notify('', 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onToggleTheme() {
|
||||||
|
theme = toggleTheme();
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!currentFile || uploading) return;
|
if (!currentFile || uploading) return;
|
||||||
uploading = true;
|
uploading = true;
|
||||||
@@ -49,11 +60,11 @@
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
protected: !!data.protected
|
protected: !!data.protected
|
||||||
});
|
});
|
||||||
notify('Envoi terminé.', 'success');
|
notify('Upload complete.', 'success');
|
||||||
currentFile = null;
|
currentFile = null;
|
||||||
password = '';
|
password = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notify(e.message || 'Échec de l\'envoi.', 'error');
|
notify(e.message || 'Upload failed.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
progress = 0;
|
progress = 0;
|
||||||
@@ -64,31 +75,59 @@
|
|||||||
<div class="mx-auto max-w-6xl px-4 pb-16 sm:px-6">
|
<div class="mx-auto max-w-6xl px-4 pb-16 sm:px-6">
|
||||||
<header class="flex items-center justify-between gap-4 py-6">
|
<header class="flex items-center justify-between gap-4 py-6">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<img src="/logo.svg" alt="File Drop" class="h-8 w-8" />
|
<img src="/logo.svg" alt="File Drop" class="h-8 w-8 dark:invert" />
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-base font-semibold leading-tight text-ink">File Drop</h1>
|
<h1 class="text-base font-semibold leading-tight text-ink">File Drop</h1>
|
||||||
<p class="text-xs text-muted">Hébergement de fichiers privé</p>
|
<p class="text-xs text-muted">Private file hosting</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/" class="btn btn-ghost px-3 py-1.5 text-xs">Nouvel envoi</a>
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost p-1.5"
|
||||||
|
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||||
|
on:click={onToggleTheme}
|
||||||
|
>
|
||||||
|
{#if theme === 'dark'}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="/" class="btn btn-ghost px-3 py-1.5 text-xs">New upload</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="grid grid-cols-1 items-start gap-4 lg:grid-cols-[360px_1fr]">
|
<main class="grid grid-cols-1 items-start gap-4 lg:grid-cols-[360px_1fr]">
|
||||||
<aside class="card p-5 lg:sticky lg:top-6">
|
<aside class="card p-5 lg:sticky lg:top-6">
|
||||||
<h2 class="mb-4 text-sm font-semibold text-ink">Envoyer un fichier</h2>
|
<h2 class="mb-4 text-sm font-semibold text-ink">Upload a file</h2>
|
||||||
|
|
||||||
<Dropzone bind:file={currentFile} disabled={uploading} on:file={onFile} />
|
<Dropzone bind:file={currentFile} disabled={uploading} on:file={onFile} />
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label for="pw" class="mb-1.5 block text-xs font-medium text-muted">
|
<label for="pw" class="mb-1.5 block text-xs font-medium text-muted">
|
||||||
Mot de passe <span class="font-normal opacity-70">(optionnel)</span>
|
Password <span class="font-normal opacity-70">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="pw"
|
id="pw"
|
||||||
type="password"
|
type="password"
|
||||||
class="field"
|
class="field"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
placeholder="Protéger le téléchargement"
|
placeholder="Protect the download"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
on:keydown={(e) => {
|
on:keydown={(e) => {
|
||||||
if (e.key === 'Enter') submit();
|
if (e.key === 'Enter') submit();
|
||||||
@@ -99,7 +138,7 @@
|
|||||||
{#if uploading}
|
{#if uploading}
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="mb-1.5 flex items-center justify-between gap-2 text-xs text-muted">
|
<div class="mb-1.5 flex items-center justify-between gap-2 text-xs text-muted">
|
||||||
<span class="truncate">{currentFile ? currentFile.name : 'Envoi…'}</span>
|
<span class="truncate">{currentFile ? currentFile.name : 'Uploading…'}</span>
|
||||||
<span class="tabular-nums">{progress}%</span>
|
<span class="tabular-nums">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-1 w-full overflow-hidden rounded-full bg-raised">
|
<div class="h-1 w-full overflow-hidden rounded-full bg-raised">
|
||||||
@@ -114,7 +153,7 @@
|
|||||||
disabled={!currentFile || uploading}
|
disabled={!currentFile || uploading}
|
||||||
on:click={submit}
|
on:click={submit}
|
||||||
>
|
>
|
||||||
{uploading ? 'Envoi…' : 'Envoyer'}
|
{uploading ? 'Uploading…' : 'Upload'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|||||||
+32
-2
@@ -2,6 +2,32 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--c-base: 250 250 250;
|
||||||
|
--c-surface: 255 255 255;
|
||||||
|
--c-raised: 245 245 245;
|
||||||
|
--c-edge: 229 229 229;
|
||||||
|
--c-ink: 23 23 23;
|
||||||
|
--c-muted: 115 115 115;
|
||||||
|
--c-accent: 23 23 23;
|
||||||
|
--c-accent-strong: 10 10 10;
|
||||||
|
--c-danger: 220 38 38;
|
||||||
|
--c-ok: 22 163 74;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--c-base: 10 10 10;
|
||||||
|
--c-surface: 23 23 23;
|
||||||
|
--c-raised: 38 38 38;
|
||||||
|
--c-edge: 64 64 64;
|
||||||
|
--c-ink: 250 250 250;
|
||||||
|
--c-muted: 163 163 163;
|
||||||
|
--c-accent: 250 250 250;
|
||||||
|
--c-accent-strong: 229 229 229;
|
||||||
|
--c-danger: 248 113 113;
|
||||||
|
--c-ok: 74 222 128;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -14,7 +40,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: rgba(23, 23, 23, 0.08);
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::selection {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
summary {
|
summary {
|
||||||
@@ -37,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply bg-accent text-white hover:bg-accent-strong;
|
@apply bg-accent text-surface hover:bg-accent-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
|
|||||||
@@ -83,9 +83,9 @@
|
|||||||
|
|
||||||
{#if file}
|
{#if file}
|
||||||
<p class="max-w-full truncate text-sm font-medium text-ink">{file.name}</p>
|
<p class="max-w-full truncate text-sm font-medium text-ink">{file.name}</p>
|
||||||
<p class="mt-1 text-xs text-muted">Déposez un autre fichier pour le remplacer</p>
|
<p class="mt-1 text-xs text-muted">Drop another file to replace it</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm font-medium text-ink">Glissez-déposez un fichier ici</p>
|
<p class="text-sm font-medium text-ink">Drag & drop a file here</p>
|
||||||
<p class="mt-1 text-xs text-muted">ou cliquez pour parcourir</p>
|
<p class="mt-1 text-xs text-muted">or click to browse</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,22 +8,22 @@
|
|||||||
|
|
||||||
async function onDelete(item) {
|
async function onDelete(item) {
|
||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
`Supprimer définitivement « ${item.originalName || item.downloadName} » ?`
|
`Delete "${item.originalName || item.downloadName}" permanently? This cannot be undone.`
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await deleteFile(item);
|
await deleteFile(item);
|
||||||
removeUpload(item.id);
|
removeUpload(item.id);
|
||||||
dispatch('notify', { message: 'Fichier supprimé.', kind: 'success' });
|
dispatch('notify', { message: 'File deleted.', kind: 'success' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch('notify', { message: e.message || 'Suppression impossible.', kind: 'error' });
|
dispatch('notify', { message: e.message || 'Deletion failed.', kind: 'error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClear() {
|
function onClear() {
|
||||||
if ($uploads.length === 0) return;
|
if ($uploads.length === 0) return;
|
||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
'Vider cette liste ? Seules les entrées de CE navigateur sont supprimées. Les fichiers restent sur le serveur.'
|
'Clear this list? This only removes the entries from THIS browser. Files on the server are not touched.'
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
clearUploads();
|
clearUploads();
|
||||||
@@ -33,14 +33,14 @@
|
|||||||
<section class="card p-5">
|
<section class="card p-5">
|
||||||
<div class="mb-4 flex items-center justify-between gap-3">
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
<div class="flex items-baseline gap-2">
|
<div class="flex items-baseline gap-2">
|
||||||
<h2 class="text-sm font-semibold text-ink">Fichiers</h2>
|
<h2 class="text-sm font-semibold text-ink">Files</h2>
|
||||||
{#if $uploads.length}
|
{#if $uploads.length}
|
||||||
<span class="text-xs text-muted">{$uploads.length}</span>
|
<span class="text-xs text-muted">{$uploads.length}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if $uploads.length}
|
{#if $uploads.length}
|
||||||
<button type="button" class="text-xs font-medium text-muted transition-colors hover:text-danger" on:click={onClear}>
|
<button type="button" class="text-xs font-medium text-muted transition-colors hover:text-danger" on:click={onClear}>
|
||||||
Vider la liste
|
Clear list
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -62,8 +62,8 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted">Aucun fichier pour l'instant.</p>
|
<p class="text-sm text-muted">No files yet.</p>
|
||||||
<p class="mt-1 text-xs text-muted">Envoyez votre premier fichier pour le voir ici.</p>
|
<p class="mt-1 text-xs text-muted">Upload your first file to see it here.</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -50,18 +50,18 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-2 md:items-end">
|
<div class="flex flex-col gap-2 md:items-end">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<a href={forcedDownloadUrl(item)} class="btn btn-primary px-3 py-1.5 text-xs">Télécharger</a>
|
<a href={forcedDownloadUrl(item)} class="btn btn-primary px-3 py-1.5 text-xs">Download</a>
|
||||||
<button type="button" class="btn btn-ghost px-3 py-1.5 text-xs" on:click={copy}>
|
<button type="button" class="btn btn-ghost px-3 py-1.5 text-xs" on:click={copy}>
|
||||||
{copied ? 'Copié' : 'Copier'}
|
{copied ? 'Copied' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-danger px-3 py-1.5 text-xs" on:click={() => dispatch('delete', item)}>
|
<button type="button" class="btn btn-danger px-3 py-1.5 text-xs" on:click={() => dispatch('delete', item)}>
|
||||||
Supprimer
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary class="cursor-pointer select-none text-xs font-medium text-muted transition-colors hover:text-ink">
|
<summary class="cursor-pointer select-none text-xs font-medium text-muted transition-colors hover:text-ink">
|
||||||
Liens
|
Links
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-2 md:flex md:justify-end">
|
<div class="mt-2 md:flex md:justify-end">
|
||||||
<LinkFormats {item} />
|
<LinkFormats {item} />
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<p class="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted">Partager</p>
|
<p class="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted">Share links</p>
|
||||||
<LinkFormats item={result} size="md" />
|
<LinkFormats item={result} size="md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const KEY = 'filehost.theme';
|
||||||
|
|
||||||
|
export function currentTheme() {
|
||||||
|
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme) {
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, theme);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleTheme() {
|
||||||
|
const next = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||||
|
applyTheme(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
+11
-10
@@ -1,19 +1,20 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
content: ['./index.html', './src/**/*.{svelte,js}'],
|
content: ['./index.html', './src/**/*.{svelte,js}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
base: '#fafafa',
|
base: 'rgb(var(--c-base) / <alpha-value>)',
|
||||||
surface: '#ffffff',
|
surface: 'rgb(var(--c-surface) / <alpha-value>)',
|
||||||
raised: '#f5f5f5',
|
raised: 'rgb(var(--c-raised) / <alpha-value>)',
|
||||||
edge: '#e5e5e5',
|
edge: 'rgb(var(--c-edge) / <alpha-value>)',
|
||||||
ink: '#171717',
|
ink: 'rgb(var(--c-ink) / <alpha-value>)',
|
||||||
muted: '#737373',
|
muted: 'rgb(var(--c-muted) / <alpha-value>)',
|
||||||
accent: '#171717',
|
accent: 'rgb(var(--c-accent) / <alpha-value>)',
|
||||||
'accent-strong': '#0a0a0a',
|
'accent-strong': 'rgb(var(--c-accent-strong) / <alpha-value>)',
|
||||||
danger: '#dc2626',
|
danger: 'rgb(var(--c-danger) / <alpha-value>)',
|
||||||
ok: '#16a34a'
|
ok: 'rgb(var(--c-ok) / <alpha-value>)'
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'sans-serif']
|
sans: ['system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'sans-serif']
|
||||||
|
|||||||
+6
-6
@@ -13,13 +13,13 @@ if [[ "${1:-}" == "--debug" ]]; then
|
|||||||
PROFILE="debug"
|
PROFILE="debug"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
command -v cargo >/dev/null 2>&1 || { echo "cargo (Rust) est requis." >&2; exit 1; }
|
command -v cargo >/dev/null 2>&1 || { echo "cargo (Rust) is required." >&2; exit 1; }
|
||||||
command -v npm >/dev/null 2>&1 || { echo "npm (Node.js) est requis." >&2; exit 1; }
|
command -v npm >/dev/null 2>&1 || { echo "npm (Node.js) is required." >&2; exit 1; }
|
||||||
|
|
||||||
echo "==> Frontend (Svelte)"
|
echo "==> Frontend (Svelte)"
|
||||||
cd "$ROOT/frontend"
|
cd "$ROOT/frontend"
|
||||||
if [[ ! -d node_modules ]]; then
|
if [[ ! -d node_modules ]]; then
|
||||||
echo " installation des dépendances..."
|
echo " installing dependencies..."
|
||||||
npm install
|
npm install
|
||||||
fi
|
fi
|
||||||
npm run build
|
npm run build
|
||||||
@@ -33,10 +33,10 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Build terminé."
|
echo "Build complete."
|
||||||
if [[ "$PROFILE" == "release" ]]; then
|
if [[ "$PROFILE" == "release" ]]; then
|
||||||
echo " binaire : $ROOT/backend/target/release/filehost"
|
echo " binary : $ROOT/backend/target/release/filehost"
|
||||||
else
|
else
|
||||||
echo " binaire : $ROOT/backend/target/debug/filehost"
|
echo " binary : $ROOT/backend/target/debug/filehost"
|
||||||
fi
|
fi
|
||||||
echo " frontend: $ROOT/frontend/dist"
|
echo " frontend: $ROOT/frontend/dist"
|
||||||
|
|||||||
Reference in New Issue
Block a user