Import Tripoli Cloud frontend prototype

React/TypeScript/Vite/Tailwind prototype from the design session (login,
dashboard, photos, backups, settings views with mocked API layer).
Infra work (Rust services, Ansible, OpenTofu) follows in subsequent commits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-16 21:21:43 +02:00
commit 5dbd3b96f6
73 changed files with 4789 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
.DS_Store
*.zip
package-lock.json
!/app/package-lock.json
# Rust
target/
**/*.rs.bk
# Ansible
*.retry
# OpenTofu / Terraform
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!*.tfvars.example
.terraform.lock.hcl
# Secrets
*.pem
*.key
!**/wireguard/*.example

52
HANDOFF.md Normal file
View file

@ -0,0 +1,52 @@
# Handoff — Tripoli Cloud
À coller dans la nouvelle session Claude Code **locale** (CLI, sur votre Mac) pour lui redonner le contexte. Cette session cloud n'a aucune persistance au-delà de cette conversation.
## Ce qui est fait (session cloud, non déployé)
- Prototype Claude Design (`Tripoli Cloud.dc.html`) implémenté en React + TypeScript + Vite + Tailwind dans `app/`.
- Vues : Login/Signup (glassmorphism sombre, cross-fade bg), Dashboard (stats, upload simulé, fichiers grille/liste), Photos, Sauvegardes, Réglages (3 onglets), bouton "Visiter en tant qu'invité", aucune mention famille.
- Architecture : `src/api/` (mocks typés async), `src/types.ts`, `src/contexts/` (Theme/Session/Navigation), `src/hooks/`, composants par domaine.
- `npm run build` passe, testé au navigateur (dark/light, mobile drawer, 4 vues) — captures OK.
- **Rien n'est commité au git de ce repo, aucun remote configuré, rien n'est déployé.** Le zip joint contient tout `app/` (sans node_modules/dist) — faites `npm install` après extraction.
## Ce qui reste : déploiement infra réelle (PAS fait, PAS commencé)
Brief complet donné par vous plus tôt dans cette session — reproduit tel quel :
> Brief technique — Tripoli Cloud v1 — déploiement sur Hetzner CPX22 (eu-central)
>
> CONTEXTE : Projet personnel réel (cloud familial : photos, backup, partage de fichiers), construit pour démontrer des compétences SRE proches de la stack de Clever Cloud (Rust, netlink, WireGuard, GitLab CI). Un seul VPS, pas un cluster. Sois honnête dans le code et les commentaires sur cette limite, ne simule jamais un comportement multi-nœuds/multi-DC.
>
> CONTRAINTES NON NÉGOCIABLES
> - 1 seul serveur (CPX22, 2 vCPU, 80 Go disque). Pas de virtualisation imbriquée disponible : NE TENTE PAS de mise en place KVM/QEMU sur ce serveur, ce n'est pas possible chez Hetzner Cloud.
> - Tout le code métier (edge, API, orchestrateur réseau) en Rust. Pas d'application open source clé en main (pas de Nextcloud/Seafile/etc.) pour le service principal.
> - Code commenté et compréhensible : je dois pouvoir défendre chaque ligne en entretien technique, n'utilise pas d'abstractions opaques.
> - Avance phase par phase, commit GitLab à la fin de chaque phase, attends ma validation avant de passer à la phase suivante. N'exécute aucune commande destructive sur le serveur sans me l'expliquer avant.
>
> SERVEUR
> IP : 65.109.142.188
> Utilisateur : root
> Clé SSH : cherche une clé nommée "hetzner-2026" (probablement dans ~/.ssh/) et utilise-la pour la connexion.
> Domaine : tripoli.cloud (DNS à pointer vers l'IP ci-dessus)
>
> PHASE 0 — Connexion & reconnaissance : SSH avec la clé hetzner-2026 sur root@65.109.142.188. Explorer avant toute action (OS, paquets, services, ports, disque, utilisateurs, /etc). Rapport avant d'agir.
>
> PHASE 1 — Socle & sécurité : firewall (nftables/ufw, seulement 80/443/WireGuard en entrée), WireGuard (serveur + peer admin Mac, vérifier le tunnel AVANT de fermer SSH public), Ansible (rôle de base), OpenTofu (serveur + DNS en code).
>
> PHASE 2 — Edge proxy (mini-Sōzu) : projet Rust `tripoli-edge` (TLS Let's Encrypt, routage par Host header, logs structurés, `/metrics`).
>
> PHASE 3 — API backend : projet Rust `tripoli-api` (axum/actix-web) : auth, upload/download, métadonnées PostgreSQL (sqlx), blobs disque ou Hetzner Object Storage, progression d'upload réelle.
>
> PHASE 4 — Réseau bas niveau : projet Rust `tripoli-netctl` (crate `rtnetlink`) : network namespace + veth par service, audit-logué, documenté comme labo pédagogique un-seul-poste.
>
> PHASE 5 — Observabilité : Prometheus + Grafana (dashboards versionnés), alertes disque plein / service down.
>
> PHASE 6 (bonus) : Bird (BGP/BFD) entre les namespaces de la Phase 4, présenté comme labo, pas comme failover de prod.
>
> CI/CD : `.gitlab-ci.yml` (build cargo+npm, test cargo, deploy rsync/scp + redémarrage systemd, unit files versionnés).
>
> LIVRABLE FINAL : README avec schéma d'architecture, ce qui est réellement en place vs simulé, limites assumées (1 VPS, pas de KVM).
## Contexte de sécurité important
- Ce brief avait été collé une première fois **en réponse à une question sans rapport** (emplacement du dossier du projet React), ce qui ressemblait à une confusion ou un copier-coller involontaire. Vous avez ensuite confirmé explicitement vouloir bien lancer ce déploiement.
- Aucune commande destructive (fermeture SSH publique, etc.) ne doit être exécutée sans explication préalable, comme précisé dans votre propre brief.
- Vérifiez vous-même, avant de lancer la session locale, que la clé "hetzner-2026" existe bien dans `~/.ssh/` sur votre Mac et que l'IP/le domaine sont corrects.

1
app/.env Normal file
View file

@ -0,0 +1 @@
VITE_API_URL=

3
app/.env.example Normal file
View file

@ -0,0 +1,3 @@
# Base URL of the real Tripoli Cloud API. Left empty for now: src/api/
# returns typed mocks until the backend exists.
VITE_API_URL=

24
app/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
app/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
app/README.md Normal file
View file

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@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/)
## React Compiler
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).
## Expanding the Oxlint configuration
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 }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

13
app/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tripoli Cloud</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2383
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
app/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^1.46.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"autoprefixer": "^10.6.1",
"oxlint": "^1.81.0",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}

6
app/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
app/public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
app/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

49
app/src/App.tsx Normal file
View file

@ -0,0 +1,49 @@
import { AuthLayout } from './components/auth/AuthLayout';
import { AppShell } from './components/layout/AppShell';
import { BackupsView } from './components/backups/BackupsView';
import { DashboardView } from './components/dashboard/DashboardView';
import { PhotosView } from './components/photos/PhotosView';
import { SettingsView } from './components/settings/SettingsView';
import { NavigationProvider, useNavigation } from './contexts/NavigationContext';
import { SessionProvider, useSession } from './contexts/SessionContext';
import { ThemeProvider } from './contexts/ThemeContext';
/** Content router — a plain state switch (NavigationContext.nav), no react-router. */
function AppContent() {
const { nav } = useNavigation();
switch (nav) {
case 'dashboard':
return <DashboardView />;
case 'photos':
return <PhotosView />;
case 'backups':
return <BackupsView />;
case 'settings':
return <SettingsView />;
}
}
function Root() {
const { view } = useSession();
if (view === 'auth') return <AuthLayout />;
return (
<NavigationProvider>
<AppShell>
<AppContent />
</AppShell>
</NavigationProvider>
);
}
export default function App() {
return (
<ThemeProvider>
<SessionProvider>
<Root />
</SessionProvider>
</ThemeProvider>
);
}

53
app/src/api/auth.ts Normal file
View file

@ -0,0 +1,53 @@
import type { User } from '../types';
import { mockFetch } from './client';
export interface Credentials {
email: string;
password: string;
}
export interface SignupPayload extends Credentials {
name: string;
}
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '??';
return parts
.slice(0, 2)
.map((p) => p[0]!.toUpperCase())
.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,
});
}
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 loginAsGuest(): Promise<User> {
return mockFetch({
id: 'usr_guest',
name: 'Invité',
initials: 'IN',
email: '',
role: 'Accès invité',
isGuest: true,
});
}

19
app/src/api/backups.ts Normal file
View file

@ -0,0 +1,19 @@
import type { BackupDevice } from '../types';
import { mockFetch } 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] })));
}

13
app/src/api/client.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* 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.
*/
export const API_BASE = import.meta.env.VITE_API_URL;
export function mockFetch<T>(data: T, delayMs = 220): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => resolve(data), delayMs);
});
}

33
app/src/api/files.ts Normal file
View file

@ -0,0 +1,33 @@
import { THUMB_TONES } from '../lib/gradients';
import type { FileItem, StatItem } from '../types';
import { mockFetch } 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.' },
];
export async function getFiles(): Promise<FileItem[]> {
return mockFetch(
RAW_FILES.map((f, i) => ({
...f,
id: `file_${i}`,
thumbGradient: THUMB_TONES[i % THUMB_TONES.length]!,
})),
);
}
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' },
]);
}

View file

@ -0,0 +1,13 @@
import type { NotificationItem } from '../types';
import { mockFetch } 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 },
];
export async function getNotifications(): Promise<NotificationItem[]> {
return mockFetch(RAW.map((n, i) => ({ ...n, id: `notif_${i}` })));
}

28
app/src/api/photos.ts Normal file
View file

@ -0,0 +1,28 @@
import { THUMB_TONES, tone } from '../lib/gradients';
import type { Album, PhotoTile } from '../types';
import { mockFetch } 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]! })),
);
}
const TILE_TONES = ['#3b4a63', '#4a3f5f', '#51413a', '#33505a', '#3d4a41', '#46384f'];
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'),
})),
);
}

36
app/src/api/settings.ts Normal file
View file

@ -0,0 +1,36 @@
import type { NotificationPrefKey, SettingsValueRow } from '../types';
import { mockFetch } from './client';
export interface StaticSettingsRows {
language: SettingsValueRow[];
account: SettingsValueRow[];
}
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' },
],
});
}
export const NOTIFICATION_PREF_LABELS: Record<NotificationPrefKey, { label: string; help: string }> = {
push: { label: 'Notifications push', help: 'Alertes sur vos appareils mobiles et de bureau.' },
email: { label: 'Résumé par e-mail', help: 'Un e-mail à chaque dépôt ou retrait important.' },
weekly: { label: 'Rapport hebdomadaire', help: 'État des sauvegardes envoyé chaque lundi.' },
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 });
}

11
app/src/api/vault.ts Normal file
View file

@ -0,0 +1,11 @@
import { mockFetch } from './client';
export interface StorageSummary {
label: string;
percent: number;
sealedNote: string;
}
export async function getStorageSummary(): Promise<StorageSummary> {
return mockFetch({ label: '812 Go / 1 To', percent: 81, sealedNote: 'Coffre scellé il y a 2 h' });
}

View file

@ -0,0 +1,20 @@
import { BACKGROUND_IMAGES } from '../../lib/backgrounds';
import { useBackgroundCrossfade } from '../../hooks/useBackgroundCrossfade';
export function AuthBackground() {
const activeIndex = useBackgroundCrossfade();
return (
<div className="absolute inset-0 overflow-hidden bg-[#07080c]">
{BACKGROUND_IMAGES.map((bg, i) => (
<div
key={bg}
className="absolute inset-0 scale-105 bg-cover bg-center transition-opacity duration-[2000ms] ease-in-out"
style={{ background: bg, opacity: i === activeIndex ? 1 : 0 }}
/>
))}
<div className="absolute inset-0 bg-[linear-gradient(100deg,rgba(6,7,11,0.92)_0%,rgba(6,7,11,0.62)_46%,rgba(6,7,11,0.28)_100%)]" />
<div className="absolute -right-[8%] -top-[18%] h-[52vw] w-[52vw] rounded-full bg-[radial-gradient(circle,rgba(91,140,255,0.26)_0%,transparent_66%)] blur-[10px]" />
</div>
);
}

View file

@ -0,0 +1,58 @@
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]">
<AuthBackground />
<div className="relative flex min-h-screen flex-col justify-between gap-11 p-6 sm:p-10 lg:p-[60px]">
<div className="flex items-center gap-2.5 text-white">
<TripoliLogo size={30} variant="hero" />
<TripoliWordmark className="text-base" />
</div>
<div className="flex flex-wrap items-center justify-between gap-8 sm:gap-14 lg:gap-[72px]">
<div className="min-w-0 max-w-[560px] flex-1 basis-[340px] text-white">
<span className="inline-flex items-center gap-2 rounded-full border border-white/14 bg-white/8 px-[13px] py-1.5 text-xs font-semibold tracking-wide text-white/80 backdrop-blur-md">
<span className="h-1.5 w-1.5 animate-tc-pulse rounded-full bg-success" />
Coffre-fort en ligne
</span>
<h1 className="m-0 my-4 text-[36px] font-extrabold leading-[1.04] tracking-tight text-balance sm:text-[48px] lg:text-[62px]">
Vos documents,
<br />
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é.
</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 />
</div>
<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>Sans publicité, sans traçage</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,163 @@
import { AlertCircle, ArrowRight, Eye, Lock, Mail, ShieldCheck } from 'lucide-react';
import { useAuthForm } from '../../hooks/useAuthForm';
export function AuthPanel() {
const {
mode,
setMode,
name,
setName,
email,
setEmail,
password,
setPassword,
emailError,
passwordError,
handleSubmit,
continueAsGuest,
loading,
} = useAuthForm();
const isSignup = mode === 'signup';
return (
<div
className="w-full max-w-[430px] rounded-[22px] border border-white/16 bg-white/[0.075] p-6 shadow-[0_32px_80px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.18)] backdrop-blur-2xl backdrop-saturate-150 sm:p-[34px]"
style={{ colorScheme: 'dark' }}
>
<h2 className="m-0 mb-1.5 text-[23px] font-bold tracking-tight text-white">
{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.'}
</p>
<div className="mb-6 flex gap-1 rounded-xl border border-white/8 bg-black/28 p-1">
<button
type="button"
onClick={() => setMode('login')}
className={`flex-1 rounded-[9px] py-2.5 text-[13px] font-semibold transition-colors ${
mode === 'login' ? 'bg-white/14 text-white' : 'bg-transparent text-white/55'
}`}
>
Connexion
</button>
<button
type="button"
onClick={() => setMode('signup')}
className={`flex-1 rounded-[9px] py-2.5 text-[13px] font-semibold transition-colors ${
mode === 'signup' ? 'bg-white/14 text-white' : 'bg-transparent text-white/55'
}`}
>
Créer un compte
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{isSignup && (
<div className="flex flex-col gap-1.5">
<label htmlFor="tc-name" className="text-[12.5px] font-semibold text-white/72">
Nom complet
</label>
<input
id="tc-name"
name="name"
type="text"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Leïla Barghouti"
className="w-full rounded-xl border border-white/14 bg-black/26 px-[15px] py-3.5 text-[14.5px] text-white outline-none placeholder:text-white/34"
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<label htmlFor="tc-email" className="text-[12.5px] font-semibold text-white/72">
Adresse e-mail
</label>
<div className="relative flex items-center">
<Mail size={16} className="absolute left-3.5 text-white/40" strokeWidth={1.8} />
<input
id="tc-email"
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={emailError}
aria-describedby="tc-email-err"
placeholder="vous@domaine.tn"
className={`w-full rounded-xl border bg-black/26 py-3.5 pl-10 pr-[15px] text-[14.5px] text-white outline-none placeholder:text-white/34 ${
emailError ? 'border-danger' : 'border-white/14'
}`}
/>
</div>
{emailError && (
<span id="tc-email-err" className="flex items-center gap-1.5 text-xs text-danger">
<AlertCircle size={13} />
Saisissez une adresse e-mail valide.
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-3">
<label htmlFor="tc-pass" className="text-[12.5px] font-semibold text-white/72">
Mot de passe
</label>
<a href="#tc-pass" className="text-xs text-brand-soft hover:text-[#a8c2ff]">
Oublié ?
</a>
</div>
<div className="relative flex items-center">
<Lock size={16} className="absolute left-3.5 text-white/40" strokeWidth={1.8} />
<input
id="tc-pass"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={passwordError}
aria-describedby="tc-pass-err"
placeholder="••••••••••"
className={`w-full rounded-xl border bg-black/26 py-3.5 pl-10 pr-[15px] text-[14.5px] text-white outline-none placeholder:text-white/34 ${
passwordError ? 'border-danger' : 'border-white/14'
}`}
/>
</div>
{passwordError && (
<span id="tc-pass-err" className="flex items-center gap-1.5 text-xs text-danger">
<AlertCircle size={13} />
Huit caractères minimum.
</span>
)}
</div>
<button
type="submit"
disabled={loading}
className="mt-1 flex w-full items-center justify-center gap-2.5 rounded-xl bg-brand-gradient px-[18px] py-3.5 text-[14.5px] font-bold text-white shadow-[0_12px_30px_rgba(91,140,255,0.34)] transition-[filter] hover:brightness-110 disabled:opacity-70"
>
<span>{isSignup ? 'Créer mon compte' : 'Se connecter'}</span>
<ArrowRight size={17} />
</button>
<button
type="button"
onClick={() => void continueAsGuest()}
className="flex w-full items-center justify-center gap-2 rounded-xl border border-white/18 bg-white/6 px-[18px] py-[13px] text-[13.5px] font-semibold text-white transition-colors hover:bg-white/12"
>
<Eye size={16} strokeWidth={1.8} />
Visiter en tant qu'invité
</button>
<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.
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,31 @@
import type { BackupDevice } 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 }) {
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]}`}>
<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 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>
);
}

View file

@ -0,0 +1,20 @@
import { useBackups } from '../../hooks/useBackups';
import { BackupRow } from './BackupRow';
export function BackupsView() {
const backups = useBackups();
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>
<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>
</div>
);
}

View file

@ -0,0 +1,46 @@
import { Plus } 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 }> = [
{ 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();
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>
);
}

View file

@ -0,0 +1,38 @@
import { useDashboardStats } from '../../hooks/useDashboardStats';
import { CreateMenu } from './CreateMenu';
import { RecentFiles } 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;
}
export function DashboardView({ uploadProgress }: DashboardViewProps) {
const stats = useDashboardStats();
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>
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">
Coffre déverrouillé.
</h1>
</div>
<CreateMenu />
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(196px,1fr))] gap-3.5">
{stats.map((stat) => (
<StatCard key={stat.id} stat={stat} />
))}
</div>
<UploadZone progress={uploadProgress} />
<RecentFiles />
</div>
);
}

View file

@ -0,0 +1,19 @@
import type { FileItem } from '../../types';
export function FileGridItem({ file }: { file: FileItem }) {
return (
<div className="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 }}>
<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>
</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 text-[11.5px] text-ink-soft">
{file.size} · {file.modified}
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
import type { FileItem } from '../../types';
export function FileListRow({ file }: { file: FileItem }) {
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 }} />
<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>
);
}

View file

@ -0,0 +1,84 @@
import { ArrowUpDown, Grid2x2, List } from 'lucide-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();
return (
<div className="overflow-hidden rounded-[18px] border border-line bg-panel backdrop-blur-md">
<div className="flex flex-wrap items-center justify-between gap-3.5 border-b border-line px-4 py-4 sm:px-[18px]">
<h2 className="m-0 text-base font-bold tracking-tight text-ink">Fichiers récents</h2>
<div className="flex flex-wrap items-center gap-2">
{filters.map((f) => {
const active = filter === f;
return (
<button
key={f}
type="button"
onClick={() => setFilter(f)}
className={`rounded-full border px-[13px] py-1.5 text-[12.5px] font-semibold transition-colors ${
active ? 'border-brand/40 bg-selected text-brand-soft' : 'border-line bg-field text-ink-soft'
}`}
>
{f}
</button>
);
})}
<button
type="button"
onClick={toggleSort}
className="flex items-center gap-1.5 rounded-full border border-line bg-field px-[13px] py-1.5 text-[12.5px] font-semibold text-ink"
>
<ArrowUpDown size={14} strokeWidth={1.8} />
{sortDesc ? 'Plus récents' : 'Plus anciens'}
</button>
<div className="flex gap-0.5 rounded-[11px] border border-line bg-field p-[3px]">
<button
type="button"
onClick={() => setGridView(true)}
aria-label="Vue grille"
aria-pressed={gridView}
className={`flex h-7 w-[30px] items-center justify-center rounded-lg ${
gridView ? 'bg-selected text-brand-soft' : 'text-ink-soft'
}`}
>
<Grid2x2 size={15} strokeWidth={1.9} />
</button>
<button
type="button"
onClick={() => setGridView(false)}
aria-label="Vue liste"
aria-pressed={!gridView}
className={`flex h-7 w-[30px] items-center justify-center rounded-lg ${
!gridView ? 'bg-selected text-brand-soft' : 'text-ink-soft'
}`}
>
<List size={15} strokeWidth={1.9} />
</button>
</div>
</div>
</div>
{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} />
))}
</div>
) : (
<div>
<div className="flex items-center gap-4 border-b border-line px-4 py-2.5 text-[11.5px] font-bold uppercase tracking-wide text-ink-soft sm:px-[18px]">
<span className="flex-1">Nom</span>
<span className="w-[90px] flex-none">Taille</span>
<span className="w-[120px] flex-none">Modifié</span>
</div>
{files.map((file) => (
<FileListRow key={file.id} file={file} />
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,23 @@
import type { StatItem } from '../../types';
import { DataIcon } from '../icons/DataIcon';
const TONE_CLASSES: Record<StatItem['tone'], string> = {
brand: 'bg-selected text-brand-soft',
success: 'bg-success/16 text-success',
violet: 'bg-violet/16 text-violet',
};
export function StatCard({ stat }: { stat: StatItem }) {
return (
<div className="rounded-2xl border border-line bg-panel p-[18px] backdrop-blur-md">
<div className="mb-3.5 flex items-center justify-between gap-2.5">
<span className="text-[12.5px] font-semibold text-ink-soft">{stat.label}</span>
<span className={`flex h-7 w-7 items-center justify-center rounded-[9px] ${TONE_CLASSES[stat.tone]}`}>
<DataIcon name={stat.icon} size={15} />
</span>
</div>
<p className="m-0 mb-1 text-[27px] font-extrabold tracking-tight text-ink">{stat.value}</p>
<p className="m-0 text-xs text-ink-soft">{stat.note}</p>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { Upload } from 'lucide-react';
import { useUploadSimulation } from '../../hooks/useUploadSimulation';
import type { UploadTask } from '../../types';
interface UploadZoneProps {
/** Piloted externally once real transfers exist; undefined = simulate locally. */
progress?: UploadTask['progress'];
}
export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
const { uploading, progress, dragging, start, onDragOver, onDragLeave, onDrop } =
useUploadSimulation(externalProgress);
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={start}
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'
}`}
>
<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} />
</span>
<div className="min-w-0 flex-1 basis-[260px]">
<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>
</div>
<span className="flex-none rounded-[11px] border border-line bg-field px-4 py-2.5 text-[13px] font-semibold text-ink">
Parcourir
</span>
</div>
{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 className="text-brand-soft">{progress} %</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-field">
<div
className="upload-progress-stripes h-2 rounded-full bg-brand-gradient transition-[width] duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</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…'}
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,14 @@
import type { IconName } from '../../types';
import { ICON_REGISTRY } from './iconRegistry';
interface DataIconProps {
name: IconName;
size?: number;
className?: string;
}
/** Renders a lucide-react icon selected by the string key our API mocks return. */
export function DataIcon({ name, size = 16, className }: DataIconProps) {
const Icon = ICON_REGISTRY[name];
return <Icon size={size} className={className} strokeWidth={1.8} />;
}

View file

@ -0,0 +1,31 @@
import {
AlertTriangle,
CheckCircle2,
FilePen,
FolderPlus,
HardDrive,
Image,
Laptop,
Server,
Share2,
Smartphone,
Tablet,
Upload,
type LucideIcon,
} from 'lucide-react';
import type { IconName } from '../../types';
export const ICON_REGISTRY: Record<IconName, LucideIcon> = {
'hard-drive': HardDrive,
image: Image,
smartphone: Smartphone,
share: Share2,
'folder-plus': FolderPlus,
'file-pen': FilePen,
upload: Upload,
check: CheckCircle2,
alert: AlertTriangle,
laptop: Laptop,
tablet: Tablet,
server: Server,
};

View file

@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { DrawerOverlay } from './DrawerOverlay';
import { TopBar } from './TopBar';
import { TripoliSidebar } from './TripoliSidebar';
export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="relative min-h-screen overflow-hidden bg-bg">
<div className="pointer-events-none fixed -left-[140px] -top-[220px] h-[620px] w-[620px] rounded-full bg-[radial-gradient(circle,rgba(91,140,255,0.16)_0%,transparent_68%)]" />
<div className="pointer-events-none fixed -bottom-[260px] -right-[160px] h-[680px] w-[680px] rounded-full bg-[radial-gradient(circle,rgba(124,92,255,0.14)_0%,transparent_68%)]" />
<div className="relative flex min-h-screen items-stretch">
<TripoliSidebar />
<DrawerOverlay />
<div className="flex min-w-0 flex-1 flex-col">
<TopBar />
<main className="min-w-0 flex-1 p-5 sm:p-9">{children}</main>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
import { useNavigation } from '../../contexts/NavigationContext';
import { useIsMobile } from '../../hooks/useIsMobile';
export function DrawerOverlay() {
const { drawerOpen, closeDrawer } = useNavigation();
const isMobile = useIsMobile();
if (!isMobile || !drawerOpen) return null;
return (
<div
onClick={closeDrawer}
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm"
aria-hidden="true"
/>
);
}

View file

@ -0,0 +1,47 @@
import { Menu, Moon, Search, Sun } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { useNavigation } from '../../contexts/NavigationContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { NotificationCenter } from '../notifications/NotificationCenter';
export function TopBar() {
const { theme, toggleTheme } = useTheme();
const { openDrawer } = useNavigation();
const isMobile = useIsMobile();
return (
<header className="sticky top-0 z-[60] flex items-center gap-3 border-b border-line bg-panel p-3.5 backdrop-blur-xl sm:px-8">
{isMobile && (
<button
type="button"
onClick={openDrawer}
aria-label="Ouvrir le menu"
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-[11px] border border-line bg-field text-ink"
>
<Menu size={18} />
</button>
)}
<div className="flex max-w-[440px] flex-1 items-center gap-2.5 rounded-xl border border-line bg-field px-3.5 py-2.5">
<Search size={16} className="flex-none opacity-50" strokeWidth={1.8} />
<input
aria-label="Rechercher un fichier"
placeholder="Rechercher un document, un dossier, une référence…"
className="w-full min-w-0 flex-1 bg-transparent text-[13.5px] text-ink outline-none"
/>
</div>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={toggleTheme}
aria-label="Basculer clair / sombre"
className="flex h-[38px] w-[38px] items-center justify-center rounded-[11px] border border-line bg-field text-ink"
>
{theme === 'dark' ? <Moon size={17} strokeWidth={1.8} /> : <Sun size={17} strokeWidth={1.8} />}
</button>
<NotificationCenter />
</div>
</header>
);
}

View file

@ -0,0 +1,32 @@
interface TripoliLogoProps {
size?: number;
variant?: 'hero' | 'sidebar';
}
/** Minimalist inline SVG mark — no external asset, easy to swap later. */
export function TripoliLogo({ size = 28, variant = 'sidebar' }: TripoliLogoProps) {
const badgeFill = variant === 'hero' ? 'rgba(255,255,255,0.08)' : 'rgba(91,140,255,0.14)';
const badgeStroke = variant === 'hero' ? 'rgba(255,255,255,0.22)' : 'rgba(91,140,255,0.36)';
return (
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" aria-hidden="true">
<rect x="1" y="1" width="30" height="30" rx="9" fill={badgeFill} stroke={badgeStroke} />
<path
d="M10 20.5h11a3.6 3.6 0 0 0 .3-7.2 5.6 5.6 0 0 0-10.7-1.1A3.6 3.6 0 0 0 10 20.5z"
fill="none"
stroke="#7ba4ff"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="16" cy="16.6" r="1.5" fill="#5b8cff" />
</svg>
);
}
export function TripoliWordmark({ className = '' }: { className?: string }) {
return (
<span className={`text-[15px] font-bold tracking-tight ${className}`}>
Tripoli<span className="font-medium opacity-55"> Cloud</span>
</span>
);
}

View file

@ -0,0 +1,96 @@
import { DatabaseBackup, Image, LayoutGrid, LogOut, Settings, X } from 'lucide-react';
import { useNavigation } from '../../contexts/NavigationContext';
import { useSession } from '../../contexts/SessionContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useStorageSummary } from '../../hooks/useStorageSummary';
import type { NavKey } from '../../types';
import { TripoliLogo, TripoliWordmark } from './TripoliLogo';
const NAV_ITEMS: Array<{ key: NavKey; label: string; icon: typeof LayoutGrid }> = [
{ key: 'dashboard', label: 'Tableau de bord', icon: LayoutGrid },
{ key: 'photos', label: 'Photos', icon: Image },
{ key: 'backups', label: 'Sauvegardes', icon: DatabaseBackup },
{ key: 'settings', label: 'Réglages', icon: Settings },
];
export function TripoliSidebar() {
const { nav, goTo, drawerOpen, closeDrawer } = useNavigation();
const { user, logout } = useSession();
const isMobile = useIsMobile();
const summary = useStorageSummary();
if (isMobile && !drawerOpen) return null;
return (
<aside
className={`z-40 flex w-64 flex-none flex-col gap-6 border-r border-line bg-panel2 p-[18px] pt-6 backdrop-blur-2xl ${
isMobile ? 'fixed inset-y-0 left-0 min-h-screen' : 'sticky top-0 min-h-screen'
}`}
>
<div className="flex items-center justify-between gap-2.5 px-1.5">
<div className="flex items-center gap-2.5">
<TripoliLogo size={28} />
<TripoliWordmark className="text-ink" />
</div>
{isMobile && (
<button
type="button"
onClick={closeDrawer}
aria-label="Fermer le menu"
className="flex h-[30px] w-[30px] items-center justify-center rounded-[9px] border border-line text-ink"
>
<X size={15} />
</button>
)}
</div>
<nav className="flex flex-col gap-[3px]">
{NAV_ITEMS.map(({ key, label, icon: Icon }) => {
const active = nav === key;
return (
<button
key={key}
type="button"
onClick={() => goTo(key)}
className={`flex items-center gap-2.5 rounded-[11px] px-3 py-2.5 text-left text-sm font-semibold transition-colors ${
active ? 'bg-selected text-ink' : 'bg-transparent text-ink-soft hover:bg-field'
}`}
>
<Icon size={17} strokeWidth={1.8} />
{label}
</button>
);
})}
</nav>
<div className="rounded-2xl border border-line bg-panel p-4">
<div className="flex justify-between gap-2 text-xs font-semibold">
<span className="text-ink">Stockage</span>
<span className="text-ink-soft">{summary?.label ?? '—'}</span>
</div>
<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">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[11px] bg-brand-gradient text-[12.5px] font-bold text-white">
{user?.initials ?? '??'}
</span>
<div className="min-w-0 flex-1">
<p className="m-0 truncate text-[13px] font-semibold text-ink">{user?.name ?? 'Invité'}</p>
<p className="m-0 text-[11.5px] text-ink-soft">{user?.role ?? ''}</p>
</div>
<button
type="button"
onClick={logout}
aria-label="Se déconnecter"
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-ink-soft"
>
<LogOut size={16} strokeWidth={1.8} />
</button>
</div>
</aside>
);
}

View file

@ -0,0 +1,64 @@
import { Bell } from 'lucide-react';
import { useNavigation } from '../../contexts/NavigationContext';
import { useNotifications } from '../../hooks/useNotifications';
import { DataIcon } from '../icons/DataIcon';
export function NotificationCenter() {
const { notifOpen, toggleNotif, closeNotif } = useNavigation();
const { notifications, unreadCount, markAllRead } = useNotifications();
return (
<div className="relative">
<button
type="button"
onClick={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"
>
<Bell size={17} strokeWidth={1.8} />
{unreadCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand-gradient px-1.5 text-[10.5px] font-bold text-white">
{unreadCount}
</span>
)}
</button>
{notifOpen && (
<div className="animate-tc-in absolute right-0 top-[48px] z-50 w-[348px] max-w-[84vw] overflow-hidden rounded-2xl border border-line bg-panel2 shadow-tc backdrop-blur-2xl">
<div className="flex items-center justify-between gap-3 border-b border-line px-4 py-3.5">
<span className="text-[13.5px] font-bold text-ink">Notifications</span>
<button type="button" onClick={markAllRead} className="text-[12.5px] font-semibold text-brand-soft">
Tout marquer lu
</button>
</div>
{notifications.map((n) => (
<div
key={n.id}
className={`flex gap-3 border-b border-line px-4 py-3.5 ${n.unread ? 'bg-selected/40' : ''}`}
>
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-selected text-brand-soft">
<DataIcon name={n.icon} />
</span>
<div className="min-w-0 flex-1">
<p className="m-0 mb-[3px] text-[13.5px] font-semibold leading-snug text-ink">{n.title}</p>
<p className="m-0 text-[12.5px] leading-relaxed text-ink-soft">{n.body}</p>
<p className="m-0 mt-1.5 text-[11.5px] text-ink-soft">{n.time}</p>
</div>
{n.unread && <span className="mt-1.5 h-[7px] w-[7px] flex-none rounded-full bg-brand" />}
</div>
))}
<button
type="button"
onClick={closeNotif}
className="w-full bg-transparent py-3.5 text-[12.5px] font-semibold text-ink-soft"
>
Fermer
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,13 @@
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>
);
}

View file

@ -0,0 +1,14 @@
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 }}
>
<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>
);
}

View file

@ -0,0 +1,50 @@
import { usePhotos } from '../../hooks/usePhotos';
import { AlbumCard } from './AlbumCard';
import { PhotoTile } from './PhotoTile';
export function PhotosView() {
const { albums, tiles, scopes, scope, setScope } = usePhotos();
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>
<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>
);
}

View file

@ -0,0 +1,39 @@
import type { NotificationPrefKey, SettingsRow } from '../../types';
interface SettingsRowViewProps {
row: SettingsRow;
onToggle: (key: NotificationPrefKey) => void;
}
export function SettingsRowView({ row, onToggle }: SettingsRowViewProps) {
return (
<div className="flex flex-wrap items-center gap-4 border-b border-line p-[18px]">
<div className="min-w-0 flex-1 basis-[260px]">
<p className="m-0 mb-1 text-sm font-semibold text-ink">{row.label}</p>
<p className="m-0 text-[12.5px] leading-relaxed text-ink-soft">{row.help}</p>
</div>
{row.kind === 'toggle' ? (
<button
type="button"
onClick={() => onToggle(row.id as NotificationPrefKey)}
role="switch"
aria-checked={row.on}
aria-label={row.label}
className={`flex h-[26px] w-[46px] flex-none items-center rounded-full border border-line p-[3px] transition-colors ${
row.on ? 'bg-brand-gradient' : 'bg-field'
}`}
>
<span
className="h-5 w-5 rounded-full bg-white shadow-[0_2px_6px_rgba(0,0,0,0.3)] transition-transform"
style={{ transform: row.on ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
) : (
<span className="flex-none rounded-[10px] border border-line bg-field px-3.5 py-2 text-[13px] font-semibold text-ink">
{row.value}
</span>
)}
</div>
);
}

View file

@ -0,0 +1,55 @@
import { useNavigation } from '../../contexts/NavigationContext';
import { SETTINGS_TABS, useSettings } from '../../hooks/useSettings';
import { SettingsRowView } from './SettingsRowView';
export function SettingsView() {
const { settingsTab, setSettingsTab } = useNavigation();
const { title, rows, togglePref } = useSettings(settingsTab);
return (
<div className="flex max-w-[860px] flex-col gap-6">
<div>
<p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">Réglages</p>
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">{title}</h1>
</div>
<div className="flex gap-1 overflow-x-auto rounded-[13px] border border-line bg-field p-1">
{SETTINGS_TABS.map((tab) => {
const active = settingsTab === tab.key;
return (
<button
key={tab.key}
type="button"
onClick={() => setSettingsTab(tab.key)}
className={`flex-none whitespace-nowrap rounded-[10px] px-4 py-2.5 text-[13px] font-semibold transition-colors ${
active ? 'bg-panel2 text-ink' : 'bg-transparent text-ink-soft'
}`}
>
{tab.label}
</button>
);
})}
</div>
<div className="overflow-hidden rounded-[18px] border border-line bg-panel">
{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>
);
}

View file

@ -0,0 +1,74 @@
import { createContext, useContext, useState, type ReactNode } from 'react';
import type { NavKey, SettingsTabKey } from '../types';
interface NavigationContextValue {
nav: NavKey;
goTo: (nav: NavKey) => void;
settingsTab: SettingsTabKey;
setSettingsTab: (tab: SettingsTabKey) => void;
drawerOpen: boolean;
openDrawer: () => void;
closeDrawer: () => void;
notifOpen: boolean;
toggleNotif: () => void;
closeNotif: () => void;
createOpen: boolean;
toggleCreate: () => void;
closeCreate: () => void;
}
const NavigationContext = createContext<NavigationContextValue | null>(null);
/**
* Navigation is a plain state switch, not a router — matches the desktop-first
* prototype (`state.nav`), which the brief explicitly allows as long as it's
* spelled out rather than silently missing routing.
*/
export function NavigationProvider({ children }: { children: ReactNode }) {
const [nav, setNav] = useState<NavKey>('dashboard');
const [settingsTab, setSettingsTab] = useState<SettingsTabKey>('language');
const [drawerOpen, setDrawerOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const goTo = (next: NavKey) => {
setNav(next);
setDrawerOpen(false);
setNotifOpen(false);
setCreateOpen(false);
};
return (
<NavigationContext.Provider
value={{
nav,
goTo,
settingsTab,
setSettingsTab,
drawerOpen,
openDrawer: () => setDrawerOpen(true),
closeDrawer: () => setDrawerOpen(false),
notifOpen,
toggleNotif: () => {
setNotifOpen((v) => !v);
setCreateOpen(false);
},
closeNotif: () => setNotifOpen(false),
createOpen,
toggleCreate: () => {
setCreateOpen((v) => !v);
setNotifOpen(false);
},
closeCreate: () => setCreateOpen(false),
}}
>
{children}
</NavigationContext.Provider>
);
}
export function useNavigation(): NavigationContextValue {
const ctx = useContext(NavigationContext);
if (!ctx) throw new Error('useNavigation must be used within a NavigationProvider');
return ctx;
}

View file

@ -0,0 +1,63 @@
import { createContext, useContext, useState, type ReactNode } from 'react';
import { login as apiLogin, loginAsGuest, signup as apiSignup, type Credentials, type SignupPayload } from '../api/auth';
import type { AppView, User } from '../types';
interface SessionContextValue {
view: AppView;
user: User | null;
loading: boolean;
login: (credentials: Credentials) => Promise<void>;
signup: (payload: SignupPayload) => Promise<void>;
continueAsGuest: () => Promise<void>;
logout: () => void;
}
const SessionContext = createContext<SessionContextValue | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const login = async (credentials: Credentials) => {
setLoading(true);
try {
setUser(await apiLogin(credentials));
} finally {
setLoading(false);
}
};
const signup = async (payload: SignupPayload) => {
setLoading(true);
try {
setUser(await apiSignup(payload));
} finally {
setLoading(false);
}
};
const continueAsGuest = async () => {
setLoading(true);
try {
setUser(await loginAsGuest());
} finally {
setLoading(false);
}
};
const logout = () => setUser(null);
return (
<SessionContext.Provider
value={{ view: user ? 'app' : 'auth', user, loading, login, signup, continueAsGuest, logout }}
>
{children}
</SessionContext.Provider>
);
}
export function useSession(): SessionContextValue {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error('useSession must be used within a SessionProvider');
return ctx;
}

View file

@ -0,0 +1,32 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import type { Theme } from '../types';
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
/**
* Dark/light is kept in React state only — intentionally not persisted to
* localStorage. It's applied as Tailwind's `dark` class on <html>, so
* `dark:` utilities and the CSS-variable tokens in index.css move together.
*/
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark');
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
return ctx;
}

View file

@ -0,0 +1,50 @@
import { useState, type FormEvent } from 'react';
import { useSession } from '../contexts/SessionContext';
import { isValidEmail, isValidPassword } from '../lib/validators';
import type { AuthMode } from '../types';
export function useAuthForm() {
const { login, signup, continueAsGuest, loading } = useSession();
const [mode, setModeState] = useState<AuthMode>('login');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitted, setSubmitted] = useState(false);
const setMode = (next: AuthMode) => {
setModeState(next);
setSubmitted(false);
};
const emailError = submitted && !isValidEmail(email);
const passwordError = submitted && !isValidPassword(password);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!isValidEmail(email) || !isValidPassword(password)) {
setSubmitted(true);
return;
}
if (mode === 'signup') {
void signup({ name, email, password });
} else {
void login({ email, password });
}
};
return {
mode,
setMode,
name,
setName,
email,
setEmail,
password,
setPassword,
emailError,
passwordError,
handleSubmit,
continueAsGuest,
loading,
};
}

View file

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
import { BACKGROUND_IMAGES } from '../lib/backgrounds';
const INTERVAL_MS = 5400;
export function useBackgroundCrossfade(): number {
const [index, setIndex] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setIndex((i) => (i + 1) % BACKGROUND_IMAGES.length);
}, INTERVAL_MS);
return () => window.clearInterval(id);
}, []);
return index;
}

View file

@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
import { getBackups } from '../api/backups';
import type { BackupDevice } from '../types';
export function useBackups() {
const [backups, setBackups] = useState<BackupDevice[]>([]);
useEffect(() => {
void getBackups().then(setBackups);
}, []);
return backups;
}

View file

@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
import { getDashboardStats } from '../api/files';
import type { StatItem } from '../types';
export function useDashboardStats() {
const [stats, setStats] = useState<StatItem[]>([]);
useEffect(() => {
void getDashboardStats().then(setStats);
}, []);
return stats;
}

View file

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
/** Mirrors Tailwind's `md` breakpoint (768px) — the sidebar becomes a drawer below it. */
const MOBILE_QUERY = '(max-width: 767px)';
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches);
useEffect(() => {
const mql = window.matchMedia(MOBILE_QUERY);
const onChange = () => setIsMobile(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return isMobile;
}

View file

@ -0,0 +1,20 @@
import { useEffect, useMemo, useState } from 'react';
import { getNotifications } from '../api/notifications';
import type { NotificationItem } from '../types';
export function useNotifications() {
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [allRead, setAllRead] = useState(false);
useEffect(() => {
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;
return { notifications: items, unreadCount, markAllRead: () => setAllRead(true) };
}

View file

@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
import { getAlbums, getPhotoTiles } from '../api/photos';
import type { Album, PhotoScope, PhotoTile } from '../types';
const SCOPES: PhotoScope[] = ['Tout', 'Personnes', 'Lieux'];
export function usePhotos() {
const [albums, setAlbums] = useState<Album[]>([]);
const [tiles, setTiles] = useState<PhotoTile[]>([]);
const [scope, setScope] = useState<PhotoScope>('Tout');
useEffect(() => {
void getAlbums().then(setAlbums);
void getPhotoTiles().then(setTiles);
}, []);
return { albums, tiles, scopes: SCOPES, scope, setScope };
}

View file

@ -0,0 +1,45 @@
import { useEffect, useMemo, useState } from 'react';
import { getFiles } from '../api/files';
import type { FileFilter, FileItem } from '../types';
const FILTERS: FileFilter[] = ['Tous', 'Dossiers', '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';
case 'Médias':
return file.kind === 'video' || file.kind === 'image';
}
}
export function useRecentFiles() {
const [files, setFiles] = useState<FileItem[]>([]);
const [filter, setFilter] = useState<FileFilter>('Tous');
const [sortDesc, setSortDesc] = useState(true);
const [gridView, setGridView] = useState(true);
useEffect(() => {
void getFiles().then(setFiles);
}, []);
const visibleFiles = useMemo(() => {
const filtered = files.filter((f) => matchesFilter(f, filter));
return sortDesc ? filtered : filtered.slice().reverse();
}, [files, filter, sortDesc]);
return {
files: visibleFiles,
filters: FILTERS,
filter,
setFilter,
sortDesc,
toggleSort: () => setSortDesc((v) => !v),
gridView,
setGridView,
};
}

View file

@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import {
getDefaultNotificationPrefs,
getStaticSettingsRows,
NOTIFICATION_PREF_LABELS,
type StaticSettingsRows,
} from '../api/settings';
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' },
];
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 togglePref = (key: NotificationPrefKey) => {
setPrefs((prev) => (prev ? { ...prev, [key]: !prev[key] } : prev));
};
let rows: SettingsRow[] = [];
if (activeTab === 'language') {
rows = staticRows?.language ?? [];
} else if (activeTab === 'account') {
rows = staticRows?.account ?? [];
} else if (prefs) {
rows = (Object.keys(NOTIFICATION_PREF_LABELS) as NotificationPrefKey[]).map((key) => ({
kind: 'toggle',
id: key,
label: NOTIFICATION_PREF_LABELS[key].label,
help: NOTIFICATION_PREF_LABELS[key].help,
on: prefs[key],
}));
}
return { title: TAB_TITLES[activeTab], rows, togglePref };
}

View file

@ -0,0 +1,10 @@
import { useEffect, useState } from 'react';
import { getStorageSummary, type StorageSummary } from '../api/vault';
export function useStorageSummary() {
const [summary, setSummary] = useState<StorageSummary | null>(null);
useEffect(() => {
void getStorageSummary().then(setSummary);
}, []);
return summary;
}

View file

@ -0,0 +1,44 @@
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 };
}

108
app/src/index.css Normal file
View file

@ -0,0 +1,108 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Theme tokens consumed by tailwind.config.js (colors.bg, colors.panel, ...).
* `.dark` is toggled on <html> by ThemeContext — this is what makes
* Tailwind's `dark:` strategy and these variables move together.
*/
:root {
--tc-bg: #f4f6fb;
--tc-panel: rgba(255, 255, 255, 0.72);
--tc-panel-2: rgba(255, 255, 255, 0.9);
--tc-line: rgba(16, 24, 40, 0.1);
--tc-field: rgba(16, 24, 40, 0.04);
--tc-selected: rgba(91, 140, 255, 0.12);
--tc-ink: #101828;
--tc-ink-2: rgba(16, 24, 40, 0.58);
--tc-shadow: 0 24px 54px rgba(16, 24, 40, 0.14);
}
:root.dark {
--tc-bg: #0a0c11;
--tc-panel: rgba(255, 255, 255, 0.045);
--tc-panel-2: rgba(255, 255, 255, 0.075);
--tc-line: rgba(255, 255, 255, 0.09);
--tc-field: rgba(255, 255, 255, 0.05);
--tc-selected: rgba(91, 140, 255, 0.16);
--tc-ink: #eef1f7;
--tc-ink-2: rgba(238, 241, 247, 0.56);
--tc-shadow: 0 28px 60px rgba(0, 0, 0, 0.5);
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--tc-bg);
font-family: Manrope, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
input::placeholder {
color: var(--tc-ink-2);
opacity: 0.65;
}
::selection {
background: rgba(91, 140, 255, 0.32);
}
@keyframes tc-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes tc-stripe {
from {
background-position: 0 0;
}
to {
background-position: 28px 0;
}
}
@keyframes tc-pulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 1;
}
}
.animate-tc-in {
animation: tc-in 0.16s ease;
}
.animate-tc-pulse {
animation: tc-pulse 2.4s ease-in-out infinite;
}
.upload-progress-stripes {
background-image: linear-gradient(
115deg,
rgba(255, 255, 255, 0.26) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.26) 50%,
rgba(255, 255, 255, 0.26) 75%,
transparent 75%
);
background-size: 28px 28px;
animation: tc-stripe 0.8s linear infinite;
}

View file

@ -0,0 +1,10 @@
/**
* Auth-screen background layers. Replace each entry with
* `url('/assets/bg-1.jpg') center/cover no-repeat` for real photography —
* the crossfade logic in useBackgroundCrossfade doesn't change.
*/
export const BACKGROUND_IMAGES: string[] = [
'radial-gradient(120% 95% at 22% 12%, #2b3340 0%, #121620 55%, #070910 100%)',
'radial-gradient(120% 95% at 78% 18%, #2f2b3f 0%, #16131f 55%, #08070d 100%)',
'radial-gradient(130% 100% at 45% 85%, #1f3340 0%, #0f161c 55%, #070a0d 100%)',
];

15
app/src/lib/gradients.ts Normal file
View file

@ -0,0 +1,15 @@
/** Shared placeholder-thumbnail gradients — swap for real thumbnails once assets exist. */
export function tone(a: string, b: string): string {
return `linear-gradient(140deg, ${a} 0%, ${b} 100%)`;
}
export const THUMB_TONES = [
tone('#3b4a63', '#1b2230'),
tone('#4a3f5f', '#221c2e'),
tone('#5a4436', '#261d18'),
tone('#33505a', '#182428'),
tone('#46384f', '#1f1a24'),
tone('#3d4a41', '#1b211d'),
tone('#3a4560', '#1a1f2c'),
tone('#51413a', '#241d19'),
] as const;

View file

@ -0,0 +1,9 @@
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[a-z]{2,}$/i;
export function isValidEmail(value: string): boolean {
return EMAIL_RE.test(value);
}
export function isValidPassword(value: string): boolean {
return value.length >= 8;
}

10
app/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

113
app/src/types.ts Normal file
View file

@ -0,0 +1,113 @@
/** Authenticated user, or the ephemeral guest identity used by "Visiter en tant qu'invité". */
export interface User {
id: string;
name: string;
initials: string;
email: string;
role: string;
isGuest: boolean;
}
/** Keys into src/components/icons/iconRegistry.ts (lucide-react components). */
export type IconName =
| 'hard-drive'
| 'image'
| 'smartphone'
| 'share'
| 'folder-plus'
| 'file-pen'
| 'upload'
| 'check'
| 'alert'
| 'laptop'
| 'tablet'
| 'server';
export type FileKind = 'folder' | 'document' | 'archive' | 'video' | 'image' | 'backup';
export interface FileItem {
id: string;
name: string;
kind: FileKind;
extLabel: string;
size: string;
modified: string;
thumbGradient: string;
}
export interface UploadTask {
fileName: string;
/** 0-100. When undefined, the zone is idle (no transfer in progress). */
progress: number | undefined;
}
export interface NotificationItem {
id: string;
icon: IconName;
title: string;
body: string;
time: string;
unread: boolean;
}
export interface Album {
id: string;
name: string;
count: string;
thumbGradient: string;
}
export interface PhotoTile {
id: string;
label: string;
thumbGradient: string;
}
export type BackupStatus = 'up-to-date' | 'syncing' | 'late';
export interface BackupDevice {
id: string;
device: string;
detail: string;
status: BackupStatus;
statusLabel: string;
icon: IconName;
}
export interface StatItem {
id: string;
label: string;
value: string;
note: string;
icon: IconName;
tone: 'brand' | 'success' | 'violet';
}
export type SettingsTabKey = 'language' | 'notifications' | 'account';
export interface SettingsValueRow {
kind: 'value';
id: string;
label: string;
help: string;
value: string;
}
export interface SettingsToggleRow {
kind: 'toggle';
id: string;
label: string;
help: string;
on: boolean;
}
export type SettingsRow = SettingsValueRow | SettingsToggleRow;
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 Theme = 'light' | 'dark';

9
app/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

59
app/tailwind.config.js Normal file
View file

@ -0,0 +1,59 @@
/**
* Design tokens for Tripoli Cloud, centralized here so components never
* hardcode colors, radii or spacing inline. Values mirror the validated
* Claude Design prototype (`project/Tripoli Cloud.dc.html`).
*
* Surface/text colors resolve through CSS variables (see src/index.css) so
* the same class (e.g. `bg-panel`) renders correctly in both themes without
* a `dark:` variant on every element. Theme switching itself still goes
* through Tailwind's `dark` class strategy on <html>.
* @type {import('tailwindcss').Config}
*/
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['Manrope', 'system-ui', 'sans-serif'],
},
colors: {
brand: {
from: '#5b8cff',
to: '#7c5cff',
DEFAULT: '#5b8cff',
soft: '#7ba4ff',
},
success: '#4ade80',
danger: '#ff8f7a',
violet: '#b39dff',
bg: 'var(--tc-bg)',
panel: 'var(--tc-panel)',
panel2: 'var(--tc-panel-2)',
line: 'var(--tc-line)',
field: 'var(--tc-field)',
selected: 'var(--tc-selected)',
ink: {
DEFAULT: 'var(--tc-ink)',
soft: 'var(--tc-ink-2)',
},
},
backgroundImage: {
'brand-gradient': 'linear-gradient(135deg, #5b8cff 0%, #7c5cff 100%)',
},
borderRadius: {
card: '16px',
panel: '18px',
modal: '14px',
},
boxShadow: {
tc: 'var(--tc-shadow)',
},
screens: {
// Explicit alias for the prototype's documented breakpoint.
mobile: { max: '767px' },
},
},
},
plugins: [],
}

26
app/tsconfig.app.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
app/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
app/tsconfig.node.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

10
app/vite.config.ts Normal file
View file

@ -0,0 +1,10 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
// Relative asset paths: the built app is served as static files from
// whatever domain/path the reverse proxy mounts it under, never localhost.
base: './',
plugins: [react()],
})