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
|
|
@ -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)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
- Auth : inscription, connexion, invité (compte réel réutilisé),
|
||||
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`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$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 }]
|
||||
}
|
||||
}
|
||||
```bash
|
||||
npm install
|
||||
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
|
||||
```
|
||||
|
||||
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() {
|
||||
const { view } = useSession();
|
||||
const { view, restoring } = useSession();
|
||||
|
||||
if (restoring) return null;
|
||||
if (view === 'auth') return <AuthLayout />;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { User } from '../types';
|
||||
import { mockFetch } from './client';
|
||||
import { ApiError, apiFetch } from './client';
|
||||
|
||||
export interface Credentials {
|
||||
email: string;
|
||||
|
|
@ -10,6 +10,14 @@ export interface SignupPayload extends Credentials {
|
|||
name: string;
|
||||
}
|
||||
|
||||
interface UserView {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
is_guest: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '??';
|
||||
|
|
@ -19,35 +27,49 @@ function initialsOf(name: string): string {
|
|||
.join('');
|
||||
}
|
||||
|
||||
export async function login({ email }: Credentials): Promise<User> {
|
||||
return mockFetch({
|
||||
id: 'usr_1',
|
||||
name: 'Leïla Barghouti',
|
||||
initials: 'LB',
|
||||
email,
|
||||
role: 'Administratrice',
|
||||
isGuest: false,
|
||||
});
|
||||
function toUser(view: UserView): User {
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
initials: initialsOf(view.name || view.email),
|
||||
email: view.email,
|
||||
role: view.is_guest ? 'Accès invité' : 'Compte personnel',
|
||||
isGuest: view.is_guest,
|
||||
};
|
||||
}
|
||||
|
||||
export async function signup({ name, email }: SignupPayload): Promise<User> {
|
||||
return mockFetch({
|
||||
id: 'usr_new',
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
email,
|
||||
role: 'Administratrice',
|
||||
isGuest: false,
|
||||
export async function login({ email, password }: Credentials): Promise<User> {
|
||||
const view = await apiFetch<UserView>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
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> {
|
||||
return mockFetch({
|
||||
id: 'usr_guest',
|
||||
name: 'Invité',
|
||||
initials: 'IN',
|
||||
email: '',
|
||||
role: 'Accès invité',
|
||||
isGuest: true,
|
||||
});
|
||||
const view = await apiFetch<UserView>('/api/auth/guest', { method: 'POST' });
|
||||
return toUser(view);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await apiFetch<void>('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
/** 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 { mockFetch } from './client';
|
||||
import { formatBytes, formatRelativeTime } from '../lib/format';
|
||||
import type { BackupItem } from '../types';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
const STATUS_LABEL: Record<BackupDevice['status'], string> = {
|
||||
'up-to-date': 'À jour',
|
||||
syncing: 'En cours',
|
||||
late: 'Retard 2 j',
|
||||
};
|
||||
|
||||
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' },
|
||||
{ device: 'Tablette de consultation', detail: '38 Go · documents en lecture seule', status: 'syncing', icon: 'tablet' },
|
||||
{ device: 'Coffre secondaire hors site', detail: '812 Go · miroir complet du volume', status: 'late', icon: 'server' },
|
||||
];
|
||||
|
||||
export async function getBackups(): Promise<BackupDevice[]> {
|
||||
return mockFetch(RAW.map((b, i) => ({ ...b, id: `backup_${i}`, statusLabel: STATUS_LABEL[b.status] })));
|
||||
interface FileView {
|
||||
id: string;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pas de suivi multi-appareils (aucun agent n'est déployé sur des
|
||||
* postes clients) : une "sauvegarde" est ici un fichier déposé avec la
|
||||
* catégorie "backup", comme n'importe quel autre dépôt.
|
||||
*/
|
||||
export async function getBackups(): Promise<BackupItem[]> {
|
||||
const views = await apiFetch<FileView[]>('/api/files?category=backup');
|
||||
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
|
||||
* currently resolves a local mock after a short simulated delay. Swapping
|
||||
* in a real backend later means rewriting the body of these functions only
|
||||
* — components and hooks never change.
|
||||
* Toutes les requêtes passent par la même origine (le frontend est servi
|
||||
* par le même Caddy qui route /api/* vers tripoli-edge -> tripoli-api) :
|
||||
* pas besoin de configurer une URL de base, ni de CORS.
|
||||
*/
|
||||
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> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(data), delayMs);
|
||||
});
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
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 type { FileItem, StatItem } from '../types';
|
||||
import { mockFetch } from './client';
|
||||
import { formatBytes, formatRelativeTime } from '../lib/format';
|
||||
import type { FileItem, FileKind, StatItem } from '../types';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
const RAW_FILES: Array<Omit<FileItem, 'id' | 'thumbGradient'>> = [
|
||||
{ name: 'Archives_fiscales_2026.zip', kind: 'archive', extLabel: 'Archive ZIP', size: '2,4 Go', modified: 'il y a 12 min' },
|
||||
{ name: 'Contrats_signes_2026', kind: 'folder', extLabel: 'Dossier · 412 éléments', size: '18,7 Go', modified: 'il y a 2 h' },
|
||||
{ name: 'Acte_de_propriete.pdf', kind: 'document', extLabel: 'Document PDF', size: '1,1 Mo', modified: 'hier' },
|
||||
{ name: 'Backup_poste_principal', kind: 'backup', extLabel: 'Sauvegarde', size: '64 Go', modified: 'hier' },
|
||||
{ name: 'Conseil_administration.mp4', kind: 'video', extLabel: 'Vidéo MP4', size: '820 Mo', modified: 'il y a 3 jours' },
|
||||
{ name: 'Scans_administratifs', kind: 'folder', extLabel: 'Dossier · 38 éléments', size: '240 Mo', modified: 'il y a 5 jours' },
|
||||
{ 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.' },
|
||||
];
|
||||
interface FileView {
|
||||
id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
category: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function getFiles(): Promise<FileItem[]> {
|
||||
return mockFetch(
|
||||
RAW_FILES.map((f, i) => ({
|
||||
...f,
|
||||
id: `file_${i}`,
|
||||
thumbGradient: THUMB_TONES[i % THUMB_TONES.length]!,
|
||||
})),
|
||||
);
|
||||
interface StorageView {
|
||||
used_bytes: number;
|
||||
available_bytes: number;
|
||||
}
|
||||
|
||||
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[]> {
|
||||
return mockFetch([
|
||||
{ id: 'stat_storage', label: 'Espace utilisé', value: '812 Go', note: '188 Go disponibles', icon: 'hard-drive', tone: 'brand' },
|
||||
{ id: 'stat_docs', label: 'Documents', value: '12 480', note: '+142 cette semaine', icon: 'image', tone: 'brand' },
|
||||
{ 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' },
|
||||
const [files, storage] = await Promise.all([
|
||||
apiFetch<FileView[]>('/api/files'),
|
||||
apiFetch<StorageView>('/api/storage'),
|
||||
]);
|
||||
|
||||
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 { mockFetch } from './client';
|
||||
import { formatRelativeTime } from '../lib/format';
|
||||
import type { IconName, NotificationItem } from '../types';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
const RAW: Array<Omit<NotificationItem, 'id'>> = [
|
||||
{ icon: 'upload', title: 'Transfert terminé', body: 'Archives_fiscales_2026.zip a été chiffré et scellé.', time: 'il y a 12 min', unread: true },
|
||||
{ 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 },
|
||||
{ icon: 'check', title: 'Sauvegarde iPhone', body: '64 Go synchronisés et vérifiés, sans erreur.', time: 'hier, 23:40', unread: false },
|
||||
{ icon: 'alert', title: 'Espace disque', body: "Il reste 18 % d'espace libre sur le volume principal.", time: 'il y a 2 jours', unread: false },
|
||||
];
|
||||
interface NotificationView {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
const ICON_BY_KIND: Record<string, IconName> = {
|
||||
upload: 'upload',
|
||||
delete: 'alert',
|
||||
};
|
||||
|
||||
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 type { Album, PhotoTile } from '../types';
|
||||
import { mockFetch } from './client';
|
||||
import { formatDateTime } from '../lib/format';
|
||||
import type { PhotoTile } from '../types';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
const RAW_ALBUMS: Array<Omit<Album, 'id' | 'thumbGradient'>> = [
|
||||
{ name: 'Contrats', count: '412 éléments' },
|
||||
{ name: 'Titres de propriété', count: '318 éléments' },
|
||||
{ name: 'Numérisations 1998', count: '219 éléments' },
|
||||
{ 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]! })),
|
||||
);
|
||||
interface FileView {
|
||||
id: string;
|
||||
filename: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
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[]> {
|
||||
return mockFetch(
|
||||
Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `tile_${i}`,
|
||||
label: `16 sept. · ${9 + i}:0${i % 6}`,
|
||||
thumbGradient: tone(TILE_TONES[i % TILE_TONES.length]!, '#141821'),
|
||||
})),
|
||||
);
|
||||
const views = await apiFetch<FileView[]>('/api/files?category=photo');
|
||||
return views.map((v) => ({
|
||||
id: v.id,
|
||||
label: formatDateTime(v.created_at),
|
||||
url: `/api/files/${v.id}`,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,20 @@
|
|||
import type { NotificationPrefKey, SettingsValueRow } from '../types';
|
||||
import { mockFetch } from './client';
|
||||
import { formatDateTime } from '../lib/format';
|
||||
import type { NotificationPrefKey, SettingsValueRow, User } from '../types';
|
||||
|
||||
export interface StaticSettingsRows {
|
||||
language: SettingsValueRow[];
|
||||
account: SettingsValueRow[];
|
||||
}
|
||||
const PREFS_STORAGE_KEY = 'tripoli-cloud:notification-prefs';
|
||||
|
||||
export async function getStaticSettingsRows(): Promise<StaticSettingsRows> {
|
||||
return mockFetch({
|
||||
language: [
|
||||
{ kind: 'value', id: 'lang', label: "Langue de l'interface", help: 'S’applique à tous les postes connectés à ce coffre.', value: 'Français' },
|
||||
{ kind: 'value', id: 'region', label: 'Région', help: 'Détermine le format des dates, nombres et unités.', value: 'Tunisie' },
|
||||
{ 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' },
|
||||
],
|
||||
account: [
|
||||
{ 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: 'leila@domaine.tn' },
|
||||
{ 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' },
|
||||
],
|
||||
});
|
||||
/**
|
||||
* Section "Compte" construite à partir du vrai utilisateur courant.
|
||||
* Les champs fictifs de la maquette d'origine (2FA, clé de récupération,
|
||||
* accès partagés) n'ont aucune fonctionnalité réelle derrière et ont
|
||||
* été retirés plutôt que simulés.
|
||||
*/
|
||||
export function accountRows(user: User): SettingsValueRow[] {
|
||||
return [
|
||||
{ kind: 'value', id: 'display-name', label: 'Nom affiché', help: 'Visible sur ce compte.', value: user.name || '—' },
|
||||
{ kind: 'value', id: 'email', label: 'Adresse e-mail', help: "Sert d'identifiant de connexion.", value: user.email || '—' },
|
||||
{ kind: 'value', id: 'type', label: 'Type de compte', help: '', value: user.isGuest ? 'Invité (démonstration)' : 'Compte personnel' },
|
||||
];
|
||||
}
|
||||
|
||||
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.' },
|
||||
};
|
||||
|
||||
export async function getDefaultNotificationPrefs(): Promise<Record<NotificationPrefKey, boolean>> {
|
||||
return mockFetch({ push: true, email: false, weekly: true, shared: true });
|
||||
const DEFAULT_PREFS: Record<NotificationPrefKey, boolean> = {
|
||||
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 {
|
||||
label: string;
|
||||
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> {
|
||||
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 { 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() {
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#07080c]">
|
||||
|
|
@ -31,17 +25,8 @@ export function AuthLayout() {
|
|||
sous clé.
|
||||
</h1>
|
||||
<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
|
||||
détenez la clé.
|
||||
Documents, photos et sauvegardes sur un serveur que vous contrôlez.
|
||||
</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>
|
||||
|
||||
<AuthPanel />
|
||||
|
|
@ -49,7 +34,7 @@ export function AuthLayout() {
|
|||
|
||||
<div className="flex flex-wrap gap-5 text-xs text-white/34">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export function AuthPanel() {
|
|||
handleSubmit,
|
||||
continueAsGuest,
|
||||
loading,
|
||||
error,
|
||||
} = useAuthForm();
|
||||
|
||||
const isSignup = mode === 'signup';
|
||||
|
|
@ -29,7 +30,7 @@ export function AuthPanel() {
|
|||
{isSignup ? 'Créer votre espace' : 'Bon retour'}
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,16 @@
|
|||
import type { BackupDevice } from '../../types';
|
||||
import type { BackupItem } from '../../types';
|
||||
import { DataIcon } from '../icons/DataIcon';
|
||||
|
||||
const STATUS_CLASSES: Record<BackupDevice['status'], string> = {
|
||||
'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 }) {
|
||||
export function BackupRow({ backup }: { backup: BackupItem }) {
|
||||
return (
|
||||
<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} />
|
||||
</span>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,44 @@
|
|||
import { useBackups } from '../../hooks/useBackups';
|
||||
import { useFileUpload } from '../../hooks/useFileUpload';
|
||||
import { UploadZone } from '../dashboard/UploadZone';
|
||||
import { BackupRow } from './BackupRow';
|
||||
|
||||
export function BackupsView() {
|
||||
const backups = useBackups();
|
||||
const { backups, refresh } = useBackups();
|
||||
const upload = useFileUpload('backup', refresh);
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[960px] flex-col gap-6">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{backups.map((b) => (
|
||||
<BackupRow key={b.id} backup={b} />
|
||||
))}
|
||||
</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">
|
||||
{backups.map((b) => (
|
||||
<BackupRow key={b.id} backup={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,24 @@
|
|||
import { Plus } from 'lucide-react';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import { DataIcon } from '../icons/DataIcon';
|
||||
import type { IconName } from '../../types';
|
||||
import { Plus, Upload } from 'lucide-react';
|
||||
|
||||
const OPTIONS: Array<{ icon: IconName; label: string }> = [
|
||||
{ icon: 'folder-plus', label: 'Nouveau dossier' },
|
||||
{ icon: 'file-pen', label: 'Nouveau document' },
|
||||
{ icon: 'upload', label: 'Importer des fichiers' },
|
||||
];
|
||||
|
||||
export function CreateMenu() {
|
||||
const { createOpen, toggleCreate, closeCreate } = useNavigation();
|
||||
interface CreateMenuProps {
|
||||
onImport: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCreate}
|
||||
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"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.2} />
|
||||
Créer
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImport}
|
||||
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} />
|
||||
<Upload size={16} strokeWidth={2.2} />
|
||||
Importer
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,35 @@
|
|||
import { useRef } from 'react';
|
||||
import { useDashboardStats } from '../../hooks/useDashboardStats';
|
||||
import { useFileUpload } from '../../hooks/useFileUpload';
|
||||
import { CreateMenu } from './CreateMenu';
|
||||
import { RecentFiles } from './RecentFiles';
|
||||
import { RecentFiles, type RecentFilesHandle } from './RecentFiles';
|
||||
import { StatCard } from './StatCard';
|
||||
import { UploadZone } from './UploadZone';
|
||||
|
||||
interface DashboardViewProps {
|
||||
/** Forwarded to UploadZone — undefined lets it run its own demo transfer. */
|
||||
uploadProgress?: number;
|
||||
}
|
||||
const TODAY = new Date().toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
export function DashboardView({ uploadProgress }: DashboardViewProps) {
|
||||
const stats = useDashboardStats();
|
||||
export function DashboardView() {
|
||||
const { stats, refresh: refreshStats } = useDashboardStats();
|
||||
const filesRef = useRef<RecentFilesHandle>(null);
|
||||
const upload = useFileUpload('file', () => {
|
||||
filesRef.current?.refresh();
|
||||
refreshStats();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[1240px] flex-col gap-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<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]">
|
||||
Coffre déverrouillé.
|
||||
</h1>
|
||||
</div>
|
||||
<CreateMenu />
|
||||
<CreateMenu onImport={upload.pickFiles} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(196px,1fr))] gap-3.5">
|
||||
|
|
@ -30,9 +38,21 @@ export function DashboardView({ uploadProgress }: DashboardViewProps) {
|
|||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,39 @@
|
|||
import { Trash2 } from 'lucide-react';
|
||||
import { downloadUrl } from '../../api/files';
|
||||
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 (
|
||||
<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 }}>
|
||||
{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">
|
||||
{file.extLabel}
|
||||
</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>
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,43 @@
|
|||
import { Trash2 } from 'lucide-react';
|
||||
import { downloadUrl } from '../../api/files';
|
||||
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 (
|
||||
<div className="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 rounded-[10px]" style={{ background: file.thumbGradient }} />
|
||||
<a
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</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 { forwardRef, useImperativeHandle } from 'react';
|
||||
import { useRecentFiles } from '../../hooks/useRecentFiles';
|
||||
import { FileGridItem } from './FileGridItem';
|
||||
import { FileListRow } from './FileListRow';
|
||||
|
||||
export function RecentFiles() {
|
||||
const { files, filters, filter, setFilter, sortDesc, toggleSort, gridView, setGridView } = useRecentFiles();
|
||||
export interface RecentFilesHandle {
|
||||
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 (
|
||||
<div className="overflow-hidden rounded-[18px] border border-line bg-panel backdrop-blur-md">
|
||||
|
|
@ -61,10 +81,12 @@ export function RecentFiles() {
|
|||
</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]">
|
||||
{files.map((file) => (
|
||||
<FileGridItem key={file.id} file={file} />
|
||||
<FileGridItem key={file.id} file={file} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -75,10 +97,10 @@ export function RecentFiles() {
|
|||
<span className="w-[120px] flex-none">Modifié</span>
|
||||
</div>
|
||||
{files.map((file) => (
|
||||
<FileListRow key={file.id} file={file} />
|
||||
<FileListRow key={file.id} file={file} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +1,47 @@
|
|||
import { Upload } from 'lucide-react';
|
||||
import { useUploadSimulation } from '../../hooks/useUploadSimulation';
|
||||
import type { UploadTask } from '../../types';
|
||||
import type { ChangeEvent, DragEvent } from 'react';
|
||||
|
||||
interface UploadZoneProps {
|
||||
/** Piloted externally once real transfers exist; undefined = simulate locally. */
|
||||
progress?: UploadTask['progress'];
|
||||
uploading: boolean;
|
||||
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) {
|
||||
const { uploading, progress, dragging, start, onDragOver, onDragLeave, onDrop } =
|
||||
useUploadSimulation(externalProgress);
|
||||
|
||||
export function UploadZone({
|
||||
uploading,
|
||||
progress,
|
||||
fileName,
|
||||
dragging,
|
||||
error,
|
||||
inputRef,
|
||||
onPick,
|
||||
onInputChange,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
accept,
|
||||
}: UploadZoneProps) {
|
||||
return (
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={start}
|
||||
onClick={onPick}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={onInputChange} />
|
||||
|
||||
<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">
|
||||
<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">
|
||||
{dragging ? 'Relâchez pour importer' : 'Déposez vos fichiers ici'}
|
||||
</p>
|
||||
<p className="m-0 text-[13px] leading-relaxed text-ink-soft">
|
||||
JPEG, RAW, MP4, PDF — jusqu'à 5 Go par fichier. Cliquez pour simuler un transfert.
|
||||
</p>
|
||||
<p className="m-0 text-[13px] leading-relaxed text-ink-soft">Cliquez ou déposez un fichier à importer.</p>
|
||||
</div>
|
||||
<span className="flex-none rounded-[11px] border border-line bg-field px-4 py-2.5 text-[13px] font-semibold text-ink">
|
||||
Parcourir
|
||||
|
|
@ -41,7 +60,7 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
|
|||
{uploading && (
|
||||
<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">
|
||||
<span>Archives_fiscales_2026.zip</span>
|
||||
<span>{fileName}</span>
|
||||
<span className="text-brand-soft">{progress} %</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-field">
|
||||
|
|
@ -51,10 +70,16 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
|
|||
/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="m-0 mt-4 text-[12.5px] font-semibold text-danger" onClick={(e) => e.stopPropagation()}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ export function TripoliSidebar() {
|
|||
<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>
|
||||
<p className="m-0 text-[11.5px] leading-relaxed text-ink-soft">{summary?.sealedNote ?? ''}</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
onClick={() => void logout()}
|
||||
aria-label="Se déconnecter"
|
||||
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() {
|
||||
const { notifOpen, toggleNotif, closeNotif } = useNavigation();
|
||||
const { notifications, unreadCount, markAllRead } = useNotifications();
|
||||
const { notifications, unreadCount, markAllRead, refresh } = useNotifications();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleNotif}
|
||||
onClick={() => {
|
||||
refresh();
|
||||
toggleNotif();
|
||||
}}
|
||||
aria-label="Notifications"
|
||||
aria-expanded={notifOpen}
|
||||
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 }) {
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-square cursor-pointer overflow-hidden rounded-[13px] border border-line transition-transform hover:scale-[1.025]"
|
||||
style={{ background: tile.thumbGradient }}
|
||||
<a
|
||||
href={tile.url}
|
||||
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">
|
||||
{tile.label}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,45 @@
|
|||
import { usePhotos } from '../../hooks/usePhotos';
|
||||
import { AlbumCard } from './AlbumCard';
|
||||
import { useFileUpload } from '../../hooks/useFileUpload';
|
||||
import { UploadZone } from '../dashboard/UploadZone';
|
||||
import { PhotoTile } from './PhotoTile';
|
||||
|
||||
export function PhotosView() {
|
||||
const { albums, tiles, scopes, scope, setScope } = usePhotos();
|
||||
const { tiles, refresh } = usePhotos();
|
||||
const upload = useFileUpload('photo', refresh);
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[1240px] flex-col gap-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">12 480 documents · 318 numérisations</p>
|
||||
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">Photos</h1>
|
||||
</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">
|
||||
{albums.map((album) => (
|
||||
<AlbumCard key={album.id} album={album} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="m-0 mb-3.5 text-sm font-bold tracking-tight text-ink">Septembre 2026</h2>
|
||||
<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>
|
||||
</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}
|
||||
accept="image/*"
|
||||
/>
|
||||
|
||||
{tiles.length === 0 ? (
|
||||
<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">
|
||||
{tiles.map((tile) => (
|
||||
<PhotoTile key={tile.id} tile={tile} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,20 +35,6 @@ export function SettingsView() {
|
|||
{rows.map((row) => (
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const NavigationContext = createContext<NavigationContextValue | null>(null);
|
|||
*/
|
||||
export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const [nav, setNav] = useState<NavKey>('dashboard');
|
||||
const [settingsTab, setSettingsTab] = useState<SettingsTabKey>('language');
|
||||
const [settingsTab, setSettingsTab] = useState<SettingsTabKey>('account');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [notifOpen, setNotifOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,55 +1,76 @@
|
|||
import { createContext, useContext, useState, type ReactNode } from 'react';
|
||||
import { login as apiLogin, loginAsGuest, signup as apiSignup, type Credentials, type SignupPayload } from '../api/auth';
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
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';
|
||||
|
||||
interface SessionContextValue {
|
||||
view: AppView;
|
||||
user: User | null;
|
||||
/** true tant que la session n'a pas été restaurée depuis le cookie au premier chargement. */
|
||||
restoring: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login: (credentials: Credentials) => Promise<void>;
|
||||
signup: (payload: SignupPayload) => Promise<void>;
|
||||
continueAsGuest: () => Promise<void>;
|
||||
logout: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [restoring, setRestoring] = useState(true);
|
||||
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);
|
||||
setError(null);
|
||||
try {
|
||||
setUser(await apiLogin(credentials));
|
||||
setUser(await action());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signup = async (payload: SignupPayload) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setUser(await apiSignup(payload));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const login = (credentials: Credentials) => run(() => apiLogin(credentials));
|
||||
const signup = (payload: SignupPayload) => run(() => apiSignup(payload));
|
||||
const continueAsGuest = () => run(() => loginAsGuest());
|
||||
|
||||
const continueAsGuest = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setUser(await loginAsGuest());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const logout = async () => {
|
||||
await apiLogout();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const logout = () => setUser(null);
|
||||
|
||||
return (
|
||||
<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}
|
||||
</SessionContext.Provider>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { isValidEmail, isValidPassword } from '../lib/validators';
|
|||
import type { AuthMode } from '../types';
|
||||
|
||||
export function useAuthForm() {
|
||||
const { login, signup, continueAsGuest, loading } = useSession();
|
||||
const { login, signup, continueAsGuest, loading, error } = useSession();
|
||||
const [mode, setModeState] = useState<AuthMode>('login');
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
|
|
@ -46,5 +46,6 @@ export function useAuthForm() {
|
|||
handleSubmit,
|
||||
continueAsGuest,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getBackups } from '../api/backups';
|
||||
import type { BackupDevice } from '../types';
|
||||
import type { BackupItem } from '../types';
|
||||
|
||||
export function useBackups() {
|
||||
const [backups, setBackups] = useState<BackupDevice[]>([]);
|
||||
useEffect(() => {
|
||||
const [backups, setBackups] = useState<BackupItem[]>([]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
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 type { StatItem } from '../types';
|
||||
|
||||
export function useDashboardStats() {
|
||||
const [stats, setStats] = useState<StatItem[]>([]);
|
||||
useEffect(() => {
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
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 { getNotifications } from '../api/notifications';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getNotifications, markAllNotificationsRead } from '../api/notifications';
|
||||
import type { NotificationItem } from '../types';
|
||||
|
||||
export function useNotifications() {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [allRead, setAllRead] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = useCallback(() => {
|
||||
void getNotifications().then(setNotifications);
|
||||
}, []);
|
||||
|
||||
const items = useMemo(
|
||||
() => notifications.map((n) => ({ ...n, unread: n.unread && !allRead })),
|
||||
[notifications, allRead],
|
||||
);
|
||||
const unreadCount = items.filter((n) => n.unread).length;
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
// Un dépôt ou une suppression ailleurs dans l'appli émet une vraie
|
||||
// notification côté serveur ; on revérifie à intervalle simple
|
||||
// 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 { getAlbums, getPhotoTiles } from '../api/photos';
|
||||
import type { Album, PhotoScope, PhotoTile } from '../types';
|
||||
|
||||
const SCOPES: PhotoScope[] = ['Tout', 'Personnes', 'Lieux'];
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getPhotoTiles } from '../api/photos';
|
||||
import type { PhotoTile } from '../types';
|
||||
|
||||
export function usePhotos() {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [tiles, setTiles] = useState<PhotoTile[]>([]);
|
||||
const [scope, setScope] = useState<PhotoScope>('Tout');
|
||||
|
||||
useEffect(() => {
|
||||
void getAlbums().then(setAlbums);
|
||||
const refresh = useCallback(() => {
|
||||
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 { getFiles } from '../api/files';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { deleteFile, getFiles } from '../api/files';
|
||||
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 {
|
||||
switch (filter) {
|
||||
case 'Tous':
|
||||
return true;
|
||||
case 'Dossiers':
|
||||
return file.kind === 'folder';
|
||||
case 'Documents':
|
||||
return file.kind === 'document';
|
||||
return file.kind === 'document' || file.kind === 'archive';
|
||||
case 'Médias':
|
||||
return file.kind === 'video' || file.kind === 'image';
|
||||
}
|
||||
|
|
@ -23,10 +21,19 @@ export function useRecentFiles() {
|
|||
const [sortDesc, setSortDesc] = useState(true);
|
||||
const [gridView, setGridView] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = useCallback(() => {
|
||||
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 filtered = files.filter((f) => matchesFilter(f, filter));
|
||||
return sortDesc ? filtered : filtered.slice().reverse();
|
||||
|
|
@ -41,5 +48,7 @@ export function useRecentFiles() {
|
|||
toggleSort: () => setSortDesc((v) => !v),
|
||||
gridView,
|
||||
setGridView,
|
||||
refresh,
|
||||
removeFile,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,39 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
getDefaultNotificationPrefs,
|
||||
getStaticSettingsRows,
|
||||
accountRows,
|
||||
getNotificationPrefs,
|
||||
NOTIFICATION_PREF_LABELS,
|
||||
type StaticSettingsRows,
|
||||
setNotificationPrefs,
|
||||
} from '../api/settings';
|
||||
import { useSession } from '../contexts/SessionContext';
|
||||
import type { NotificationPrefKey, SettingsRow, SettingsTabKey } from '../types';
|
||||
|
||||
const TAB_TITLES: Record<SettingsTabKey, string> = {
|
||||
language: 'Langue et région',
|
||||
notifications: 'Préférences de notification',
|
||||
account: 'Paramètres du compte',
|
||||
};
|
||||
|
||||
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: 'notifications', label: 'Notifications' },
|
||||
];
|
||||
|
||||
export function useSettings(activeTab: SettingsTabKey) {
|
||||
const [staticRows, setStaticRows] = useState<StaticSettingsRows | null>(null);
|
||||
const [prefs, setPrefs] = useState<Record<NotificationPrefKey, boolean> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void getStaticSettingsRows().then(setStaticRows);
|
||||
void getDefaultNotificationPrefs().then(setPrefs);
|
||||
}, []);
|
||||
const { user } = useSession();
|
||||
const [prefs, setPrefs] = useState<Record<NotificationPrefKey, boolean>>(() => getNotificationPrefs());
|
||||
|
||||
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[] = [];
|
||||
if (activeTab === 'language') {
|
||||
rows = staticRows?.language ?? [];
|
||||
} else if (activeTab === 'account') {
|
||||
rows = staticRows?.account ?? [];
|
||||
} else if (prefs) {
|
||||
if (activeTab === 'account') {
|
||||
rows = user ? accountRows(user) : [];
|
||||
} else {
|
||||
rows = (Object.keys(NOTIFICATION_PREF_LABELS) as NotificationPrefKey[]).map((key) => ({
|
||||
kind: 'toggle',
|
||||
id: key,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,16 @@ import { getStorageSummary, type StorageSummary } from '../api/vault';
|
|||
|
||||
export function useStorageSummary() {
|
||||
const [summary, setSummary] = useState<StorageSummary | null>(null);
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
count: string;
|
||||
thumbGradient: string;
|
||||
}
|
||||
|
||||
export interface PhotoTile {
|
||||
id: 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 BackupDevice {
|
||||
export interface BackupItem {
|
||||
id: string;
|
||||
device: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
status: BackupStatus;
|
||||
statusLabel: string;
|
||||
icon: IconName;
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +73,7 @@ export interface StatItem {
|
|||
tone: 'brand' | 'success' | 'violet';
|
||||
}
|
||||
|
||||
export type SettingsTabKey = 'language' | 'notifications' | 'account';
|
||||
export type SettingsTabKey = 'notifications' | 'account';
|
||||
|
||||
export interface SettingsValueRow {
|
||||
kind: 'value';
|
||||
|
|
@ -108,6 +98,5 @@ export type NotificationPrefKey = 'push' | 'email' | 'weekly' | 'shared';
|
|||
export type AppView = 'auth' | 'app';
|
||||
export type NavKey = 'dashboard' | 'photos' | 'backups' | 'settings';
|
||||
export type AuthMode = 'login' | 'signup';
|
||||
export type FileFilter = 'Tous' | 'Dossiers' | 'Documents' | 'Médias';
|
||||
export type PhotoScope = 'Tout' | 'Personnes' | 'Lieux';
|
||||
export type FileFilter = 'Tous' | 'Documents' | 'Médias';
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue