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:
commit
5dbd3b96f6
73 changed files with 4789 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
52
HANDOFF.md
Normal 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
1
app/.env
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL=
|
||||
3
app/.env.example
Normal file
3
app/.env.example
Normal 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
24
app/.gitignore
vendored
Normal 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
8
app/.oxlintrc.json
Normal 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
32
app/README.md
Normal 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
13
app/index.html
Normal 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
2383
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
app/package.json
Normal file
29
app/package.json
Normal 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
6
app/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
app/public/favicon.svg
Normal file
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
24
app/public/icons.svg
Normal 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
49
app/src/App.tsx
Normal 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
53
app/src/api/auth.ts
Normal 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
19
app/src/api/backups.ts
Normal 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
13
app/src/api/client.ts
Normal 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
33
app/src/api/files.ts
Normal 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' },
|
||||
]);
|
||||
}
|
||||
13
app/src/api/notifications.ts
Normal file
13
app/src/api/notifications.ts
Normal 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
28
app/src/api/photos.ts
Normal 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
36
app/src/api/settings.ts
Normal 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
11
app/src/api/vault.ts
Normal 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' });
|
||||
}
|
||||
20
app/src/components/auth/AuthBackground.tsx
Normal file
20
app/src/components/auth/AuthBackground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
app/src/components/auth/AuthLayout.tsx
Normal file
58
app/src/components/auth/AuthLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
app/src/components/auth/AuthPanel.tsx
Normal file
163
app/src/components/auth/AuthPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/src/components/backups/BackupRow.tsx
Normal file
31
app/src/components/backups/BackupRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/src/components/backups/BackupsView.tsx
Normal file
20
app/src/components/backups/BackupsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
app/src/components/dashboard/CreateMenu.tsx
Normal file
46
app/src/components/dashboard/CreateMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
app/src/components/dashboard/DashboardView.tsx
Normal file
38
app/src/components/dashboard/DashboardView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/src/components/dashboard/FileGridItem.tsx
Normal file
19
app/src/components/dashboard/FileGridItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
app/src/components/dashboard/FileListRow.tsx
Normal file
15
app/src/components/dashboard/FileListRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
app/src/components/dashboard/RecentFiles.tsx
Normal file
84
app/src/components/dashboard/RecentFiles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
app/src/components/dashboard/StatCard.tsx
Normal file
23
app/src/components/dashboard/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
app/src/components/dashboard/UploadZone.tsx
Normal file
60
app/src/components/dashboard/UploadZone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
app/src/components/icons/DataIcon.tsx
Normal file
14
app/src/components/icons/DataIcon.tsx
Normal 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} />;
|
||||
}
|
||||
31
app/src/components/icons/iconRegistry.ts
Normal file
31
app/src/components/icons/iconRegistry.ts
Normal 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,
|
||||
};
|
||||
23
app/src/components/layout/AppShell.tsx
Normal file
23
app/src/components/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/src/components/layout/DrawerOverlay.tsx
Normal file
17
app/src/components/layout/DrawerOverlay.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
47
app/src/components/layout/TopBar.tsx
Normal file
47
app/src/components/layout/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
app/src/components/layout/TripoliLogo.tsx
Normal file
32
app/src/components/layout/TripoliLogo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
app/src/components/layout/TripoliSidebar.tsx
Normal file
96
app/src/components/layout/TripoliSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
app/src/components/notifications/NotificationCenter.tsx
Normal file
64
app/src/components/notifications/NotificationCenter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
app/src/components/photos/AlbumCard.tsx
Normal file
13
app/src/components/photos/AlbumCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
app/src/components/photos/PhotoTile.tsx
Normal file
14
app/src/components/photos/PhotoTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
app/src/components/photos/PhotosView.tsx
Normal file
50
app/src/components/photos/PhotosView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
app/src/components/settings/SettingsRowView.tsx
Normal file
39
app/src/components/settings/SettingsRowView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
app/src/components/settings/SettingsView.tsx
Normal file
55
app/src/components/settings/SettingsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
app/src/contexts/NavigationContext.tsx
Normal file
74
app/src/contexts/NavigationContext.tsx
Normal 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;
|
||||
}
|
||||
63
app/src/contexts/SessionContext.tsx
Normal file
63
app/src/contexts/SessionContext.tsx
Normal 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;
|
||||
}
|
||||
32
app/src/contexts/ThemeContext.tsx
Normal file
32
app/src/contexts/ThemeContext.tsx
Normal 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;
|
||||
}
|
||||
50
app/src/hooks/useAuthForm.ts
Normal file
50
app/src/hooks/useAuthForm.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
17
app/src/hooks/useBackgroundCrossfade.ts
Normal file
17
app/src/hooks/useBackgroundCrossfade.ts
Normal 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;
|
||||
}
|
||||
11
app/src/hooks/useBackups.ts
Normal file
11
app/src/hooks/useBackups.ts
Normal 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;
|
||||
}
|
||||
11
app/src/hooks/useDashboardStats.ts
Normal file
11
app/src/hooks/useDashboardStats.ts
Normal 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;
|
||||
}
|
||||
17
app/src/hooks/useIsMobile.ts
Normal file
17
app/src/hooks/useIsMobile.ts
Normal 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;
|
||||
}
|
||||
20
app/src/hooks/useNotifications.ts
Normal file
20
app/src/hooks/useNotifications.ts
Normal 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) };
|
||||
}
|
||||
18
app/src/hooks/usePhotos.ts
Normal file
18
app/src/hooks/usePhotos.ts
Normal 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 };
|
||||
}
|
||||
45
app/src/hooks/useRecentFiles.ts
Normal file
45
app/src/hooks/useRecentFiles.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
51
app/src/hooks/useSettings.ts
Normal file
51
app/src/hooks/useSettings.ts
Normal 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 };
|
||||
}
|
||||
10
app/src/hooks/useStorageSummary.ts
Normal file
10
app/src/hooks/useStorageSummary.ts
Normal 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;
|
||||
}
|
||||
44
app/src/hooks/useUploadSimulation.ts
Normal file
44
app/src/hooks/useUploadSimulation.ts
Normal 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
108
app/src/index.css
Normal 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;
|
||||
}
|
||||
10
app/src/lib/backgrounds.ts
Normal file
10
app/src/lib/backgrounds.ts
Normal 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
15
app/src/lib/gradients.ts
Normal 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;
|
||||
9
app/src/lib/validators.ts
Normal file
9
app/src/lib/validators.ts
Normal 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
10
app/src/main.tsx
Normal 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
113
app/src/types.ts
Normal 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
9
app/src/vite-env.d.ts
vendored
Normal 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
59
app/tailwind.config.js
Normal 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
26
app/tsconfig.app.json
Normal 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
7
app/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
app/tsconfig.node.json
Normal file
23
app/tsconfig.node.json
Normal 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
10
app/vite.config.ts
Normal 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()],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue