Frontend et backend entièrement réels : plus aucune donnée simulée
À la demande explicite de l'utilisateur après avoir constaté que tripoli.cloud affichait un 404 vide (le frontend n'avait jamais été déployé) : déploiement du frontend, puis remplacement complet des mocks par de vraies données, des deux côtés. Backend (tripoli-api) : - users.name, files.category (file/photo/backup), table notifications (migration 0002). - POST /api/auth/guest : compte invité réel et réutilisé (invite@tripoli.local), pas une identité simulée côté frontend. - GET /api/files?category=... , GET /api/notifications (+ read-all/ {id}/read), GET /api/storage (used_bytes réel, available_bytes via statvfs - jamais un quota inventé). - Notifications émises réellement sur upload/suppression. Frontend (app/) : - src/api/*.ts appellent réellement tripoli-api (plus de mockFetch). - Upload réel (XHR, vraie progression), suppression avec bouton dédié, vraies miniatures photo (<img src="/api/files/:id">). - Session restaurée au chargement (GET /api/auth/me), logout réel. - Retiré plutôt que simulé : 2FA, clé de récupération, accès partagés, albums, dossiers, "4 appareils sauvegardés", chiffres marketing et claim AES-256 côté client sur la page de connexion (jamais vrai - remplacé par le vrai : HTTPS + Argon2). Déploiement : - Frontend construit et déployé sur le serveur (le build local échouait spécifiquement sur cette machine - rolldown/esbuild natif ne répondait plus, cause non résolue ; contourné en buildant sur le serveur, comme déjà fait pour les crates Rust). - Caddy : /var/www/tripoli-cloud servi en statique pour tripoli.cloud, /api/*, /healthz, /metrics toujours proxyés vers tripoli-edge. - Pipeline CI étendu : le job deploy construit et déploie maintenant aussi le frontend, via un script sudo dédié supplémentaire (tripoli-deploy-frontend.sh) sur le compte deploy sans privilège. - Vérifié en navigateur réel (Chrome) : inscription, connexion, invité, upload avec progression, photos, sauvegardes, notifications, suppression, déconnexion - tous testés en conditions réelles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5816c7eda6
commit
a639b705a6
53 changed files with 1352 additions and 587 deletions
|
|
@ -52,10 +52,17 @@ jobs:
|
||||||
- name: Build release (workspace)
|
- name: Build release (workspace)
|
||||||
run: cargo build --release --workspace
|
run: cargo build --release --workspace
|
||||||
|
|
||||||
|
- name: npm build (app/)
|
||||||
|
working-directory: app
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
# Identité de déploiement dédiée, sans privilège : `deploy` ne peut
|
# Identité de déploiement dédiée, sans privilège : `deploy` ne peut
|
||||||
# que déposer un fichier dans /opt/deploy-staging et lancer le
|
# que déposer des fichiers dans /opt/deploy-staging et lancer les
|
||||||
# script sudo tripoli-deploy.sh, qui installe le binaire et
|
# scripts sudo tripoli-deploy*.sh, qui installent exactement ce
|
||||||
# redémarre EXACTEMENT le service nommé - rien de plus. Pas de clé
|
# qui est attendu (binaire + redémarrage du service nommé, ou
|
||||||
|
# synchronisation du frontend statique) - rien de plus. Pas de clé
|
||||||
# root dans ce pipeline.
|
# root dans ce pipeline.
|
||||||
- name: Déployer tripoli-edge et tripoli-api
|
- name: Déployer tripoli-edge et tripoli-api
|
||||||
env:
|
env:
|
||||||
|
|
@ -71,3 +78,6 @@ jobs:
|
||||||
scp -i ~/.ssh/deploy_key "target/release/$service" "deploy@$DEPLOY_HOST:/opt/deploy-staging/$service"
|
scp -i ~/.ssh/deploy_key "target/release/$service" "deploy@$DEPLOY_HOST:/opt/deploy-staging/$service"
|
||||||
ssh -i ~/.ssh/deploy_key "deploy@$DEPLOY_HOST" sudo /usr/local/sbin/tripoli-deploy.sh "$service"
|
ssh -i ~/.ssh/deploy_key "deploy@$DEPLOY_HOST" sudo /usr/local/sbin/tripoli-deploy.sh "$service"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
rsync -az -e "ssh -i ~/.ssh/deploy_key" --delete app/dist/ "deploy@$DEPLOY_HOST:/opt/deploy-staging/frontend/"
|
||||||
|
ssh -i ~/.ssh/deploy_key "deploy@$DEPLOY_HOST" sudo /usr/local/sbin/tripoli-deploy-frontend.sh
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,3 +21,6 @@ target/
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
!**/wireguard/*.example
|
!**/wireguard/*.example
|
||||||
|
|
||||||
|
# Claude Code local runtime state
|
||||||
|
.claude/
|
||||||
|
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2358,6 +2358,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"nix 0.31.3",
|
||||||
"password-hash",
|
"password-hash",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -31,14 +31,15 @@ tenté, non simulé.
|
||||||
| TLS Let's Encrypt | domaines (Forgejo,
|
| TLS Let's Encrypt | domaines (Forgejo,
|
||||||
| routage par Host | lecheminvert, etc.)
|
| routage par Host | lecheminvert, etc.)
|
||||||
+-------------------------+
|
+-------------------------+
|
||||||
|
|
/api/*, /healthz, /metrics tout le reste
|
||||||
172.17.0.1:9000 (passerelle docker0)
|
| |
|
||||||
v
|
172.17.0.1:9000 (docker0) /var/www/tripoli-cloud
|
||||||
+-------------------------+
|
v (fichiers statiques,
|
||||||
| tripoli-edge | Rust (hyper/tokio), systemd,
|
+-------------------------+ file_server de Caddy)
|
||||||
| /healthz /metrics | utilisateur système sans
|
| tripoli-edge |
|
||||||
| reverse-proxy | privilège
|
| /healthz /metrics | Rust (hyper/tokio), systemd,
|
||||||
+-------------------------+
|
| reverse-proxy | utilisateur système sans
|
||||||
|
+-------------------------+ privilège
|
||||||
|
|
|
|
||||||
127.0.0.1:8081
|
127.0.0.1:8081
|
||||||
v
|
v
|
||||||
|
|
@ -91,12 +92,18 @@ tenté, non simulé.
|
||||||
d'Alertmanager, pas de policy Grafana dédiée branchée).
|
d'Alertmanager, pas de policy Grafana dédiée branchée).
|
||||||
- **SSH public toujours ouvert** : choix explicite fait avec
|
- **SSH public toujours ouvert** : choix explicite fait avec
|
||||||
l'utilisateur, pas encore restreint au tunnel WireGuard.
|
l'utilisateur, pas encore restreint au tunnel WireGuard.
|
||||||
|
- **Frontend, fonctionnalités volontairement absentes** : pas de
|
||||||
|
dossiers (stockage à plat), pas de suivi multi-appareils pour les
|
||||||
|
sauvegardes (aucun agent client), pas de partage/accès multi-
|
||||||
|
utilisateurs, préférences de notification stockées dans le navigateur
|
||||||
|
(pas synchronisées entre appareils). Rien de tout ça n'est simulé -
|
||||||
|
simplement pas construit dans cette passe.
|
||||||
|
|
||||||
## État réel
|
## État réel
|
||||||
|
|
||||||
| Composant | État |
|
| Composant | État |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Prototype frontend (`app/`) | Fait — React/TS/Vite/Tailwind, maquette |
|
| Frontend (`app/`) | Fait et déployé en prod — React/TS/Vite/Tailwind, servi en statique par Caddy (`/var/www/tripoli-cloud`), **branché sur les vraies données de tripoli-api** (plus aucun mock) : auth, upload réel avec progression, photos/sauvegardes/notifications/espace disque réels. Éléments sans fonctionnalité réelle possible (2FA, albums, "4 appareils sauvegardés"...) retirés plutôt que simulés — détail dans `app/README.md` |
|
||||||
| Reconnaissance serveur (Phase 0) | Fait |
|
| Reconnaissance serveur (Phase 0) | Fait |
|
||||||
| WireGuard (canal admin Mac ↔ serveur) | Fait — `10.100.0.0/24`, port 58212/udp (51820 filtré par le pare-feu Hetzner Cloud, voir `ansible/group_vars/all.yml`) |
|
| WireGuard (canal admin Mac ↔ serveur) | Fait — `10.100.0.0/24`, port 58212/udp (51820 filtré par le pare-feu Hetzner Cloud, voir `ansible/group_vars/all.yml`) |
|
||||||
| Pare-feu / fail2ban / unattended-upgrades | Déjà en place avant ce projet ; confirmés/complétés par Ansible |
|
| Pare-feu / fail2ban / unattended-upgrades | Déjà en place avant ce projet ; confirmés/complétés par Ansible |
|
||||||
|
|
@ -104,11 +111,11 @@ tenté, non simulé.
|
||||||
| Ansible (rôle de base) | Fait — `ansible/` |
|
| Ansible (rôle de base) | Fait — `ansible/` |
|
||||||
| OpenTofu (serveur + DNS) | Code écrit — `tofu/` ; serveur en donnée en lecture seule (jamais recréé/détruit), DNS géré via le provider OVH. `plan`/`apply` pas encore exécutés (nécessitent des identifiants OVH/Hetzner non fournis) |
|
| OpenTofu (serveur + DNS) | Code écrit — `tofu/` ; serveur en donnée en lecture seule (jamais recréé/détruit), DNS géré via le provider OVH. `plan`/`apply` pas encore exécutés (nécessitent des identifiants OVH/Hetzner non fournis) |
|
||||||
| `tripoli-edge` (Rust, Phase 2) | Fait et déployé en prod — `tripoli-edge/`, service systemd, derrière Caddy, vérifié via `https://tripoli.cloud/healthz` et `/metrics` |
|
| `tripoli-edge` (Rust, Phase 2) | Fait et déployé en prod — `tripoli-edge/`, service systemd, derrière Caddy, vérifié via `https://tripoli.cloud/healthz` et `/metrics` |
|
||||||
| `tripoli-api` (Rust, Phase 3) | Fait et déployé en prod — `tripoli-api/`, auth par session (Argon2), upload/download, PostgreSQL dédié (Docker), vérifié via `https://tripoli.cloud/api/...` |
|
| `tripoli-api` (Rust, Phase 3) | Fait et déployé en prod — `tripoli-api/`, auth par session (Argon2, compte invité réel), fichiers par catégorie (file/photo/backup), notifications réelles, espace disque réel (`statvfs`), PostgreSQL dédié (Docker), vérifié via `https://tripoli.cloud/api/...` et en navigateur réel |
|
||||||
| `tripoli-netctl` (Rust, Phase 4) | Fait — `tripoli-netctl/`, labo pédagogique (netns + veth via `rtnetlink`), testé sur le serveur (création, ping traversant la veth, destruction propre), **volontairement pas branché sur le trafic de prod** |
|
| `tripoli-netctl` (Rust, Phase 4) | Fait — `tripoli-netctl/`, labo pédagogique (netns + veth via `rtnetlink`), testé sur le serveur (création, ping traversant la veth, destruction propre), **volontairement pas branché sur le trafic de prod** |
|
||||||
| Observabilité (Phase 5) | Fait — pas de stack dédiée (réutilise le Prometheus/Grafana partagé existant), scrape tripoli-edge + alertes (down, disque) + dashboard versionné (`monitoring/`), vérifié via l'API Prometheus/Grafana |
|
| Observabilité (Phase 5) | Fait — pas de stack dédiée (réutilise le Prometheus/Grafana partagé existant), scrape tripoli-edge + alertes (down, disque) + dashboard versionné (`monitoring/`), vérifié via l'API Prometheus/Grafana |
|
||||||
| Bird/BGP (Phase 6, bonus) | Fait — `bird-lab/`, BGP+BFD entre 2 namespaces via la racine en transit (AS 65000/65010/65011), vérifié (sessions Established, BFD Up, propagation de route, détection de panne sur coupure de lien), puis proprement démonté |
|
| Bird/BGP (Phase 6, bonus) | Fait — `bird-lab/`, BGP+BFD entre 2 namespaces via la racine en transit (AS 65000/65010/65011), vérifié (sessions Established, BFD Up, propagation de route, détection de panne sur coupure de lien), puis proprement démonté |
|
||||||
| CI/CD | Fait — `.forgejo/workflows/ci.yml` (pas `.gitlab-ci.yml` : le dépôt vit sur Forgejo, pas GitLab). Runner Forgejo Actions déployé en systemd, déploiement via un compte `deploy` sans privilège (`ci/README.md`) |
|
| CI/CD | Fait — `.forgejo/workflows/ci.yml` (pas `.gitlab-ci.yml` : le dépôt vit sur Forgejo, pas GitLab). Runner Forgejo Actions déployé en systemd, déploie binaires Rust **et** frontend, via un compte `deploy` sans privilège (`ci/README.md`) |
|
||||||
|
|
||||||
## Dépôt
|
## Dépôt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,39 @@
|
||||||
# React + TypeScript + Vite
|
# Tripoli Cloud — frontend
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
React + TypeScript + Vite + Tailwind. Appelle réellement `tripoli-api`
|
||||||
|
via `/api/...` (même origine, servi par le Caddy partagé - voir le
|
||||||
|
README racine) : plus aucune donnée simulée.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## Ce qui est réel
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
- Auth : inscription, connexion, invité (compte réel réutilisé),
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
session persistée par cookie, restaurée au chargement.
|
||||||
|
- Fichiers : upload réel (XHR, vraie progression), téléchargement,
|
||||||
|
suppression, filtré par catégorie (`file`/`photo`/`backup`).
|
||||||
|
- Photos : grille des fichiers catégorie `photo`, vraies miniatures
|
||||||
|
(`<img src="/api/files/:id">`), pas de génération de miniature à part.
|
||||||
|
- Sauvegardes : fichiers catégorie `backup` - pas de suivi
|
||||||
|
multi-appareils (aucun agent client n'existe).
|
||||||
|
- Espace disque : `used_bytes` réel (somme des fichiers), `available_bytes`
|
||||||
|
réel (`statvfs` côté serveur) - jamais un quota inventé.
|
||||||
|
- Notifications : émises par le serveur à chaque upload/suppression.
|
||||||
|
- Réglages : section Compte depuis le vrai utilisateur ; préférences de
|
||||||
|
notification dans `localStorage` (device-local, pas synchronisé -
|
||||||
|
annoncé comme tel, pas simulé comme synchronisé).
|
||||||
|
|
||||||
## React Compiler
|
## Ce qui a été retiré (pas simulé)
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
La maquette d'origine (`Tripoli Cloud.dc.html`) incluait des éléments
|
||||||
|
sans fonctionnalité réelle possible dans cette version : 2FA, clé de
|
||||||
|
récupération, accès partagés, albums photo, dossiers, "4 appareils
|
||||||
|
sauvegardés", chiffres marketing sur la page de connexion, chiffrement
|
||||||
|
AES-256 côté client. Retirés plutôt que simulés - voir les commentaires
|
||||||
|
dans `src/api/*.ts` pour le détail de chaque décision.
|
||||||
|
|
||||||
## Expanding the Oxlint configuration
|
## Développement
|
||||||
|
|
||||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
```bash
|
||||||
|
npm install
|
||||||
```json
|
npm run dev # nécessite tripoli-api accessible sur /api (proxy Vite à ajouter si besoin en local)
|
||||||
{
|
npm run build # build de production, servi en statique par Caddy
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
||||||
"plugins": ["react", "typescript", "oxc"],
|
|
||||||
"options": {
|
|
||||||
"typeAware": true
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"react/rules-of-hooks": "error",
|
|
||||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,9 @@ function AppContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Root() {
|
function Root() {
|
||||||
const { view } = useSession();
|
const { view, restoring } = useSession();
|
||||||
|
|
||||||
|
if (restoring) return null;
|
||||||
if (view === 'auth') return <AuthLayout />;
|
if (view === 'auth') return <AuthLayout />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { User } from '../types';
|
import type { User } from '../types';
|
||||||
import { mockFetch } from './client';
|
import { ApiError, apiFetch } from './client';
|
||||||
|
|
||||||
export interface Credentials {
|
export interface Credentials {
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -10,6 +10,14 @@ export interface SignupPayload extends Credentials {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
is_guest: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
function initialsOf(name: string): string {
|
function initialsOf(name: string): string {
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
if (parts.length === 0) return '??';
|
if (parts.length === 0) return '??';
|
||||||
|
|
@ -19,35 +27,49 @@ function initialsOf(name: string): string {
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login({ email }: Credentials): Promise<User> {
|
function toUser(view: UserView): User {
|
||||||
return mockFetch({
|
return {
|
||||||
id: 'usr_1',
|
id: view.id,
|
||||||
name: 'Leïla Barghouti',
|
name: view.name,
|
||||||
initials: 'LB',
|
initials: initialsOf(view.name || view.email),
|
||||||
email,
|
email: view.email,
|
||||||
role: 'Administratrice',
|
role: view.is_guest ? 'Accès invité' : 'Compte personnel',
|
||||||
isGuest: false,
|
isGuest: view.is_guest,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signup({ name, email }: SignupPayload): Promise<User> {
|
export async function login({ email, password }: Credentials): Promise<User> {
|
||||||
return mockFetch({
|
const view = await apiFetch<UserView>('/api/auth/login', {
|
||||||
id: 'usr_new',
|
method: 'POST',
|
||||||
name,
|
body: JSON.stringify({ email, password }),
|
||||||
initials: initialsOf(name),
|
|
||||||
email,
|
|
||||||
role: 'Administratrice',
|
|
||||||
isGuest: false,
|
|
||||||
});
|
});
|
||||||
|
return toUser(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signup({ name, email, password }: SignupPayload): Promise<User> {
|
||||||
|
const view = await apiFetch<UserView>('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, email, password }),
|
||||||
|
});
|
||||||
|
return toUser(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginAsGuest(): Promise<User> {
|
export async function loginAsGuest(): Promise<User> {
|
||||||
return mockFetch({
|
const view = await apiFetch<UserView>('/api/auth/guest', { method: 'POST' });
|
||||||
id: 'usr_guest',
|
return toUser(view);
|
||||||
name: 'Invité',
|
}
|
||||||
initials: 'IN',
|
|
||||||
email: '',
|
export async function logout(): Promise<void> {
|
||||||
role: 'Accès invité',
|
await apiFetch<void>('/api/auth/logout', { method: 'POST' });
|
||||||
isGuest: true,
|
}
|
||||||
});
|
|
||||||
|
/** Restaure la session depuis le cookie au chargement de la page. `null` si non connecté. */
|
||||||
|
export async function fetchCurrentUser(): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
const view = await apiFetch<UserView>('/api/auth/me');
|
||||||
|
return toUser(view);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
import type { BackupDevice } from '../types';
|
import { formatBytes, formatRelativeTime } from '../lib/format';
|
||||||
import { mockFetch } from './client';
|
import type { BackupItem } from '../types';
|
||||||
|
import { apiFetch } from './client';
|
||||||
|
|
||||||
const STATUS_LABEL: Record<BackupDevice['status'], string> = {
|
interface FileView {
|
||||||
'up-to-date': 'À jour',
|
id: string;
|
||||||
syncing: 'En cours',
|
filename: string;
|
||||||
late: 'Retard 2 j',
|
size_bytes: number;
|
||||||
};
|
created_at: string;
|
||||||
|
}
|
||||||
const RAW: Array<Omit<BackupDevice, 'id' | 'statusLabel'>> = [
|
|
||||||
{ device: 'Poste principal', detail: '64 Go · documents, messages, réglages', status: 'up-to-date', icon: 'smartphone' },
|
/**
|
||||||
{ device: 'Portable de travail', detail: '212 Go · dossier de travail et bureau', status: 'up-to-date', icon: 'laptop' },
|
* Pas de suivi multi-appareils (aucun agent n'est déployé sur des
|
||||||
{ device: 'Tablette de consultation', detail: '38 Go · documents en lecture seule', status: 'syncing', icon: 'tablet' },
|
* postes clients) : une "sauvegarde" est ici un fichier déposé avec la
|
||||||
{ device: 'Coffre secondaire hors site', detail: '812 Go · miroir complet du volume', status: 'late', icon: 'server' },
|
* catégorie "backup", comme n'importe quel autre dépôt.
|
||||||
];
|
*/
|
||||||
|
export async function getBackups(): Promise<BackupItem[]> {
|
||||||
export async function getBackups(): Promise<BackupDevice[]> {
|
const views = await apiFetch<FileView[]>('/api/files?category=backup');
|
||||||
return mockFetch(RAW.map((b, i) => ({ ...b, id: `backup_${i}`, statusLabel: STATUS_LABEL[b.status] })));
|
return views.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
name: v.filename,
|
||||||
|
detail: `${formatBytes(v.size_bytes)} · déposé ${formatRelativeTime(v.created_at)}`,
|
||||||
|
icon: 'server',
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,42 @@
|
||||||
/**
|
/**
|
||||||
* Every function in src/api/ is written as if it called `API_BASE`, but
|
* Toutes les requêtes passent par la même origine (le frontend est servi
|
||||||
* currently resolves a local mock after a short simulated delay. Swapping
|
* par le même Caddy qui route /api/* vers tripoli-edge -> tripoli-api) :
|
||||||
* in a real backend later means rewriting the body of these functions only
|
* pas besoin de configurer une URL de base, ni de CORS.
|
||||||
* — components and hooks never change.
|
|
||||||
*/
|
*/
|
||||||
export const API_BASE = import.meta.env.VITE_API_URL;
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
export function mockFetch<T>(data: T, delayMs = 220): Promise<T> {
|
constructor(status: number, message: string) {
|
||||||
return new Promise((resolve) => {
|
super(message);
|
||||||
setTimeout(() => resolve(data), delayMs);
|
this.status = status;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
...(init?.body && !(init.body instanceof FormData)
|
||||||
|
? { 'Content-Type': 'application/json' }
|
||||||
|
: {}),
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = res.statusText;
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { error?: string };
|
||||||
|
if (body.error) message = body.error;
|
||||||
|
} catch {
|
||||||
|
// pas de corps JSON exploitable - on garde le statusText
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,143 @@
|
||||||
import { THUMB_TONES } from '../lib/gradients';
|
import { THUMB_TONES } from '../lib/gradients';
|
||||||
import type { FileItem, StatItem } from '../types';
|
import { formatBytes, formatRelativeTime } from '../lib/format';
|
||||||
import { mockFetch } from './client';
|
import type { FileItem, FileKind, StatItem } from '../types';
|
||||||
|
import { apiFetch } from './client';
|
||||||
|
|
||||||
const RAW_FILES: Array<Omit<FileItem, 'id' | 'thumbGradient'>> = [
|
interface FileView {
|
||||||
{ name: 'Archives_fiscales_2026.zip', kind: 'archive', extLabel: 'Archive ZIP', size: '2,4 Go', modified: 'il y a 12 min' },
|
id: string;
|
||||||
{ name: 'Contrats_signes_2026', kind: 'folder', extLabel: 'Dossier · 412 éléments', size: '18,7 Go', modified: 'il y a 2 h' },
|
filename: string;
|
||||||
{ name: 'Acte_de_propriete.pdf', kind: 'document', extLabel: 'Document PDF', size: '1,1 Mo', modified: 'hier' },
|
content_type: string;
|
||||||
{ name: 'Backup_poste_principal', kind: 'backup', extLabel: 'Sauvegarde', size: '64 Go', modified: 'hier' },
|
category: string;
|
||||||
{ name: 'Conseil_administration.mp4', kind: 'video', extLabel: 'Vidéo MP4', size: '820 Mo', modified: 'il y a 3 jours' },
|
size_bytes: number;
|
||||||
{ name: 'Scans_administratifs', kind: 'folder', extLabel: 'Dossier · 38 éléments', size: '240 Mo', modified: 'il y a 5 jours' },
|
created_at: string;
|
||||||
{ name: 'Statuts_et_annexes.docx', kind: 'document', extLabel: 'Document Word', size: '96 Ko', modified: 'il y a 1 sem.' },
|
}
|
||||||
{ name: 'Numerisations_1998', kind: 'folder', extLabel: 'Dossier · 219 éléments', size: '3,2 Go', modified: 'il y a 2 sem.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function getFiles(): Promise<FileItem[]> {
|
interface StorageView {
|
||||||
return mockFetch(
|
used_bytes: number;
|
||||||
RAW_FILES.map((f, i) => ({
|
available_bytes: number;
|
||||||
...f,
|
}
|
||||||
id: `file_${i}`,
|
|
||||||
thumbGradient: THUMB_TONES[i % THUMB_TONES.length]!,
|
function kindOf(view: FileView): FileKind {
|
||||||
})),
|
if (view.category === 'backup') return 'backup';
|
||||||
);
|
if (view.content_type.startsWith('image/')) return 'image';
|
||||||
|
if (view.content_type.startsWith('video/')) return 'video';
|
||||||
|
if (
|
||||||
|
view.content_type.includes('zip') ||
|
||||||
|
view.content_type.includes('gzip') ||
|
||||||
|
view.content_type.includes('tar') ||
|
||||||
|
view.content_type.includes('compressed')
|
||||||
|
) {
|
||||||
|
return 'archive';
|
||||||
|
}
|
||||||
|
return 'document';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extLabelOf(view: FileView, kind: FileKind): string {
|
||||||
|
const ext = view.filename.includes('.') ? view.filename.split('.').pop()!.toUpperCase() : '';
|
||||||
|
const byKind: Record<FileKind, string> = {
|
||||||
|
image: 'Image',
|
||||||
|
video: 'Vidéo',
|
||||||
|
archive: 'Archive',
|
||||||
|
backup: 'Sauvegarde',
|
||||||
|
document: 'Document',
|
||||||
|
folder: 'Dossier',
|
||||||
|
};
|
||||||
|
return ext ? `${byKind[kind]} ${ext}` : byKind[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFileItem(view: FileView, index: number): FileItem {
|
||||||
|
const kind = kindOf(view);
|
||||||
|
return {
|
||||||
|
id: view.id,
|
||||||
|
name: view.filename,
|
||||||
|
kind,
|
||||||
|
extLabel: extLabelOf(view, kind),
|
||||||
|
size: formatBytes(view.size_bytes),
|
||||||
|
modified: formatRelativeTime(view.created_at),
|
||||||
|
thumbGradient: THUMB_TONES[index % THUMB_TONES.length]!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFiles(category?: string): Promise<FileItem[]> {
|
||||||
|
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||||
|
const views = await apiFetch<FileView[]>(`/api/files${qs}`);
|
||||||
|
return views.map(toFileItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadUrl(fileId: string): string {
|
||||||
|
return `/api/files/${fileId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFile(fileId: string): Promise<void> {
|
||||||
|
await apiFetch<void>(`/api/files/${fileId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload réel via XMLHttpRequest (pas `fetch`) : c'est la seule API du
|
||||||
|
* navigateur qui expose un vrai événement de progression sur le corps
|
||||||
|
* envoyé (`upload.onprogress`) - `fetch` n'a pas d'équivalent côté
|
||||||
|
* requête sortante.
|
||||||
|
*/
|
||||||
|
export function uploadFile(
|
||||||
|
file: File,
|
||||||
|
category: 'file' | 'photo' | 'backup',
|
||||||
|
onProgress: (percent: number) => void,
|
||||||
|
): Promise<FileItem> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('category', category);
|
||||||
|
form.append('file', file);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/files');
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const view = JSON.parse(xhr.responseText) as FileView;
|
||||||
|
resolve(toFileItem(view, 0));
|
||||||
|
} else {
|
||||||
|
let message = xhr.statusText;
|
||||||
|
try {
|
||||||
|
message = (JSON.parse(xhr.responseText) as { error?: string }).error ?? message;
|
||||||
|
} catch {
|
||||||
|
// pas de corps JSON exploitable
|
||||||
|
}
|
||||||
|
reject(new Error(message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error('Échec réseau pendant le transfert'));
|
||||||
|
|
||||||
|
xhr.send(form);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardStats(): Promise<StatItem[]> {
|
export async function getDashboardStats(): Promise<StatItem[]> {
|
||||||
return mockFetch([
|
const [files, storage] = await Promise.all([
|
||||||
{ id: 'stat_storage', label: 'Espace utilisé', value: '812 Go', note: '188 Go disponibles', icon: 'hard-drive', tone: 'brand' },
|
apiFetch<FileView[]>('/api/files'),
|
||||||
{ id: 'stat_docs', label: 'Documents', value: '12 480', note: '+142 cette semaine', icon: 'image', tone: 'brand' },
|
apiFetch<StorageView>('/api/storage'),
|
||||||
{ id: 'stat_devices', label: 'Postes', value: '4', note: 'tous scellés', icon: 'smartphone', tone: 'success' },
|
|
||||||
{ id: 'stat_access', label: 'Accès actifs', value: '7', note: '3 liens expirent bientôt', icon: 'share', tone: 'violet' },
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'stat_storage',
|
||||||
|
label: 'Espace utilisé',
|
||||||
|
value: formatBytes(storage.used_bytes),
|
||||||
|
note: `${formatBytes(storage.available_bytes)} disponibles sur le serveur`,
|
||||||
|
icon: 'hard-drive',
|
||||||
|
tone: 'brand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stat_docs',
|
||||||
|
label: 'Fichiers',
|
||||||
|
value: String(files.length),
|
||||||
|
note: files.length > 0 ? `dernier dépôt ${formatRelativeTime(files[0]!.created_at)}` : 'aucun dépôt',
|
||||||
|
icon: 'image',
|
||||||
|
tone: 'success',
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,33 @@
|
||||||
import type { NotificationItem } from '../types';
|
import { formatRelativeTime } from '../lib/format';
|
||||||
import { mockFetch } from './client';
|
import type { IconName, NotificationItem } from '../types';
|
||||||
|
import { apiFetch } from './client';
|
||||||
|
|
||||||
const RAW: Array<Omit<NotificationItem, 'id'>> = [
|
interface NotificationView {
|
||||||
{ icon: 'upload', title: 'Transfert terminé', body: 'Archives_fiscales_2026.zip a été chiffré et scellé.', time: 'il y a 12 min', unread: true },
|
id: string;
|
||||||
{ icon: 'share', title: 'Nouveau dépôt', body: '42 documents ont été ajoutés au coffre Archives.', time: 'il y a 3 h', unread: true },
|
kind: string;
|
||||||
{ icon: 'check', title: 'Sauvegarde iPhone', body: '64 Go synchronisés et vérifiés, sans erreur.', time: 'hier, 23:40', unread: false },
|
title: string;
|
||||||
{ icon: 'alert', title: 'Espace disque', body: "Il reste 18 % d'espace libre sur le volume principal.", time: 'il y a 2 jours', unread: false },
|
body: string;
|
||||||
];
|
created_at: string;
|
||||||
|
unread: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_BY_KIND: Record<string, IconName> = {
|
||||||
|
upload: 'upload',
|
||||||
|
delete: 'alert',
|
||||||
|
};
|
||||||
|
|
||||||
export async function getNotifications(): Promise<NotificationItem[]> {
|
export async function getNotifications(): Promise<NotificationItem[]> {
|
||||||
return mockFetch(RAW.map((n, i) => ({ ...n, id: `notif_${i}` })));
|
const views = await apiFetch<NotificationView[]>('/api/notifications');
|
||||||
|
return views.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
icon: ICON_BY_KIND[v.kind] ?? 'check',
|
||||||
|
title: v.title,
|
||||||
|
body: v.body,
|
||||||
|
time: formatRelativeTime(v.created_at),
|
||||||
|
unread: v.unread,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(): Promise<void> {
|
||||||
|
await apiFetch<void>('/api/notifications/read-all', { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,24 @@
|
||||||
import { THUMB_TONES, tone } from '../lib/gradients';
|
import { formatDateTime } from '../lib/format';
|
||||||
import type { Album, PhotoTile } from '../types';
|
import type { PhotoTile } from '../types';
|
||||||
import { mockFetch } from './client';
|
import { apiFetch } from './client';
|
||||||
|
|
||||||
const RAW_ALBUMS: Array<Omit<Album, 'id' | 'thumbGradient'>> = [
|
interface FileView {
|
||||||
{ name: 'Contrats', count: '412 éléments' },
|
id: string;
|
||||||
{ name: 'Titres de propriété', count: '318 éléments' },
|
filename: string;
|
||||||
{ name: 'Numérisations 1998', count: '219 éléments' },
|
created_at: string;
|
||||||
{ name: "Pièces d'identité", count: '1 204 éléments' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function getAlbums(): Promise<Album[]> {
|
|
||||||
return mockFetch(
|
|
||||||
RAW_ALBUMS.map((a, i) => ({ ...a, id: `album_${i}`, thumbGradient: THUMB_TONES[i % THUMB_TONES.length]! })),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TILE_TONES = ['#3b4a63', '#4a3f5f', '#51413a', '#33505a', '#3d4a41', '#46384f'];
|
/**
|
||||||
|
* Pas de concept d'album : ce serait simuler une organisation
|
||||||
|
* (personnes, lieux) qu'aucune fonctionnalité réelle ne produit
|
||||||
|
* aujourd'hui. La vue Photos est une vraie grille des fichiers
|
||||||
|
* catégorie "photo", triés par date de dépôt.
|
||||||
|
*/
|
||||||
export async function getPhotoTiles(): Promise<PhotoTile[]> {
|
export async function getPhotoTiles(): Promise<PhotoTile[]> {
|
||||||
return mockFetch(
|
const views = await apiFetch<FileView[]>('/api/files?category=photo');
|
||||||
Array.from({ length: 12 }, (_, i) => ({
|
return views.map((v) => ({
|
||||||
id: `tile_${i}`,
|
id: v.id,
|
||||||
label: `16 sept. · ${9 + i}:0${i % 6}`,
|
label: formatDateTime(v.created_at),
|
||||||
thumbGradient: tone(TILE_TONES[i % TILE_TONES.length]!, '#141821'),
|
url: `/api/files/${v.id}`,
|
||||||
})),
|
}));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,20 @@
|
||||||
import type { NotificationPrefKey, SettingsValueRow } from '../types';
|
import { formatDateTime } from '../lib/format';
|
||||||
import { mockFetch } from './client';
|
import type { NotificationPrefKey, SettingsValueRow, User } from '../types';
|
||||||
|
|
||||||
export interface StaticSettingsRows {
|
const PREFS_STORAGE_KEY = 'tripoli-cloud:notification-prefs';
|
||||||
language: SettingsValueRow[];
|
|
||||||
account: SettingsValueRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStaticSettingsRows(): Promise<StaticSettingsRows> {
|
/**
|
||||||
return mockFetch({
|
* Section "Compte" construite à partir du vrai utilisateur courant.
|
||||||
language: [
|
* Les champs fictifs de la maquette d'origine (2FA, clé de récupération,
|
||||||
{ kind: 'value', id: 'lang', label: "Langue de l'interface", help: 'S’applique à tous les postes connectés à ce coffre.', value: 'Français' },
|
* accès partagés) n'ont aucune fonctionnalité réelle derrière et ont
|
||||||
{ kind: 'value', id: 'region', label: 'Région', help: 'Détermine le format des dates, nombres et unités.', value: 'Tunisie' },
|
* été retirés plutôt que simulés.
|
||||||
{ kind: 'value', id: 'tz', label: 'Fuseau horaire', help: 'Utilisé pour l’horodatage des sauvegardes.', value: 'GMT+1 · Tunis' },
|
*/
|
||||||
{ kind: 'value', id: 'week-start', label: 'Premier jour de la semaine', help: 'Affichage des calendriers et des vues par date.', value: 'Lundi' },
|
export function accountRows(user: User): SettingsValueRow[] {
|
||||||
],
|
return [
|
||||||
account: [
|
{ kind: 'value', id: 'display-name', label: 'Nom affiché', help: 'Visible sur ce compte.', value: user.name || '—' },
|
||||||
{ kind: 'value', id: 'display-name', label: 'Nom affiché', help: 'Visible par les personnes autorisées sur ce coffre.', value: 'Leïla Barghouti' },
|
{ kind: 'value', id: 'email', label: 'Adresse e-mail', help: "Sert d'identifiant de connexion.", value: user.email || '—' },
|
||||||
{ kind: 'value', id: 'email', label: 'Adresse e-mail', help: 'Sert d’identifiant de connexion.', value: 'leila@domaine.tn' },
|
{ kind: 'value', id: 'type', label: 'Type de compte', help: '', value: user.isGuest ? 'Invité (démonstration)' : 'Compte personnel' },
|
||||||
{ kind: 'value', id: '2fa', label: 'Double authentification', help: 'Application d’authentification requise à chaque connexion.', value: 'Activée' },
|
];
|
||||||
{ kind: 'value', id: 'access', label: 'Accès autorisés', help: 'Quatre personnes disposent d’une clé de déverrouillage.', value: '4 accès' },
|
|
||||||
{ kind: 'value', id: 'recovery-key', label: 'Clé de récupération', help: 'À conserver hors ligne — elle ne peut pas être régénérée.', value: 'Téléchargée' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NOTIFICATION_PREF_LABELS: Record<NotificationPrefKey, { label: string; help: string }> = {
|
export const NOTIFICATION_PREF_LABELS: Record<NotificationPrefKey, { label: string; help: string }> = {
|
||||||
|
|
@ -31,6 +24,38 @@ export const NOTIFICATION_PREF_LABELS: Record<NotificationPrefKey, { label: stri
|
||||||
shared: { label: 'Activité des partages', help: 'Quand un accès autorisé dépose ou retire un document.' },
|
shared: { label: 'Activité des partages', help: 'Quand un accès autorisé dépose ou retire un document.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getDefaultNotificationPrefs(): Promise<Record<NotificationPrefKey, boolean>> {
|
const DEFAULT_PREFS: Record<NotificationPrefKey, boolean> = {
|
||||||
return mockFetch({ push: true, email: false, weekly: true, shared: true });
|
push: true,
|
||||||
|
email: false,
|
||||||
|
weekly: true,
|
||||||
|
shared: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Préférences persistées dans le navigateur (localStorage), pas sur le
|
||||||
|
* serveur : réellement sauvegardées (elles survivent à un rechargement),
|
||||||
|
* mais propres à cet appareil - pas de synchronisation multi-appareils,
|
||||||
|
* ce serait mentir sur une fonctionnalité qui n'existe pas.
|
||||||
|
*/
|
||||||
|
export function getNotificationPrefs(): Record<NotificationPrefKey, boolean> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PREFS_STORAGE_KEY);
|
||||||
|
if (!raw) return DEFAULT_PREFS;
|
||||||
|
return { ...DEFAULT_PREFS, ...(JSON.parse(raw) as Partial<Record<NotificationPrefKey, boolean>>) };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_PREFS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setNotificationPrefs(prefs: Record<NotificationPrefKey, boolean>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PREFS_STORAGE_KEY, JSON.stringify(prefs));
|
||||||
|
} catch {
|
||||||
|
// stockage indisponible (navigation privée, quota) - la préférence
|
||||||
|
// reste simplement en mémoire pour cette session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMemberSince(isoDate: string): string {
|
||||||
|
return `Membre depuis le ${formatDateTime(isoDate)}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,25 @@
|
||||||
import { mockFetch } from './client';
|
import { formatBytes } from '../lib/format';
|
||||||
|
import { apiFetch } from './client';
|
||||||
|
|
||||||
|
interface StorageView {
|
||||||
|
used_bytes: number;
|
||||||
|
available_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorageSummary {
|
export interface StorageSummary {
|
||||||
label: string;
|
label: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
sealedNote: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pas de quota fictif du type "1 To" : le total affiché est l'espace
|
||||||
|
* réellement disponible sur le disque du serveur (`statvfs`), pas un
|
||||||
|
* chiffre inventé. */
|
||||||
export async function getStorageSummary(): Promise<StorageSummary> {
|
export async function getStorageSummary(): Promise<StorageSummary> {
|
||||||
return mockFetch({ label: '812 Go / 1 To', percent: 81, sealedNote: 'Coffre scellé il y a 2 h' });
|
const view = await apiFetch<StorageView>('/api/storage');
|
||||||
|
const total = view.used_bytes + view.available_bytes;
|
||||||
|
const percent = total > 0 ? Math.round((view.used_bytes / total) * 100) : 0;
|
||||||
|
return {
|
||||||
|
label: `${formatBytes(view.used_bytes)} / ${formatBytes(total)}`,
|
||||||
|
percent,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,6 @@ import { AuthBackground } from './AuthBackground';
|
||||||
import { AuthPanel } from './AuthPanel';
|
import { AuthPanel } from './AuthPanel';
|
||||||
import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo';
|
import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo';
|
||||||
|
|
||||||
const AUTH_STATS = [
|
|
||||||
{ value: '12 480', label: 'documents indexés' },
|
|
||||||
{ value: '4', label: 'postes sauvegardés' },
|
|
||||||
{ value: '100 %', label: 'chiffré localement' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AuthLayout() {
|
export function AuthLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen overflow-hidden bg-[#07080c]">
|
<div className="relative min-h-screen overflow-hidden bg-[#07080c]">
|
||||||
|
|
@ -31,17 +25,8 @@ export function AuthLayout() {
|
||||||
sous clé.
|
sous clé.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="m-0 max-w-[44ch] text-[15px] leading-relaxed text-white/62 sm:text-[17.5px]">
|
<p className="m-0 max-w-[44ch] text-[15px] leading-relaxed text-white/62 sm:text-[17.5px]">
|
||||||
Documents sensibles, archives et sauvegardes dans un coffre-fort chiffré de bout en bout. Vous seul
|
Documents, photos et sauvegardes sur un serveur que vous contrôlez.
|
||||||
détenez la clé.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-[38px] flex flex-wrap gap-7">
|
|
||||||
{AUTH_STATS.map((s) => (
|
|
||||||
<div key={s.label}>
|
|
||||||
<p className="m-0 mb-[3px] text-[22px] font-bold tracking-tight text-white">{s.value}</p>
|
|
||||||
<p className="m-0 text-[12.5px] text-white/50">{s.label}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AuthPanel />
|
<AuthPanel />
|
||||||
|
|
@ -49,7 +34,7 @@ export function AuthLayout() {
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-5 text-xs text-white/34">
|
<div className="flex flex-wrap gap-5 text-xs text-white/34">
|
||||||
<span>Tripoli Cloud — 2026</span>
|
<span>Tripoli Cloud — 2026</span>
|
||||||
<span>Hébergé sur votre propre matériel</span>
|
<span>Hébergé sur votre propre serveur</span>
|
||||||
<span>Sans publicité, sans traçage</span>
|
<span>Sans publicité, sans traçage</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export function AuthPanel() {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
continueAsGuest,
|
continueAsGuest,
|
||||||
loading,
|
loading,
|
||||||
|
error,
|
||||||
} = useAuthForm();
|
} = useAuthForm();
|
||||||
|
|
||||||
const isSignup = mode === 'signup';
|
const isSignup = mode === 'signup';
|
||||||
|
|
@ -29,7 +30,7 @@ export function AuthPanel() {
|
||||||
{isSignup ? 'Créer votre espace' : 'Bon retour'}
|
{isSignup ? 'Créer votre espace' : 'Bon retour'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="m-0 mb-6 text-[13.5px] text-white/55">
|
<p className="m-0 mb-6 text-[13.5px] text-white/55">
|
||||||
{isSignup ? 'Un coffre-fort chiffré, une seule clé : la vôtre.' : 'Déverrouillez votre coffre-fort.'}
|
{isSignup ? 'Créez votre compte en quelques secondes.' : 'Déverrouillez votre coffre-fort.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mb-6 flex gap-1 rounded-xl border border-white/8 bg-black/28 p-1">
|
<div className="mb-6 flex gap-1 rounded-xl border border-white/8 bg-black/28 p-1">
|
||||||
|
|
@ -54,6 +55,13 @@ export function AuthPanel() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3.5 py-3 text-[13px] font-semibold text-danger">
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSignup && (
|
{isSignup && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="tc-name" className="text-[12.5px] font-semibold text-white/72">
|
<label htmlFor="tc-name" className="text-[12.5px] font-semibold text-white/72">
|
||||||
|
|
@ -155,7 +163,7 @@ export function AuthPanel() {
|
||||||
|
|
||||||
<div className="mt-1.5 flex items-center gap-2.5 border-t border-white/10 pt-4 text-xs text-white/48">
|
<div className="mt-1.5 flex items-center gap-2.5 border-t border-white/10 pt-4 text-xs text-white/48">
|
||||||
<ShieldCheck size={14} className="text-success" strokeWidth={2} />
|
<ShieldCheck size={14} className="text-success" strokeWidth={2} />
|
||||||
Chiffrement AES-256 côté client — aucune donnée en clair sur le serveur.
|
Connexion chiffrée (HTTPS), mots de passe hachés (Argon2) — jamais stockés en clair.
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,16 @@
|
||||||
import type { BackupDevice } from '../../types';
|
import type { BackupItem } from '../../types';
|
||||||
import { DataIcon } from '../icons/DataIcon';
|
import { DataIcon } from '../icons/DataIcon';
|
||||||
|
|
||||||
const STATUS_CLASSES: Record<BackupDevice['status'], string> = {
|
export function BackupRow({ backup }: { backup: BackupItem }) {
|
||||||
'up-to-date': 'bg-success/16 text-success',
|
|
||||||
syncing: 'bg-violet/16 text-violet',
|
|
||||||
late: 'bg-danger/16 text-danger',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICON_TONE: Record<BackupDevice['status'], string> = {
|
|
||||||
'up-to-date': 'bg-selected text-brand-soft',
|
|
||||||
syncing: 'bg-violet/16 text-violet',
|
|
||||||
late: 'bg-danger/16 text-danger',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BackupRow({ backup }: { backup: BackupDevice }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-4 rounded-2xl border border-line bg-panel p-[18px]">
|
<div className="flex flex-wrap items-center gap-4 rounded-2xl border border-line bg-panel p-[18px]">
|
||||||
<span className={`flex h-10 w-10 flex-none items-center justify-center rounded-xl ${ICON_TONE[backup.status]}`}>
|
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-selected text-brand-soft">
|
||||||
<DataIcon name={backup.icon} size={19} />
|
<DataIcon name={backup.icon} size={19} />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1 basis-[200px]">
|
<div className="min-w-0 flex-1 basis-[200px]">
|
||||||
<p className="m-0 mb-[3px] text-[14.5px] font-bold text-ink">{backup.device}</p>
|
<p className="m-0 mb-[3px] text-[14.5px] font-bold text-ink">{backup.name}</p>
|
||||||
<p className="m-0 text-[12.5px] text-ink-soft">{backup.detail}</p>
|
<p className="m-0 text-[12.5px] text-ink-soft">{backup.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`flex-none rounded-full px-3 py-1.5 text-[11.5px] font-bold ${STATUS_CLASSES[backup.status]}`}>
|
|
||||||
{backup.statusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,44 @@
|
||||||
import { useBackups } from '../../hooks/useBackups';
|
import { useBackups } from '../../hooks/useBackups';
|
||||||
|
import { useFileUpload } from '../../hooks/useFileUpload';
|
||||||
|
import { UploadZone } from '../dashboard/UploadZone';
|
||||||
import { BackupRow } from './BackupRow';
|
import { BackupRow } from './BackupRow';
|
||||||
|
|
||||||
export function BackupsView() {
|
export function BackupsView() {
|
||||||
const backups = useBackups();
|
const { backups, refresh } = useBackups();
|
||||||
|
const upload = useFileUpload('backup', refresh);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex max-w-[960px] flex-col gap-6">
|
<div className="flex max-w-[960px] flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">4 appareils protégés</p>
|
<p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">
|
||||||
|
{backups.length} sauvegarde{backups.length === 1 ? '' : 's'} déposée{backups.length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">Sauvegardes</h1>
|
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">Sauvegardes</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UploadZone
|
||||||
|
uploading={upload.uploading}
|
||||||
|
progress={upload.progress}
|
||||||
|
fileName={upload.fileName}
|
||||||
|
dragging={upload.dragging}
|
||||||
|
error={upload.error}
|
||||||
|
inputRef={upload.inputRef}
|
||||||
|
onPick={upload.pickFiles}
|
||||||
|
onInputChange={upload.onInputChange}
|
||||||
|
onDragOver={upload.onDragOver}
|
||||||
|
onDragLeave={upload.onDragLeave}
|
||||||
|
onDrop={upload.onDrop}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{backups.length === 0 ? (
|
||||||
|
<p className="m-0 text-[13px] text-ink-soft">Aucune sauvegarde pour l'instant.</p>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{backups.map((b) => (
|
{backups.map((b) => (
|
||||||
<BackupRow key={b.id} backup={b} />
|
<BackupRow key={b.id} backup={b} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,24 @@
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus, Upload } from 'lucide-react';
|
||||||
import { useNavigation } from '../../contexts/NavigationContext';
|
|
||||||
import { DataIcon } from '../icons/DataIcon';
|
|
||||||
import type { IconName } from '../../types';
|
|
||||||
|
|
||||||
const OPTIONS: Array<{ icon: IconName; label: string }> = [
|
interface CreateMenuProps {
|
||||||
{ icon: 'folder-plus', label: 'Nouveau dossier' },
|
onImport: () => void;
|
||||||
{ icon: 'file-pen', label: 'Nouveau document' },
|
}
|
||||||
{ icon: 'upload', label: 'Importer des fichiers' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function CreateMenu() {
|
|
||||||
const { createOpen, toggleCreate, closeCreate } = useNavigation();
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un seul vrai bouton : "Nouveau dossier"/"Nouveau document" de la
|
||||||
|
* maquette d'origine n'ont aucune fonctionnalité derrière (pas de
|
||||||
|
* dossiers, pas d'éditeur de document) - retirés plutôt que simulés.
|
||||||
|
*/
|
||||||
|
export function CreateMenu({ onImport }: CreateMenuProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleCreate}
|
onClick={onImport}
|
||||||
aria-expanded={createOpen}
|
|
||||||
className="flex items-center gap-2 rounded-xl bg-brand-gradient px-[17px] py-3 text-[13.5px] font-bold text-white shadow-[0_10px_26px_rgba(91,140,255,0.3)] transition-[filter] hover:brightness-110"
|
className="flex items-center gap-2 rounded-xl bg-brand-gradient px-[17px] py-3 text-[13.5px] font-bold text-white shadow-[0_10px_26px_rgba(91,140,255,0.3)] transition-[filter] hover:brightness-110"
|
||||||
>
|
>
|
||||||
<Plus size={16} strokeWidth={2.2} />
|
<Plus size={16} strokeWidth={2.2} />
|
||||||
Créer
|
<Upload size={16} strokeWidth={2.2} />
|
||||||
|
Importer
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{createOpen && (
|
|
||||||
<div className="animate-tc-in absolute right-0 top-[50px] z-50 w-[232px] rounded-2xl border border-line bg-panel2 p-1.5 shadow-tc backdrop-blur-xl">
|
|
||||||
{OPTIONS.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.label}
|
|
||||||
type="button"
|
|
||||||
onClick={closeCreate}
|
|
||||||
className="flex w-full items-center gap-2.5 rounded-[10px] px-3 py-2.5 text-left text-[13.5px] font-semibold text-ink hover:bg-field"
|
|
||||||
>
|
|
||||||
<span className="flex h-7 w-7 items-center justify-center rounded-[9px] bg-selected text-brand-soft">
|
|
||||||
<DataIcon name={opt.icon} size={15} />
|
|
||||||
</span>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,35 @@
|
||||||
|
import { useRef } from 'react';
|
||||||
import { useDashboardStats } from '../../hooks/useDashboardStats';
|
import { useDashboardStats } from '../../hooks/useDashboardStats';
|
||||||
|
import { useFileUpload } from '../../hooks/useFileUpload';
|
||||||
import { CreateMenu } from './CreateMenu';
|
import { CreateMenu } from './CreateMenu';
|
||||||
import { RecentFiles } from './RecentFiles';
|
import { RecentFiles, type RecentFilesHandle } from './RecentFiles';
|
||||||
import { StatCard } from './StatCard';
|
import { StatCard } from './StatCard';
|
||||||
import { UploadZone } from './UploadZone';
|
import { UploadZone } from './UploadZone';
|
||||||
|
|
||||||
interface DashboardViewProps {
|
const TODAY = new Date().toLocaleDateString('fr-FR', {
|
||||||
/** Forwarded to UploadZone — undefined lets it run its own demo transfer. */
|
weekday: 'long',
|
||||||
uploadProgress?: number;
|
day: 'numeric',
|
||||||
}
|
month: 'long',
|
||||||
|
});
|
||||||
|
|
||||||
export function DashboardView({ uploadProgress }: DashboardViewProps) {
|
export function DashboardView() {
|
||||||
const stats = useDashboardStats();
|
const { stats, refresh: refreshStats } = useDashboardStats();
|
||||||
|
const filesRef = useRef<RecentFilesHandle>(null);
|
||||||
|
const upload = useFileUpload('file', () => {
|
||||||
|
filesRef.current?.refresh();
|
||||||
|
refreshStats();
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex max-w-[1240px] flex-col gap-6">
|
<div className="flex max-w-[1240px] flex-col gap-6">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">Mercredi 16 septembre</p>
|
<p className="m-0 mb-[7px] text-[12.5px] font-semibold capitalize text-ink-soft">{TODAY}</p>
|
||||||
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">
|
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">
|
||||||
Coffre déverrouillé.
|
Coffre déverrouillé.
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<CreateMenu />
|
<CreateMenu onImport={upload.pickFiles} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(196px,1fr))] gap-3.5">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(196px,1fr))] gap-3.5">
|
||||||
|
|
@ -30,9 +38,21 @@ export function DashboardView({ uploadProgress }: DashboardViewProps) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UploadZone progress={uploadProgress} />
|
<UploadZone
|
||||||
|
uploading={upload.uploading}
|
||||||
|
progress={upload.progress}
|
||||||
|
fileName={upload.fileName}
|
||||||
|
dragging={upload.dragging}
|
||||||
|
error={upload.error}
|
||||||
|
inputRef={upload.inputRef}
|
||||||
|
onPick={upload.pickFiles}
|
||||||
|
onInputChange={upload.onInputChange}
|
||||||
|
onDragOver={upload.onDragOver}
|
||||||
|
onDragLeave={upload.onDragLeave}
|
||||||
|
onDrop={upload.onDrop}
|
||||||
|
/>
|
||||||
|
|
||||||
<RecentFiles />
|
<RecentFiles ref={filesRef} onChange={refreshStats} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,39 @@
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { downloadUrl } from '../../api/files';
|
||||||
import type { FileItem } from '../../types';
|
import type { FileItem } from '../../types';
|
||||||
|
|
||||||
export function FileGridItem({ file }: { file: FileItem }) {
|
interface FileGridItemProps {
|
||||||
|
file: FileItem;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileGridItem({ file, onDelete }: FileGridItemProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 rounded-2xl border border-line bg-field p-3.5 transition-colors hover:bg-panel2">
|
<a
|
||||||
|
href={downloadUrl(file.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="group flex flex-col gap-3 rounded-2xl border border-line bg-field p-3.5 transition-colors hover:bg-panel2"
|
||||||
|
>
|
||||||
<div className="relative h-[88px] overflow-hidden rounded-xl" style={{ background: file.thumbGradient }}>
|
<div className="relative h-[88px] overflow-hidden rounded-xl" style={{ background: file.thumbGradient }}>
|
||||||
|
{file.kind === 'image' && (
|
||||||
|
<img src={downloadUrl(file.id)} alt="" className="h-full w-full object-cover" />
|
||||||
|
)}
|
||||||
<span className="absolute left-2 top-2 rounded-[7px] bg-black/55 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-white backdrop-blur-sm">
|
<span className="absolute left-2 top-2 rounded-[7px] bg-black/55 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-white backdrop-blur-sm">
|
||||||
{file.extLabel}
|
{file.extLabel}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(file.id);
|
||||||
|
}}
|
||||||
|
aria-label={`Supprimer ${file.name}`}
|
||||||
|
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="m-0 mb-1 break-words text-[13.5px] font-semibold leading-snug text-ink">{file.name}</p>
|
<p className="m-0 mb-1 break-words text-[13.5px] font-semibold leading-snug text-ink">{file.name}</p>
|
||||||
|
|
@ -14,6 +41,6 @@ export function FileGridItem({ file }: { file: FileItem }) {
|
||||||
{file.size} · {file.modified}
|
{file.size} · {file.modified}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,43 @@
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { downloadUrl } from '../../api/files';
|
||||||
import type { FileItem } from '../../types';
|
import type { FileItem } from '../../types';
|
||||||
|
|
||||||
export function FileListRow({ file }: { file: FileItem }) {
|
interface FileListRowProps {
|
||||||
|
file: FileItem;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileListRow({ file, onDelete }: FileListRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 border-b border-line px-4 py-3 transition-colors hover:bg-field sm:px-[18px]">
|
<a
|
||||||
<span className="h-[34px] w-[34px] flex-none rounded-[10px]" style={{ background: file.thumbGradient }} />
|
href={downloadUrl(file.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="group flex items-center gap-4 border-b border-line px-4 py-3 transition-colors hover:bg-field sm:px-[18px]"
|
||||||
|
>
|
||||||
|
<span className="h-[34px] w-[34px] flex-none overflow-hidden rounded-[10px]" style={{ background: file.thumbGradient }}>
|
||||||
|
{file.kind === 'image' && (
|
||||||
|
<img src={downloadUrl(file.id)} alt="" className="h-full w-full object-cover" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="m-0 truncate text-[13.5px] font-semibold text-ink">{file.name}</p>
|
<p className="m-0 truncate text-[13.5px] font-semibold text-ink">{file.name}</p>
|
||||||
<p className="m-0 mt-0.5 text-[11.5px] text-ink-soft">{file.extLabel}</p>
|
<p className="m-0 mt-0.5 text-[11.5px] text-ink-soft">{file.extLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-[90px] flex-none text-[12.5px] text-ink-soft">{file.size}</span>
|
<span className="w-[90px] flex-none text-[12.5px] text-ink-soft">{file.size}</span>
|
||||||
<span className="w-[120px] flex-none text-[12.5px] text-ink-soft">{file.modified}</span>
|
<span className="w-[120px] flex-none text-[12.5px] text-ink-soft">{file.modified}</span>
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(file.id);
|
||||||
|
}}
|
||||||
|
aria-label={`Supprimer ${file.name}`}
|
||||||
|
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-ink-soft opacity-0 transition-opacity hover:text-danger group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,30 @@
|
||||||
import { ArrowUpDown, Grid2x2, List } from 'lucide-react';
|
import { ArrowUpDown, Grid2x2, List } from 'lucide-react';
|
||||||
|
import { forwardRef, useImperativeHandle } from 'react';
|
||||||
import { useRecentFiles } from '../../hooks/useRecentFiles';
|
import { useRecentFiles } from '../../hooks/useRecentFiles';
|
||||||
import { FileGridItem } from './FileGridItem';
|
import { FileGridItem } from './FileGridItem';
|
||||||
import { FileListRow } from './FileListRow';
|
import { FileListRow } from './FileListRow';
|
||||||
|
|
||||||
export function RecentFiles() {
|
export interface RecentFilesHandle {
|
||||||
const { files, filters, filter, setFilter, sortDesc, toggleSort, gridView, setGridView } = useRecentFiles();
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentFilesProps {
|
||||||
|
/** Notifié après une suppression réussie, pour que le tableau de bord puisse rafraîchir ses stats. */
|
||||||
|
onChange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecentFiles = forwardRef<RecentFilesHandle, RecentFilesProps>(function RecentFiles(
|
||||||
|
{ onChange },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { files, filters, filter, setFilter, sortDesc, toggleSort, gridView, setGridView, refresh, removeFile } =
|
||||||
|
useRecentFiles();
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({ refresh }), [refresh]);
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
void removeFile(id).then(() => onChange?.());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-[18px] border border-line bg-panel backdrop-blur-md">
|
<div className="overflow-hidden rounded-[18px] border border-line bg-panel backdrop-blur-md">
|
||||||
|
|
@ -61,10 +81,12 @@ export function RecentFiles() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{gridView ? (
|
{files.length === 0 ? (
|
||||||
|
<p className="m-0 p-[18px] text-[13px] text-ink-soft">Aucun fichier pour l'instant.</p>
|
||||||
|
) : gridView ? (
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(188px,1fr))] gap-3.5 p-[18px]">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(188px,1fr))] gap-3.5 p-[18px]">
|
||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<FileGridItem key={file.id} file={file} />
|
<FileGridItem key={file.id} file={file} onDelete={handleDelete} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -75,10 +97,10 @@ export function RecentFiles() {
|
||||||
<span className="w-[120px] flex-none">Modifié</span>
|
<span className="w-[120px] flex-none">Modifié</span>
|
||||||
</div>
|
</div>
|
||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<FileListRow key={file.id} file={file} />
|
<FileListRow key={file.id} file={file} onDelete={handleDelete} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,47 @@
|
||||||
import { Upload } from 'lucide-react';
|
import { Upload } from 'lucide-react';
|
||||||
import { useUploadSimulation } from '../../hooks/useUploadSimulation';
|
import type { ChangeEvent, DragEvent } from 'react';
|
||||||
import type { UploadTask } from '../../types';
|
|
||||||
|
|
||||||
interface UploadZoneProps {
|
interface UploadZoneProps {
|
||||||
/** Piloted externally once real transfers exist; undefined = simulate locally. */
|
uploading: boolean;
|
||||||
progress?: UploadTask['progress'];
|
progress: number;
|
||||||
|
fileName: string;
|
||||||
|
dragging: boolean;
|
||||||
|
error: string | null;
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
onPick: () => void;
|
||||||
|
onInputChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onDragOver: (e: DragEvent) => void;
|
||||||
|
onDragLeave: () => void;
|
||||||
|
onDrop: (e: DragEvent) => void;
|
||||||
|
accept?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
|
export function UploadZone({
|
||||||
const { uploading, progress, dragging, start, onDragOver, onDragLeave, onDrop } =
|
uploading,
|
||||||
useUploadSimulation(externalProgress);
|
progress,
|
||||||
|
fileName,
|
||||||
|
dragging,
|
||||||
|
error,
|
||||||
|
inputRef,
|
||||||
|
onPick,
|
||||||
|
onInputChange,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
accept,
|
||||||
|
}: UploadZoneProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onClick={start}
|
onClick={onPick}
|
||||||
className={`cursor-pointer rounded-[18px] border-[1.5px] border-dashed p-6 transition-colors sm:p-[34px] ${
|
className={`cursor-pointer rounded-[18px] border-[1.5px] border-dashed p-6 transition-colors sm:p-[34px] ${
|
||||||
dragging ? 'border-brand bg-selected' : 'border-line bg-panel'
|
dragging ? 'border-brand bg-selected' : 'border-line bg-panel'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={onInputChange} />
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-[18px]">
|
<div className="flex flex-wrap items-center gap-[18px]">
|
||||||
<span className="flex h-[52px] w-[52px] flex-none items-center justify-center rounded-2xl border border-brand/30 bg-selected text-brand-soft">
|
<span className="flex h-[52px] w-[52px] flex-none items-center justify-center rounded-2xl border border-brand/30 bg-selected text-brand-soft">
|
||||||
<Upload size={22} strokeWidth={2} />
|
<Upload size={22} strokeWidth={2} />
|
||||||
|
|
@ -29,9 +50,7 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
|
||||||
<p className="m-0 mb-[5px] text-[16.5px] font-bold tracking-tight text-ink">
|
<p className="m-0 mb-[5px] text-[16.5px] font-bold tracking-tight text-ink">
|
||||||
{dragging ? 'Relâchez pour importer' : 'Déposez vos fichiers ici'}
|
{dragging ? 'Relâchez pour importer' : 'Déposez vos fichiers ici'}
|
||||||
</p>
|
</p>
|
||||||
<p className="m-0 text-[13px] leading-relaxed text-ink-soft">
|
<p className="m-0 text-[13px] leading-relaxed text-ink-soft">Cliquez ou déposez un fichier à importer.</p>
|
||||||
JPEG, RAW, MP4, PDF — jusqu'à 5 Go par fichier. Cliquez pour simuler un transfert.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-none rounded-[11px] border border-line bg-field px-4 py-2.5 text-[13px] font-semibold text-ink">
|
<span className="flex-none rounded-[11px] border border-line bg-field px-4 py-2.5 text-[13px] font-semibold text-ink">
|
||||||
Parcourir
|
Parcourir
|
||||||
|
|
@ -41,7 +60,7 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
|
||||||
{uploading && (
|
{uploading && (
|
||||||
<div className="mt-6 border-t border-line pt-5">
|
<div className="mt-6 border-t border-line pt-5">
|
||||||
<div className="mb-2.5 flex justify-between gap-3 text-[12.5px] font-semibold text-ink">
|
<div className="mb-2.5 flex justify-between gap-3 text-[12.5px] font-semibold text-ink">
|
||||||
<span>Archives_fiscales_2026.zip</span>
|
<span>{fileName}</span>
|
||||||
<span className="text-brand-soft">{progress} %</span>
|
<span className="text-brand-soft">{progress} %</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-field">
|
<div className="h-2 overflow-hidden rounded-full bg-field">
|
||||||
|
|
@ -51,10 +70,16 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="m-0 mt-2.5 text-xs text-ink-soft">
|
<p className="m-0 mt-2.5 text-xs text-ink-soft">
|
||||||
{progress >= 100 ? 'Transfert terminé — fichier chiffré et vérifié.' : 'Chiffrement en cours côté client…'}
|
{progress >= 100 ? 'Transfert terminé.' : 'Envoi en cours…'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="m-0 mt-4 text-[12.5px] font-semibold text-danger" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,6 @@ export function TripoliSidebar() {
|
||||||
<div className="my-2.5 h-[7px] overflow-hidden rounded-full bg-field">
|
<div className="my-2.5 h-[7px] overflow-hidden rounded-full bg-field">
|
||||||
<div className="h-[7px] rounded-full bg-brand-gradient" style={{ width: `${summary?.percent ?? 0}%` }} />
|
<div className="h-[7px] rounded-full bg-brand-gradient" style={{ width: `${summary?.percent ?? 0}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="m-0 text-[11.5px] leading-relaxed text-ink-soft">{summary?.sealedNote ?? ''}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto flex items-center gap-2.5 rounded-2xl border border-line bg-panel p-2.5">
|
<div className="mt-auto flex items-center gap-2.5 rounded-2xl border border-line bg-panel p-2.5">
|
||||||
|
|
@ -84,7 +83,7 @@ export function TripoliSidebar() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={logout}
|
onClick={() => void logout()}
|
||||||
aria-label="Se déconnecter"
|
aria-label="Se déconnecter"
|
||||||
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-ink-soft"
|
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-ink-soft"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,16 @@ import { DataIcon } from '../icons/DataIcon';
|
||||||
|
|
||||||
export function NotificationCenter() {
|
export function NotificationCenter() {
|
||||||
const { notifOpen, toggleNotif, closeNotif } = useNavigation();
|
const { notifOpen, toggleNotif, closeNotif } = useNavigation();
|
||||||
const { notifications, unreadCount, markAllRead } = useNotifications();
|
const { notifications, unreadCount, markAllRead, refresh } = useNotifications();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleNotif}
|
onClick={() => {
|
||||||
|
refresh();
|
||||||
|
toggleNotif();
|
||||||
|
}}
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
aria-expanded={notifOpen}
|
aria-expanded={notifOpen}
|
||||||
className="relative flex h-[38px] w-[38px] items-center justify-center rounded-[11px] border border-line bg-field text-ink"
|
className="relative flex h-[38px] w-[38px] items-center justify-center rounded-[11px] border border-line bg-field text-ink"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import type { Album } from '../../types';
|
|
||||||
|
|
||||||
export function AlbumCard({ album }: { album: Album }) {
|
|
||||||
return (
|
|
||||||
<div className="w-[200px] flex-none overflow-hidden rounded-2xl border border-line bg-panel">
|
|
||||||
<div className="h-[116px]" style={{ background: album.thumbGradient }} />
|
|
||||||
<div className="p-3.5">
|
|
||||||
<p className="m-0 mb-[3px] text-[13.5px] font-bold text-ink">{album.name}</p>
|
|
||||||
<p className="m-0 text-[11.5px] text-ink-soft">{album.count}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,13 +2,16 @@ import type { PhotoTile as PhotoTileData } from '../../types';
|
||||||
|
|
||||||
export function PhotoTile({ tile }: { tile: PhotoTileData }) {
|
export function PhotoTile({ tile }: { tile: PhotoTileData }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<a
|
||||||
className="relative aspect-square cursor-pointer overflow-hidden rounded-[13px] border border-line transition-transform hover:scale-[1.025]"
|
href={tile.url}
|
||||||
style={{ background: tile.thumbGradient }}
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="relative block aspect-square cursor-pointer overflow-hidden rounded-[13px] border border-line bg-field transition-transform hover:scale-[1.025]"
|
||||||
>
|
>
|
||||||
|
<img src={tile.url} alt="" className="h-full w-full object-cover" loading="lazy" />
|
||||||
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-[rgba(5,6,10,0.72)] to-transparent px-2.5 pb-2 pt-[18px] text-[11px] font-semibold text-white">
|
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-[rgba(5,6,10,0.72)] to-transparent px-2.5 pb-2 pt-[18px] text-[11px] font-semibold text-white">
|
||||||
{tile.label}
|
{tile.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,45 @@
|
||||||
import { usePhotos } from '../../hooks/usePhotos';
|
import { usePhotos } from '../../hooks/usePhotos';
|
||||||
import { AlbumCard } from './AlbumCard';
|
import { useFileUpload } from '../../hooks/useFileUpload';
|
||||||
|
import { UploadZone } from '../dashboard/UploadZone';
|
||||||
import { PhotoTile } from './PhotoTile';
|
import { PhotoTile } from './PhotoTile';
|
||||||
|
|
||||||
export function PhotosView() {
|
export function PhotosView() {
|
||||||
const { albums, tiles, scopes, scope, setScope } = usePhotos();
|
const { tiles, refresh } = usePhotos();
|
||||||
|
const upload = useFileUpload('photo', refresh);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex max-w-[1240px] flex-col gap-6">
|
<div className="flex max-w-[1240px] flex-col gap-6">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">12 480 documents · 318 numérisations</p>
|
<p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">
|
||||||
|
{tiles.length} photo{tiles.length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">Photos</h1>
|
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">Photos</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
{scopes.map((s) => {
|
|
||||||
const active = scope === s;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setScope(s)}
|
|
||||||
className={`rounded-full border px-[15px] py-2 text-[12.5px] font-semibold transition-colors ${
|
|
||||||
active ? 'border-brand/40 bg-selected text-brand-soft' : 'border-line bg-field text-ink-soft'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3.5 overflow-x-auto pb-1">
|
<UploadZone
|
||||||
{albums.map((album) => (
|
uploading={upload.uploading}
|
||||||
<AlbumCard key={album.id} album={album} />
|
progress={upload.progress}
|
||||||
))}
|
fileName={upload.fileName}
|
||||||
</div>
|
dragging={upload.dragging}
|
||||||
|
error={upload.error}
|
||||||
|
inputRef={upload.inputRef}
|
||||||
|
onPick={upload.pickFiles}
|
||||||
|
onInputChange={upload.onInputChange}
|
||||||
|
onDragOver={upload.onDragOver}
|
||||||
|
onDragLeave={upload.onDragLeave}
|
||||||
|
onDrop={upload.onDrop}
|
||||||
|
accept="image/*"
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
{tiles.length === 0 ? (
|
||||||
<h2 className="m-0 mb-3.5 text-sm font-bold tracking-tight text-ink">Septembre 2026</h2>
|
<p className="m-0 text-[13px] text-ink-soft">Aucune photo pour l'instant.</p>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-2.5">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-2.5">
|
||||||
{tiles.map((tile) => (
|
{tiles.map((tile) => (
|
||||||
<PhotoTile key={tile.id} tile={tile} />
|
<PhotoTile key={tile.id} tile={tile} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,20 +35,6 @@ export function SettingsView() {
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<SettingsRowView key={row.id} row={row} onToggle={togglePref} />
|
<SettingsRowView key={row.id} row={row} onToggle={togglePref} />
|
||||||
))}
|
))}
|
||||||
<div className="flex flex-wrap gap-2.5 p-[18px]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-xl bg-brand-gradient px-[18px] py-3 text-[13.5px] font-bold text-white transition-[filter] hover:brightness-110"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-xl border border-line bg-field px-[18px] py-3 text-[13.5px] font-semibold text-ink"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const NavigationContext = createContext<NavigationContextValue | null>(null);
|
||||||
*/
|
*/
|
||||||
export function NavigationProvider({ children }: { children: ReactNode }) {
|
export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||||
const [nav, setNav] = useState<NavKey>('dashboard');
|
const [nav, setNav] = useState<NavKey>('dashboard');
|
||||||
const [settingsTab, setSettingsTab] = useState<SettingsTabKey>('language');
|
const [settingsTab, setSettingsTab] = useState<SettingsTabKey>('account');
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [notifOpen, setNotifOpen] = useState(false);
|
const [notifOpen, setNotifOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,76 @@
|
||||||
import { createContext, useContext, useState, type ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||||
import { login as apiLogin, loginAsGuest, signup as apiSignup, type Credentials, type SignupPayload } from '../api/auth';
|
import {
|
||||||
|
fetchCurrentUser,
|
||||||
|
login as apiLogin,
|
||||||
|
loginAsGuest,
|
||||||
|
logout as apiLogout,
|
||||||
|
signup as apiSignup,
|
||||||
|
type Credentials,
|
||||||
|
type SignupPayload,
|
||||||
|
} from '../api/auth';
|
||||||
import type { AppView, User } from '../types';
|
import type { AppView, User } from '../types';
|
||||||
|
|
||||||
interface SessionContextValue {
|
interface SessionContextValue {
|
||||||
view: AppView;
|
view: AppView;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
|
/** true tant que la session n'a pas été restaurée depuis le cookie au premier chargement. */
|
||||||
|
restoring: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
login: (credentials: Credentials) => Promise<void>;
|
login: (credentials: Credentials) => Promise<void>;
|
||||||
signup: (payload: SignupPayload) => Promise<void>;
|
signup: (payload: SignupPayload) => Promise<void>;
|
||||||
continueAsGuest: () => Promise<void>;
|
continueAsGuest: () => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||||
|
|
||||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [restoring, setRestoring] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const login = async (credentials: Credentials) => {
|
useEffect(() => {
|
||||||
|
void fetchCurrentUser()
|
||||||
|
.then(setUser)
|
||||||
|
.finally(() => setRestoring(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<User>) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setUser(await apiLogin(credentials));
|
setUser(await action());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const signup = async (payload: SignupPayload) => {
|
const login = (credentials: Credentials) => run(() => apiLogin(credentials));
|
||||||
setLoading(true);
|
const signup = (payload: SignupPayload) => run(() => apiSignup(payload));
|
||||||
try {
|
const continueAsGuest = () => run(() => loginAsGuest());
|
||||||
setUser(await apiSignup(payload));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const continueAsGuest = async () => {
|
const logout = async () => {
|
||||||
setLoading(true);
|
await apiLogout();
|
||||||
try {
|
setUser(null);
|
||||||
setUser(await loginAsGuest());
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => setUser(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionContext.Provider
|
<SessionContext.Provider
|
||||||
value={{ view: user ? 'app' : 'auth', user, loading, login, signup, continueAsGuest, logout }}
|
value={{
|
||||||
|
view: user ? 'app' : 'auth',
|
||||||
|
user,
|
||||||
|
restoring,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
signup,
|
||||||
|
continueAsGuest,
|
||||||
|
logout,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SessionContext.Provider>
|
</SessionContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { isValidEmail, isValidPassword } from '../lib/validators';
|
||||||
import type { AuthMode } from '../types';
|
import type { AuthMode } from '../types';
|
||||||
|
|
||||||
export function useAuthForm() {
|
export function useAuthForm() {
|
||||||
const { login, signup, continueAsGuest, loading } = useSession();
|
const { login, signup, continueAsGuest, loading, error } = useSession();
|
||||||
const [mode, setModeState] = useState<AuthMode>('login');
|
const [mode, setModeState] = useState<AuthMode>('login');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
@ -46,5 +46,6 @@ export function useAuthForm() {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
continueAsGuest,
|
continueAsGuest,
|
||||||
loading,
|
loading,
|
||||||
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { getBackups } from '../api/backups';
|
import { getBackups } from '../api/backups';
|
||||||
import type { BackupDevice } from '../types';
|
import type { BackupItem } from '../types';
|
||||||
|
|
||||||
export function useBackups() {
|
export function useBackups() {
|
||||||
const [backups, setBackups] = useState<BackupDevice[]>([]);
|
const [backups, setBackups] = useState<BackupItem[]>([]);
|
||||||
useEffect(() => {
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
void getBackups().then(setBackups);
|
void getBackups().then(setBackups);
|
||||||
}, []);
|
}, []);
|
||||||
return backups;
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { backups, refresh };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { getDashboardStats } from '../api/files';
|
import { getDashboardStats } from '../api/files';
|
||||||
import type { StatItem } from '../types';
|
import type { StatItem } from '../types';
|
||||||
|
|
||||||
export function useDashboardStats() {
|
export function useDashboardStats() {
|
||||||
const [stats, setStats] = useState<StatItem[]>([]);
|
const [stats, setStats] = useState<StatItem[]>([]);
|
||||||
useEffect(() => {
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
void getDashboardStats().then(setStats);
|
void getDashboardStats().then(setStats);
|
||||||
}, []);
|
}, []);
|
||||||
return stats;
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { stats, refresh };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
app/src/hooks/useFileUpload.ts
Normal file
61
app/src/hooks/useFileUpload.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { useRef, useState, type DragEvent } from 'react';
|
||||||
|
import { uploadFile } from '../api/files';
|
||||||
|
|
||||||
|
/** Pilote un vrai transfert (XHR + progression) vers /api/files. */
|
||||||
|
export function useFileUpload(category: 'file' | 'photo' | 'backup', onDone: () => void) {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [fileName, setFileName] = useState('');
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const send = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
setProgress(0);
|
||||||
|
setFileName(file.name);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await uploadFile(file, category, setProgress);
|
||||||
|
onDone();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Échec du transfert');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickFiles = () => inputRef.current?.click();
|
||||||
|
|
||||||
|
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (file) void send(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
};
|
||||||
|
const onDragLeave = () => setDragging(false);
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) void send(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploading,
|
||||||
|
progress,
|
||||||
|
fileName,
|
||||||
|
dragging,
|
||||||
|
error,
|
||||||
|
inputRef,
|
||||||
|
pickFiles,
|
||||||
|
onInputChange,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,29 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { getNotifications } from '../api/notifications';
|
import { getNotifications, markAllNotificationsRead } from '../api/notifications';
|
||||||
import type { NotificationItem } from '../types';
|
import type { NotificationItem } from '../types';
|
||||||
|
|
||||||
export function useNotifications() {
|
export function useNotifications() {
|
||||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||||
const [allRead, setAllRead] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const refresh = useCallback(() => {
|
||||||
void getNotifications().then(setNotifications);
|
void getNotifications().then(setNotifications);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const items = useMemo(
|
useEffect(() => {
|
||||||
() => notifications.map((n) => ({ ...n, unread: n.unread && !allRead })),
|
refresh();
|
||||||
[notifications, allRead],
|
// Un dépôt ou une suppression ailleurs dans l'appli émet une vraie
|
||||||
);
|
// notification côté serveur ; on revérifie à intervalle simple
|
||||||
const unreadCount = items.filter((n) => n.unread).length;
|
// plutôt que de câbler un flux temps réel pour ce volume d'usage.
|
||||||
|
const interval = window.setInterval(refresh, 15000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
return { notifications: items, unreadCount, markAllRead: () => setAllRead(true) };
|
const unreadCount = notifications.filter((n) => n.unread).length;
|
||||||
|
|
||||||
|
const markAllRead = () => {
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })));
|
||||||
|
void markAllNotificationsRead();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { notifications, unreadCount, markAllRead, refresh };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { getAlbums, getPhotoTiles } from '../api/photos';
|
import { getPhotoTiles } from '../api/photos';
|
||||||
import type { Album, PhotoScope, PhotoTile } from '../types';
|
import type { PhotoTile } from '../types';
|
||||||
|
|
||||||
const SCOPES: PhotoScope[] = ['Tout', 'Personnes', 'Lieux'];
|
|
||||||
|
|
||||||
export function usePhotos() {
|
export function usePhotos() {
|
||||||
const [albums, setAlbums] = useState<Album[]>([]);
|
|
||||||
const [tiles, setTiles] = useState<PhotoTile[]>([]);
|
const [tiles, setTiles] = useState<PhotoTile[]>([]);
|
||||||
const [scope, setScope] = useState<PhotoScope>('Tout');
|
|
||||||
|
|
||||||
useEffect(() => {
|
const refresh = useCallback(() => {
|
||||||
void getAlbums().then(setAlbums);
|
|
||||||
void getPhotoTiles().then(setTiles);
|
void getPhotoTiles().then(setTiles);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { albums, tiles, scopes: SCOPES, scope, setScope };
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { tiles, refresh };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { getFiles } from '../api/files';
|
import { deleteFile, getFiles } from '../api/files';
|
||||||
import type { FileFilter, FileItem } from '../types';
|
import type { FileFilter, FileItem } from '../types';
|
||||||
|
|
||||||
const FILTERS: FileFilter[] = ['Tous', 'Dossiers', 'Documents', 'Médias'];
|
const FILTERS: FileFilter[] = ['Tous', 'Documents', 'Médias'];
|
||||||
|
|
||||||
function matchesFilter(file: FileItem, filter: FileFilter): boolean {
|
function matchesFilter(file: FileItem, filter: FileFilter): boolean {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'Tous':
|
case 'Tous':
|
||||||
return true;
|
return true;
|
||||||
case 'Dossiers':
|
|
||||||
return file.kind === 'folder';
|
|
||||||
case 'Documents':
|
case 'Documents':
|
||||||
return file.kind === 'document';
|
return file.kind === 'document' || file.kind === 'archive';
|
||||||
case 'Médias':
|
case 'Médias':
|
||||||
return file.kind === 'video' || file.kind === 'image';
|
return file.kind === 'video' || file.kind === 'image';
|
||||||
}
|
}
|
||||||
|
|
@ -23,10 +21,19 @@ export function useRecentFiles() {
|
||||||
const [sortDesc, setSortDesc] = useState(true);
|
const [sortDesc, setSortDesc] = useState(true);
|
||||||
const [gridView, setGridView] = useState(true);
|
const [gridView, setGridView] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
const refresh = useCallback(() => {
|
||||||
void getFiles().then(setFiles);
|
void getFiles().then(setFiles);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const removeFile = useCallback(async (id: string) => {
|
||||||
|
await deleteFile(id);
|
||||||
|
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const visibleFiles = useMemo(() => {
|
const visibleFiles = useMemo(() => {
|
||||||
const filtered = files.filter((f) => matchesFilter(f, filter));
|
const filtered = files.filter((f) => matchesFilter(f, filter));
|
||||||
return sortDesc ? filtered : filtered.slice().reverse();
|
return sortDesc ? filtered : filtered.slice().reverse();
|
||||||
|
|
@ -41,5 +48,7 @@ export function useRecentFiles() {
|
||||||
toggleSort: () => setSortDesc((v) => !v),
|
toggleSort: () => setSortDesc((v) => !v),
|
||||||
gridView,
|
gridView,
|
||||||
setGridView,
|
setGridView,
|
||||||
|
refresh,
|
||||||
|
removeFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,39 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
getDefaultNotificationPrefs,
|
accountRows,
|
||||||
getStaticSettingsRows,
|
getNotificationPrefs,
|
||||||
NOTIFICATION_PREF_LABELS,
|
NOTIFICATION_PREF_LABELS,
|
||||||
type StaticSettingsRows,
|
setNotificationPrefs,
|
||||||
} from '../api/settings';
|
} from '../api/settings';
|
||||||
|
import { useSession } from '../contexts/SessionContext';
|
||||||
import type { NotificationPrefKey, SettingsRow, SettingsTabKey } from '../types';
|
import type { NotificationPrefKey, SettingsRow, SettingsTabKey } from '../types';
|
||||||
|
|
||||||
const TAB_TITLES: Record<SettingsTabKey, string> = {
|
const TAB_TITLES: Record<SettingsTabKey, string> = {
|
||||||
language: 'Langue et région',
|
|
||||||
notifications: 'Préférences de notification',
|
notifications: 'Préférences de notification',
|
||||||
account: 'Paramètres du compte',
|
account: 'Paramètres du compte',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SETTINGS_TABS: Array<{ key: SettingsTabKey; label: string }> = [
|
export const SETTINGS_TABS: Array<{ key: SettingsTabKey; label: string }> = [
|
||||||
{ key: 'language', label: 'Langue et région' },
|
|
||||||
{ key: 'notifications', label: 'Notifications' },
|
|
||||||
{ key: 'account', label: 'Compte' },
|
{ key: 'account', label: 'Compte' },
|
||||||
|
{ key: 'notifications', label: 'Notifications' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function useSettings(activeTab: SettingsTabKey) {
|
export function useSettings(activeTab: SettingsTabKey) {
|
||||||
const [staticRows, setStaticRows] = useState<StaticSettingsRows | null>(null);
|
const { user } = useSession();
|
||||||
const [prefs, setPrefs] = useState<Record<NotificationPrefKey, boolean> | null>(null);
|
const [prefs, setPrefs] = useState<Record<NotificationPrefKey, boolean>>(() => getNotificationPrefs());
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void getStaticSettingsRows().then(setStaticRows);
|
|
||||||
void getDefaultNotificationPrefs().then(setPrefs);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePref = (key: NotificationPrefKey) => {
|
const togglePref = (key: NotificationPrefKey) => {
|
||||||
setPrefs((prev) => (prev ? { ...prev, [key]: !prev[key] } : prev));
|
setPrefs((prev) => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] };
|
||||||
|
setNotificationPrefs(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows: SettingsRow[] = [];
|
let rows: SettingsRow[] = [];
|
||||||
if (activeTab === 'language') {
|
if (activeTab === 'account') {
|
||||||
rows = staticRows?.language ?? [];
|
rows = user ? accountRows(user) : [];
|
||||||
} else if (activeTab === 'account') {
|
} else {
|
||||||
rows = staticRows?.account ?? [];
|
|
||||||
} else if (prefs) {
|
|
||||||
rows = (Object.keys(NOTIFICATION_PREF_LABELS) as NotificationPrefKey[]).map((key) => ({
|
rows = (Object.keys(NOTIFICATION_PREF_LABELS) as NotificationPrefKey[]).map((key) => ({
|
||||||
kind: 'toggle',
|
kind: 'toggle',
|
||||||
id: key,
|
id: key,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,16 @@ import { getStorageSummary, type StorageSummary } from '../api/vault';
|
||||||
|
|
||||||
export function useStorageSummary() {
|
export function useStorageSummary() {
|
||||||
const [summary, setSummary] = useState<StorageSummary | null>(null);
|
const [summary, setSummary] = useState<StorageSummary | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void getStorageSummary().then(setSummary);
|
const refresh = () => void getStorageSummary().then(setSummary);
|
||||||
|
refresh();
|
||||||
|
// La barre de stockage vit dans la sidebar, hors de portée des
|
||||||
|
// callbacks d'upload des vues - un intervalle simple évite de
|
||||||
|
// faire remonter cet état jusqu'à la racine de l'appli.
|
||||||
|
const interval = window.setInterval(refresh, 15000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { useEffect, useRef, useState, type DragEvent } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the UploadZone's demo transfer. `externalProgress` lets a caller
|
|
||||||
* override the simulated value with real numbers later (the component
|
|
||||||
* itself never knows the difference) — this is the `progress` prop the
|
|
||||||
* brief asked to expose.
|
|
||||||
*/
|
|
||||||
export function useUploadSimulation(externalProgress?: number) {
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [internalProgress, setInternalProgress] = useState(0);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
const timerRef = useRef<number | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => () => window.clearInterval(timerRef.current), []);
|
|
||||||
|
|
||||||
const start = () => {
|
|
||||||
window.clearInterval(timerRef.current);
|
|
||||||
setUploading(true);
|
|
||||||
setInternalProgress(0);
|
|
||||||
setDragging(false);
|
|
||||||
timerRef.current = window.setInterval(() => {
|
|
||||||
setInternalProgress((p) => {
|
|
||||||
const next = Math.min(100, p + 4 + Math.random() * 7);
|
|
||||||
if (next >= 100) window.clearInterval(timerRef.current);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, 280);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragOver = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
const onDragLeave = () => setDragging(false);
|
|
||||||
const onDrop = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
start();
|
|
||||||
};
|
|
||||||
|
|
||||||
const progress = Math.round(externalProgress ?? internalProgress);
|
|
||||||
|
|
||||||
return { uploading, progress, dragging, start, onDragOver, onDragLeave, onDrop };
|
|
||||||
}
|
|
||||||
37
app/src/lib/format.ts
Normal file
37
app/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes <= 0) return '0 Ko';
|
||||||
|
const units = ['Ko', 'Mo', 'Go', 'To'];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value < 10 ? 1 : 0).replace('.', ',')} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeTime(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
|
const diffMin = Math.round(diffMs / 60000);
|
||||||
|
|
||||||
|
if (diffMin < 1) return "à l'instant";
|
||||||
|
if (diffMin < 60) return `il y a ${diffMin} min`;
|
||||||
|
const diffH = Math.round(diffMin / 60);
|
||||||
|
if (diffH < 24) return `il y a ${diffH} h`;
|
||||||
|
const diffD = Math.round(diffH / 24);
|
||||||
|
if (diffD === 1) return 'hier';
|
||||||
|
if (diffD < 7) return `il y a ${diffD} jours`;
|
||||||
|
const diffW = Math.round(diffD / 7);
|
||||||
|
if (diffW < 5) return `il y a ${diffW} sem.`;
|
||||||
|
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -50,27 +50,17 @@ export interface NotificationItem {
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Album {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
count: string;
|
|
||||||
thumbGradient: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PhotoTile {
|
export interface PhotoTile {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
thumbGradient: string;
|
/** URL réelle du fichier (`/api/files/:id`), affichée dans une <img>. */
|
||||||
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackupStatus = 'up-to-date' | 'syncing' | 'late';
|
export interface BackupItem {
|
||||||
|
|
||||||
export interface BackupDevice {
|
|
||||||
id: string;
|
id: string;
|
||||||
device: string;
|
name: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
status: BackupStatus;
|
|
||||||
statusLabel: string;
|
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +73,7 @@ export interface StatItem {
|
||||||
tone: 'brand' | 'success' | 'violet';
|
tone: 'brand' | 'success' | 'violet';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsTabKey = 'language' | 'notifications' | 'account';
|
export type SettingsTabKey = 'notifications' | 'account';
|
||||||
|
|
||||||
export interface SettingsValueRow {
|
export interface SettingsValueRow {
|
||||||
kind: 'value';
|
kind: 'value';
|
||||||
|
|
@ -108,6 +98,5 @@ export type NotificationPrefKey = 'push' | 'email' | 'weekly' | 'shared';
|
||||||
export type AppView = 'auth' | 'app';
|
export type AppView = 'auth' | 'app';
|
||||||
export type NavKey = 'dashboard' | 'photos' | 'backups' | 'settings';
|
export type NavKey = 'dashboard' | 'photos' | 'backups' | 'settings';
|
||||||
export type AuthMode = 'login' | 'signup';
|
export type AuthMode = 'login' | 'signup';
|
||||||
export type FileFilter = 'Tous' | 'Dossiers' | 'Documents' | 'Médias';
|
export type FileFilter = 'Tous' | 'Documents' | 'Médias';
|
||||||
export type PhotoScope = 'Tout' | 'Personnes' | 'Lieux';
|
|
||||||
export type Theme = 'light' | 'dark';
|
export type Theme = 'light' | 'dark';
|
||||||
|
|
|
||||||
34
ci/README.md
34
ci/README.md
|
|
@ -20,12 +20,18 @@ Forgejo mono-utilisateur, pas un service ouvert à des tiers.
|
||||||
|
|
||||||
Le pipeline ne se connecte jamais en root. Un utilisateur système
|
Le pipeline ne se connecte jamais en root. Un utilisateur système
|
||||||
`deploy` existe uniquement pour :
|
`deploy` existe uniquement pour :
|
||||||
1. recevoir le binaire compilé par `scp` dans `/opt/deploy-staging/` ;
|
1. recevoir le binaire compilé (`scp`) ou le build du frontend
|
||||||
2. lancer `sudo /usr/local/sbin/tripoli-deploy.sh <service>` - un
|
(`rsync`) dans `/opt/deploy-staging/` ;
|
||||||
script sudo dédié (voir `/etc/sudoers.d/deploy` sur le serveur) qui
|
2. lancer un des scripts sudo dédiés (voir `/etc/sudoers.d/deploy` sur
|
||||||
n'accepte que `tripoli-edge` ou `tripoli-api` comme argument, installe
|
le serveur), chacun figé sur une seule action :
|
||||||
le binaire à l'emplacement attendu et redémarre exactement ce
|
- `tripoli-deploy.sh <service>` - n'accepte que `tripoli-edge` ou
|
||||||
service. Rien d'autre n'est accessible via ce compte.
|
`tripoli-api`, installe le binaire et redémarre exactement ce
|
||||||
|
service ;
|
||||||
|
- `tripoli-deploy-frontend.sh` - synchronise le build statique vers
|
||||||
|
`/var/www/tripoli-cloud` (servi directement par Caddy, pas de
|
||||||
|
redémarrage de service nécessaire).
|
||||||
|
|
||||||
|
Rien d'autre n'est accessible via ce compte.
|
||||||
|
|
||||||
La clé privée correspondante est stockée comme secret Forgejo Actions
|
La clé privée correspondante est stockée comme secret Forgejo Actions
|
||||||
(`DEPLOY_SSH_KEY`), jamais dans ce dépôt.
|
(`DEPLOY_SSH_KEY`), jamais dans ce dépôt.
|
||||||
|
|
@ -37,9 +43,21 @@ La clé privée correspondante est stockée comme secret Forgejo Actions
|
||||||
pour ce code), `cargo build --workspace`, `cargo test --workspace`,
|
pour ce code), `cargo build --workspace`, `cargo test --workspace`,
|
||||||
puis `npm ci && npm run build` pour le frontend.
|
puis `npm ci && npm run build` pour le frontend.
|
||||||
- `deploy` (uniquement sur `main`, après succès du job précédent) :
|
- `deploy` (uniquement sur `main`, après succès du job précédent) :
|
||||||
build release, puis dépôt + redémarrage de `tripoli-edge` et
|
build release + build frontend, puis dépôt/redémarrage de
|
||||||
`tripoli-api` via le compte `deploy`.
|
`tripoli-edge` et `tripoli-api`, et synchronisation du frontend
|
||||||
|
statique - tout via le compte `deploy`.
|
||||||
|
|
||||||
`tripoli-netctl` et le labo `bird-lab` ne sont pas déployés par ce
|
`tripoli-netctl` et le labo `bird-lab` ne sont pas déployés par ce
|
||||||
pipeline : ce sont des outils de labo qu'on lance à la main, pas des
|
pipeline : ce sont des outils de labo qu'on lance à la main, pas des
|
||||||
services qui tournent en continu.
|
services qui tournent en continu.
|
||||||
|
|
||||||
|
## Frontend : plus de données simulées
|
||||||
|
|
||||||
|
Le frontend appelle désormais réellement `tripoli-api` (auth, upload
|
||||||
|
avec vraie progression XHR, photos/sauvegardes/notifications/espace
|
||||||
|
disque réels). Ce qui n'avait honnêtement rien de réel derrière dans la
|
||||||
|
maquette d'origine a été retiré plutôt que simulé : 2FA, clé de
|
||||||
|
récupération, accès partagés, albums, "appareils sauvegardés", chiffres
|
||||||
|
marketing inventés sur la page de connexion. Détails dans
|
||||||
|
`app/src/api/*.ts` (chaque module documente ce qu'il retire et
|
||||||
|
pourquoi).
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ rand_core = { version = "0.6", features = ["std"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
nix = { version = "0.31", features = ["fs"] }
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,19 @@ implémentations RustCrypto).
|
||||||
| Route | Auth | Rôle |
|
| Route | Auth | Rôle |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET /healthz` | non | ping PostgreSQL |
|
| `GET /healthz` | non | ping PostgreSQL |
|
||||||
| `POST /api/auth/register` | non | `{email, password}` -> crée le compte + session |
|
| `POST /api/auth/register` | non | `{name, email, password}` -> crée le compte + session |
|
||||||
| `POST /api/auth/login` | non | `{email, password}` -> session |
|
| `POST /api/auth/login` | non | `{email, password}` -> session |
|
||||||
|
| `POST /api/auth/guest` | non | connecte (crée si besoin) le compte invité réel `invite@tripoli.local` |
|
||||||
| `POST /api/auth/logout` | oui | invalide la session |
|
| `POST /api/auth/logout` | oui | invalide la session |
|
||||||
| `GET /api/auth/me` | oui | utilisateur courant |
|
| `GET /api/auth/me` | oui | utilisateur courant |
|
||||||
| `GET /api/files` | oui | fichiers de l'utilisateur courant |
|
| `GET /api/files` | oui | fichiers de l'utilisateur courant, `?category=file\|photo\|backup` |
|
||||||
| `POST /api/files` | oui | upload multipart, champ `file` |
|
| `POST /api/files` | oui | upload multipart, champs `file` et `category` (optionnel) |
|
||||||
| `GET /api/files/{id}` | oui | téléchargement (vérifie la propriété) |
|
| `GET /api/files/{id}` | oui | téléchargement (vérifie la propriété) |
|
||||||
| `DELETE /api/files/{id}` | oui | suppression (vérifie la propriété) |
|
| `DELETE /api/files/{id}` | oui | suppression (vérifie la propriété) |
|
||||||
|
| `GET /api/notifications` | oui | notifications de l'utilisateur (émises par upload/suppression) |
|
||||||
|
| `POST /api/notifications/read-all` | oui | marque tout comme lu |
|
||||||
|
| `POST /api/notifications/{id}/read` | oui | marque une notification comme lue |
|
||||||
|
| `GET /api/storage` | oui | `used_bytes` (somme réelle des fichiers), `available_bytes` (`statvfs`) |
|
||||||
|
|
||||||
## Stockage
|
## Stockage
|
||||||
|
|
||||||
|
|
@ -66,8 +71,18 @@ client - le corps de la requête part effectivement au fil de l'eau.
|
||||||
jamais affiché dans un terminal).
|
jamais affiché dans un terminal).
|
||||||
- tripoli-api tourne en systemd (`deploy/tripoli-api.service`) sous le même
|
- tripoli-api tourne en systemd (`deploy/tripoli-api.service`) sous le même
|
||||||
utilisateur système sans privilège `tripoli` que tripoli-edge.
|
utilisateur système sans privilège `tripoli` que tripoli-edge.
|
||||||
- Les migrations (`migrations/0001_init.sql`) s'appliquent automatiquement
|
- Les migrations (`migrations/*.sql`) s'appliquent automatiquement au
|
||||||
au démarrage (`sqlx::migrate!`).
|
démarrage (`sqlx::migrate!`) - `0002_full_features.sql` ajoute `name`
|
||||||
|
aux utilisateurs, `category` aux fichiers, et la table
|
||||||
|
`notifications`.
|
||||||
|
|
||||||
|
## Compte invité
|
||||||
|
|
||||||
|
"Visiter en tant qu'invité" se connecte à un compte réel
|
||||||
|
(`invite@tripoli.local`), créé une seule fois puis réutilisé - jamais
|
||||||
|
une identité simulée côté frontend. Son mot de passe est un UUID
|
||||||
|
aléatoire jamais communiqué : personne ne peut s'y connecter autrement
|
||||||
|
qu'en passant par `POST /api/auth/guest`.
|
||||||
|
|
||||||
## Vérifié en production
|
## Vérifié en production
|
||||||
|
|
||||||
|
|
|
||||||
15
tripoli-api/migrations/0002_full_features.sql
Normal file
15
tripoli-api/migrations/0002_full_features.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE files ADD COLUMN category TEXT NOT NULL DEFAULT 'file';
|
||||||
|
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
read_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX notifications_user_id_idx ON notifications(user_id);
|
||||||
|
|
@ -5,7 +5,7 @@ use axum::http::request::Parts;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use rand_core::OsRng;
|
use rand_core::OsRng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
@ -16,16 +16,32 @@ use crate::state::SharedState;
|
||||||
|
|
||||||
pub const SESSION_COOKIE: &str = "tripoli_session";
|
pub const SESSION_COOKIE: &str = "tripoli_session";
|
||||||
|
|
||||||
|
/// Adresse réservée au compte invité : "Visiter en tant qu'invité" se
|
||||||
|
/// connecte réellement à ce compte plutôt que de simuler une identité -
|
||||||
|
/// mêmes routes, mêmes sessions, même stockage. Pas de mot de passe
|
||||||
|
/// dessus : `guest()` crée ou réutilise ce compte directement.
|
||||||
|
const GUEST_EMAIL: &str = "invite@tripoli.local";
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct Credentials {
|
pub struct Credentials {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterPayload {
|
||||||
|
pub name: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct UserView {
|
pub struct UserView {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
pub is_guest: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_password(password: &str) -> Result<String, AppError> {
|
fn hash_password(password: &str) -> Result<String, AppError> {
|
||||||
|
|
@ -67,25 +83,39 @@ fn session_cookie(session_id: Uuid, ttl_hours: i64) -> Cookie<'static> {
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn to_view(id: Uuid, name: String, email: String, created_at: DateTime<Utc>) -> UserView {
|
||||||
|
let is_guest = email == GUEST_EMAIL;
|
||||||
|
UserView {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
is_guest,
|
||||||
|
created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Json(body): Json<Credentials>,
|
Json(body): Json<RegisterPayload>,
|
||||||
) -> Result<(CookieJar, Json<UserView>), AppError> {
|
) -> Result<(CookieJar, Json<UserView>), AppError> {
|
||||||
if body.email.trim().is_empty() || body.password.len() < 8 {
|
if body.name.trim().is_empty() || body.email.trim().is_empty() || body.password.len() < 8 {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"email requis, mot de passe de 8 caractères minimum".to_string(),
|
"nom et email requis, mot de passe de 8 caractères minimum".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let password_hash = hash_password(&body.password)?;
|
let password_hash = hash_password(&body.password)?;
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
|
|
||||||
sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)")
|
let created_at: DateTime<Utc> = sqlx::query_scalar(
|
||||||
|
"INSERT INTO users (id, name, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING created_at",
|
||||||
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(&body.name)
|
||||||
.bind(&body.email)
|
.bind(&body.email)
|
||||||
.bind(&password_hash)
|
.bind(&password_hash)
|
||||||
.execute(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
|
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
|
||||||
|
|
@ -99,10 +129,7 @@ pub async fn register(
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
jar,
|
jar,
|
||||||
Json(UserView {
|
Json(to_view(user_id, body.name, body.email, created_at)),
|
||||||
id: user_id,
|
|
||||||
email: body.email,
|
|
||||||
}),
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,13 +138,14 @@ pub async fn login(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Json(body): Json<Credentials>,
|
Json(body): Json<Credentials>,
|
||||||
) -> Result<(CookieJar, Json<UserView>), AppError> {
|
) -> Result<(CookieJar, Json<UserView>), AppError> {
|
||||||
let row: Option<(Uuid, String, String)> =
|
let row: Option<(Uuid, String, String, String, DateTime<Utc>)> = sqlx::query_as(
|
||||||
sqlx::query_as("SELECT id, email, password_hash FROM users WHERE email = $1")
|
"SELECT id, name, email, password_hash, created_at FROM users WHERE email = $1",
|
||||||
|
)
|
||||||
.bind(&body.email)
|
.bind(&body.email)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let Some((user_id, email, password_hash)) = row else {
|
let Some((user_id, name, email, password_hash, created_at)) = row else {
|
||||||
return Err(AppError::InvalidCredentials);
|
return Err(AppError::InvalidCredentials);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -128,7 +156,44 @@ pub async fn login(
|
||||||
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
|
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
|
||||||
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
|
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
|
||||||
|
|
||||||
Ok((jar, Json(UserView { id: user_id, email })))
|
Ok((jar, Json(to_view(user_id, name, email, created_at))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compte réel, réutilisé à chaque appel (créé une seule fois, mot de
|
||||||
|
/// passe aléatoire jamais communiqué - personne ne peut se connecter
|
||||||
|
/// autrement qu'en repassant par cette route). "Invité" veut dire
|
||||||
|
/// "compte de démonstration sans inscription", pas "identité simulée".
|
||||||
|
pub async fn guest(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> Result<(CookieJar, Json<UserView>), AppError> {
|
||||||
|
let existing: Option<(Uuid, String, String, DateTime<Utc>)> =
|
||||||
|
sqlx::query_as("SELECT id, name, email, created_at FROM users WHERE email = $1")
|
||||||
|
.bind(GUEST_EMAIL)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (user_id, name, email, created_at) = if let Some(row) = existing {
|
||||||
|
row
|
||||||
|
} else {
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let password_hash = hash_password(&Uuid::new_v4().to_string())?;
|
||||||
|
let created_at: DateTime<Utc> = sqlx::query_scalar(
|
||||||
|
"INSERT INTO users (id, name, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING created_at",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind("Invité")
|
||||||
|
.bind(GUEST_EMAIL)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
(user_id, "Invité".to_string(), GUEST_EMAIL.to_string(), created_at)
|
||||||
|
};
|
||||||
|
|
||||||
|
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
|
||||||
|
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
|
||||||
|
|
||||||
|
Ok((jar, Json(to_view(user_id, name, email, created_at))))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(
|
pub async fn logout(
|
||||||
|
|
@ -146,11 +211,12 @@ pub async fn logout(
|
||||||
Ok(jar.remove(Cookie::from(SESSION_COOKIE)))
|
Ok(jar.remove(Cookie::from(SESSION_COOKIE)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn me(user: AuthUser) -> Json<UserView> {
|
pub async fn me(State(state): State<SharedState>, user: AuthUser) -> Result<Json<UserView>, AppError> {
|
||||||
Json(UserView {
|
let row: (DateTime<Utc>,) = sqlx::query_as("SELECT created_at FROM users WHERE id = $1")
|
||||||
id: user.id,
|
.bind(user.id)
|
||||||
email: user.email,
|
.fetch_one(&state.pool)
|
||||||
})
|
.await?;
|
||||||
|
Ok(Json(to_view(user.id, user.name, user.email, row.0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracteur de requête : présent sur toute route qui exige une session
|
/// Extracteur de requête : présent sur toute route qui exige une session
|
||||||
|
|
@ -158,6 +224,7 @@ pub async fn me(user: AuthUser) -> Json<UserView> {
|
||||||
/// d'atteindre le handler - le handler n'a jamais à vérifier lui-même.
|
/// d'atteindre le handler - le handler n'a jamais à vérifier lui-même.
|
||||||
pub struct AuthUser {
|
pub struct AuthUser {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,8 +241,8 @@ impl FromRequestParts<SharedState> for AuthUser {
|
||||||
.and_then(|c| Uuid::parse_str(c.value()).ok())
|
.and_then(|c| Uuid::parse_str(c.value()).ok())
|
||||||
.ok_or(AppError::Unauthorized)?;
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
let row: Option<(Uuid, String)> = sqlx::query_as(
|
let row: Option<(Uuid, String, String)> = sqlx::query_as(
|
||||||
"SELECT users.id, users.email FROM sessions \
|
"SELECT users.id, users.name, users.email FROM sessions \
|
||||||
JOIN users ON users.id = sessions.user_id \
|
JOIN users ON users.id = sessions.user_id \
|
||||||
WHERE sessions.id = $1 AND sessions.expires_at > now()",
|
WHERE sessions.id = $1 AND sessions.expires_at > now()",
|
||||||
)
|
)
|
||||||
|
|
@ -184,7 +251,7 @@ impl FromRequestParts<SharedState> for AuthUser {
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let (id, email) = row.ok_or(AppError::Unauthorized)?;
|
let (id, name, email) = row.ok_or(AppError::Unauthorized)?;
|
||||||
Ok(AuthUser { id, email })
|
Ok(AuthUser { id, name, email })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Multipart, Path, State};
|
use axum::extract::{Multipart, Path, Query, State};
|
||||||
use axum::http::header;
|
use axum::http::header;
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
@ -12,36 +12,51 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::notifications;
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
|
|
||||||
|
const CATEGORIES: [&str; 3] = ["file", "photo", "backup"];
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct FileView {
|
pub struct FileView {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub content_type: String,
|
pub content_type: String,
|
||||||
|
pub category: String,
|
||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListQuery {
|
||||||
|
category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list(
|
pub async fn list(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
) -> Result<Json<Vec<FileView>>, AppError> {
|
) -> Result<Json<Vec<FileView>>, AppError> {
|
||||||
let files = sqlx::query_as::<_, (Uuid, String, String, i64, chrono::DateTime<chrono::Utc>)>(
|
let files = sqlx::query_as::<_, (Uuid, String, String, String, i64, chrono::DateTime<chrono::Utc>)>(
|
||||||
"SELECT id, filename, content_type, size_bytes, created_at \
|
"SELECT id, filename, content_type, category, size_bytes, created_at \
|
||||||
FROM files WHERE owner_id = $1 ORDER BY created_at DESC",
|
FROM files WHERE owner_id = $1 AND ($2::text IS NULL OR category = $2) \
|
||||||
|
ORDER BY created_at DESC",
|
||||||
)
|
)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
|
.bind(query.category)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, filename, content_type, size_bytes, created_at)| FileView {
|
.map(
|
||||||
|
|(id, filename, content_type, category, size_bytes, created_at)| FileView {
|
||||||
id,
|
id,
|
||||||
filename,
|
filename,
|
||||||
content_type,
|
content_type,
|
||||||
|
category,
|
||||||
size_bytes,
|
size_bytes,
|
||||||
created_at,
|
created_at,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(files))
|
Ok(Json(files))
|
||||||
|
|
@ -52,12 +67,23 @@ pub async fn list(
|
||||||
/// mémoire : c'est ce qui rend la progression d'upload réelle côté
|
/// mémoire : c'est ce qui rend la progression d'upload réelle côté
|
||||||
/// client (le corps de la requête part réellement au fil de l'eau, pas
|
/// client (le corps de la requête part réellement au fil de l'eau, pas
|
||||||
/// d'un coup une fois tout reçu par le serveur).
|
/// d'un coup une fois tout reçu par le serveur).
|
||||||
|
///
|
||||||
|
/// `category` est un champ multipart optionnel envoyé avant `file` par
|
||||||
|
/// le client (photo/backup/file) ; tout le reste du multipart est
|
||||||
|
/// traité comme avant.
|
||||||
pub async fn upload(
|
pub async fn upload(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<FileView>, AppError> {
|
) -> Result<Json<FileView>, AppError> {
|
||||||
let Some(mut field) = multipart
|
let mut category = "file".to_string();
|
||||||
|
|
||||||
|
// `Field` emprunte `multipart` : impossible de le faire sortir de la
|
||||||
|
// boucle sans conflit d'emprunt. On consomme donc les champs
|
||||||
|
// "category" un par un jusqu'à tomber sur le champ fichier, traité
|
||||||
|
// immédiatement dans cette même itération.
|
||||||
|
let mut field = loop {
|
||||||
|
let Some(field) = multipart
|
||||||
.next_field()
|
.next_field()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::BadRequest(e.to_string()))?
|
.map_err(|e| AppError::BadRequest(e.to_string()))?
|
||||||
|
|
@ -65,6 +91,21 @@ pub async fn upload(
|
||||||
return Err(AppError::BadRequest("champ 'file' manquant".to_string()));
|
return Err(AppError::BadRequest("champ 'file' manquant".to_string()));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if field.name() == Some("category") {
|
||||||
|
let value = field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||||
|
if !CATEGORIES.contains(&value.as_str()) {
|
||||||
|
return Err(AppError::BadRequest(format!("catégorie inconnue : {value}")));
|
||||||
|
}
|
||||||
|
category = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break field;
|
||||||
|
};
|
||||||
|
|
||||||
let filename = field
|
let filename = field
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
|
|
@ -93,22 +134,33 @@ pub async fn upload(
|
||||||
tokio::fs::rename(&tmp_path, &final_path).await?;
|
tokio::fs::rename(&tmp_path, &final_path).await?;
|
||||||
|
|
||||||
let created_at: chrono::DateTime<chrono::Utc> = sqlx::query(
|
let created_at: chrono::DateTime<chrono::Utc> = sqlx::query(
|
||||||
"INSERT INTO files (id, owner_id, filename, content_type, size_bytes) \
|
"INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes) \
|
||||||
VALUES ($1, $2, $3, $4, $5) RETURNING created_at",
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING created_at",
|
||||||
)
|
)
|
||||||
.bind(file_id)
|
.bind(file_id)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(&filename)
|
.bind(&filename)
|
||||||
.bind(&content_type)
|
.bind(&content_type)
|
||||||
|
.bind(&category)
|
||||||
.bind(size_bytes)
|
.bind(size_bytes)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.get("created_at");
|
.get("created_at");
|
||||||
|
|
||||||
|
notifications::emit(
|
||||||
|
&state.pool,
|
||||||
|
user.id,
|
||||||
|
"upload",
|
||||||
|
"Transfert terminé",
|
||||||
|
&format!("{filename} a été déposé."),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(FileView {
|
Ok(Json(FileView {
|
||||||
id: file_id,
|
id: file_id,
|
||||||
filename,
|
filename,
|
||||||
content_type,
|
content_type,
|
||||||
|
category,
|
||||||
size_bytes,
|
size_bytes,
|
||||||
created_at,
|
created_at,
|
||||||
}))
|
}))
|
||||||
|
|
@ -134,11 +186,19 @@ pub async fn download(
|
||||||
let file = File::open(&path).await.map_err(|_| AppError::NotFound)?;
|
let file = File::open(&path).await.map_err(|_| AppError::NotFound)?;
|
||||||
let body = Body::from_stream(ReaderStream::new(file));
|
let body = Body::from_stream(ReaderStream::new(file));
|
||||||
|
|
||||||
|
// "inline" pour les images : elles doivent s'afficher dans une
|
||||||
|
// balise <img>, pas déclencher un téléchargement.
|
||||||
|
let disposition_kind = if content_type.starts_with("image/") {
|
||||||
|
"inline"
|
||||||
|
} else {
|
||||||
|
"attachment"
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.header(header::CONTENT_TYPE, content_type)
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
.header(
|
.header(
|
||||||
header::CONTENT_DISPOSITION,
|
header::CONTENT_DISPOSITION,
|
||||||
format!("attachment; filename=\"{filename}\""),
|
format!("{disposition_kind}; filename=\"{filename}\""),
|
||||||
)
|
)
|
||||||
.body(body)
|
.body(body)
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))?)
|
.map_err(|e| AppError::Internal(e.to_string()))?)
|
||||||
|
|
@ -149,15 +209,16 @@ pub async fn delete(
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
Path(file_id): Path<Uuid>,
|
Path(file_id): Path<Uuid>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let result = sqlx::query("DELETE FROM files WHERE id = $1 AND owner_id = $2")
|
let filename: Option<String> =
|
||||||
|
sqlx::query_scalar("DELETE FROM files WHERE id = $1 AND owner_id = $2 RETURNING filename")
|
||||||
.bind(file_id)
|
.bind(file_id)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.execute(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
let Some(filename) = filename else {
|
||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
}
|
};
|
||||||
|
|
||||||
let path = state.blob_dir.join(file_id.to_string());
|
let path = state.blob_dir.join(file_id.to_string());
|
||||||
// Le blob peut déjà être absent (incohérence disque/DB préexistante) :
|
// Le blob peut déjà être absent (incohérence disque/DB préexistante) :
|
||||||
|
|
@ -165,5 +226,14 @@ pub async fn delete(
|
||||||
// qui, du point de vue de l'utilisateur, a réussi.
|
// qui, du point de vue de l'utilisateur, a réussi.
|
||||||
let _ = tokio::fs::remove_file(&path).await;
|
let _ = tokio::fs::remove_file(&path).await;
|
||||||
|
|
||||||
|
notifications::emit(
|
||||||
|
&state.pool,
|
||||||
|
user.id,
|
||||||
|
"delete",
|
||||||
|
"Fichier supprimé",
|
||||||
|
&format!("{filename} a été retiré du coffre."),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod error;
|
mod error;
|
||||||
mod files;
|
mod files;
|
||||||
|
mod notifications;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod storage;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -45,6 +47,7 @@ async fn main() {
|
||||||
.route("/healthz", get(healthz))
|
.route("/healthz", get(healthz))
|
||||||
.route("/api/auth/register", post(auth::register))
|
.route("/api/auth/register", post(auth::register))
|
||||||
.route("/api/auth/login", post(auth::login))
|
.route("/api/auth/login", post(auth::login))
|
||||||
|
.route("/api/auth/guest", post(auth::guest))
|
||||||
.route("/api/auth/logout", post(auth::logout))
|
.route("/api/auth/logout", post(auth::logout))
|
||||||
.route("/api/auth/me", get(auth::me))
|
.route("/api/auth/me", get(auth::me))
|
||||||
.route("/api/files", get(files::list).post(files::upload))
|
.route("/api/files", get(files::list).post(files::upload))
|
||||||
|
|
@ -52,6 +55,16 @@ async fn main() {
|
||||||
"/api/files/{id}",
|
"/api/files/{id}",
|
||||||
get(files::download).delete(files::delete),
|
get(files::download).delete(files::delete),
|
||||||
)
|
)
|
||||||
|
.route("/api/notifications", get(notifications::list))
|
||||||
|
.route(
|
||||||
|
"/api/notifications/read-all",
|
||||||
|
post(notifications::mark_all_read),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/notifications/{id}/read",
|
||||||
|
post(notifications::mark_read),
|
||||||
|
)
|
||||||
|
.route("/api/storage", get(storage::get))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré");
|
tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré");
|
||||||
|
|
|
||||||
95
tripoli-api/src/notifications.rs
Normal file
95
tripoli-api/src/notifications.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::state::SharedState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NotificationView {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub kind: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub unread: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Émet une notification réelle liée à un événement qui vient de se
|
||||||
|
/// produire (upload, suppression, ...). Volontairement best-effort :
|
||||||
|
/// une notification qui échoue à s'insérer ne doit jamais faire échouer
|
||||||
|
/// l'action qui l'a déclenchée.
|
||||||
|
pub async fn emit(pool: &PgPool, user_id: Uuid, kind: &str, title: &str, body: &str) {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO notifications (id, user_id, kind, title, body) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4())
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(title)
|
||||||
|
.bind(body)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
tracing::warn!(error = %err, "échec de l'émission d'une notification");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
user: AuthUser,
|
||||||
|
) -> Result<Json<Vec<NotificationView>>, AppError> {
|
||||||
|
let rows = sqlx::query_as::<_, (Uuid, String, String, String, chrono::DateTime<chrono::Utc>, Option<chrono::DateTime<chrono::Utc>>)>(
|
||||||
|
"SELECT id, kind, title, body, created_at, read_at FROM notifications \
|
||||||
|
WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, kind, title, body, created_at, read_at)| NotificationView {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
created_at,
|
||||||
|
unread: read_at.is_none(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_all_read(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
user: AuthUser,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query("UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL")
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_read(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(notification_id): Path<Uuid>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE notifications SET read_at = now() WHERE id = $1 AND user_id = $2 AND read_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(notification_id)
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
47
tripoli-api/src/storage.rs
Normal file
47
tripoli-api/src/storage.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::state::SharedState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StorageView {
|
||||||
|
/// Somme réelle des tailles de fichiers de cet utilisateur.
|
||||||
|
pub used_bytes: i64,
|
||||||
|
/// Espace réellement disponible sur le disque du serveur qui
|
||||||
|
/// stocke les blobs (`statvfs`, pas un quota inventé).
|
||||||
|
pub available_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pas de quota fictif type "1 To" : l'espace "disponible" est celui
|
||||||
|
/// que `statvfs` rapporte vraiment sur le volume qui stocke
|
||||||
|
/// `blob_dir`, partagé avec le reste du serveur.
|
||||||
|
pub async fn get(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
user: AuthUser,
|
||||||
|
) -> Result<Json<StorageView>, AppError> {
|
||||||
|
// `SUM(bigint)` renvoie un NUMERIC côté Postgres (pour ne jamais
|
||||||
|
// silencieusement déborder) - cast explicite pour revenir à un i64.
|
||||||
|
let used_bytes: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM files WHERE owner_id = $1",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let blob_dir = state.blob_dir.clone();
|
||||||
|
let available_bytes = tokio::task::spawn_blocking(move || -> Result<u64, AppError> {
|
||||||
|
let stats = nix::sys::statvfs::statvfs(&blob_dir)
|
||||||
|
.map_err(|e| AppError::Internal(format!("statvfs : {e}")))?;
|
||||||
|
Ok(stats.blocks_available() as u64 * stats.fragment_size())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))??;
|
||||||
|
|
||||||
|
Ok(Json(StorageView {
|
||||||
|
used_bytes,
|
||||||
|
available_bytes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue