add desktop
4
apps/frontend/.env.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Backend API base URL (no trailing slash, no /api suffix)
|
||||||
|
# Dev local: http://localhost:3000
|
||||||
|
# Prod VPS: https://api.ti-pote.example.com
|
||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
39
apps/frontend/.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
build/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Tauri (Rust build artifacts)
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/Cargo.lock
|
||||||
|
src-tauri/gen/schemas/
|
||||||
|
src-tauri/WixTools/
|
||||||
68
apps/frontend/README.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# @ti-pote/frontend
|
||||||
|
|
||||||
|
Desktop companion app for Ti-Pote. **Vite + React + TypeScript + Tailwind**,
|
||||||
|
wrappable as a native desktop app with **Tauri v2**.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Auth: register / login / auto refresh-token rotation
|
||||||
|
- Session persistence (Tauri Store plugin on desktop, localStorage in browser)
|
||||||
|
- Dashboard: list of associated robots (`GET /api/devices`)
|
||||||
|
- Robot pairing: 6-digit code screen wired to `POST /api/pairing/confirm`
|
||||||
|
|
||||||
|
## Quick start (web dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/frontend
|
||||||
|
cp .env.example .env # point VITE_API_URL to your backend
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # http://localhost:1420
|
||||||
|
```
|
||||||
|
|
||||||
|
## Desktop build (Tauri v2)
|
||||||
|
|
||||||
|
Prerequisites: Rust toolchain (`rustup`) and the
|
||||||
|
[Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First time only: generate the bundle icons from any source PNG
|
||||||
|
pnpm tauri icon path/to/logo.png
|
||||||
|
|
||||||
|
# Dev (hot reload + native window)
|
||||||
|
pnpm tauri dev
|
||||||
|
|
||||||
|
# Production bundle (.dmg / .app on macOS, .msi on Windows, .deb/.AppImage on Linux)
|
||||||
|
pnpm tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ Button / Input / Card / ProtectedRoute
|
||||||
|
│ ├── context/ AuthContext (React)
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── api.ts fetch wrapper + typed endpoints + auto-refresh
|
||||||
|
│ │ └── storage.ts Tauri Store ↔ localStorage fallback
|
||||||
|
│ ├── pages/ Login / Register / Dashboard / PairRobot
|
||||||
|
│ ├── styles/ Tailwind entry
|
||||||
|
│ ├── App.tsx Router
|
||||||
|
│ └── main.tsx Entry point
|
||||||
|
└── src-tauri/ Tauri v2 Rust wrapper
|
||||||
|
├── Cargo.toml
|
||||||
|
├── tauri.conf.json
|
||||||
|
├── capabilities/
|
||||||
|
└── src/{main.rs,lib.rs}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend API used
|
||||||
|
|
||||||
|
| Flow | Endpoint | Notes |
|
||||||
|
| -------- | ---------------------------- | ---------------------------------- |
|
||||||
|
| Register | `POST /api/auth/register` | Creates user + home |
|
||||||
|
| Login | `POST /api/auth/login` | |
|
||||||
|
| Refresh | `POST /api/auth/refresh` | Called transparently on 401 |
|
||||||
|
| Me | `GET /api/auth/me` | |
|
||||||
|
| Devices | `GET /api/devices` | Dashboard |
|
||||||
|
| Pair | `POST /api/pairing/confirm` | `{ code }` — 6-digit from robot UI |
|
||||||
12
apps/frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ti-Pote</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100 antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
apps/frontend/package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@ti-pote/frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src --ext ts,tsx --max-warnings 0",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-store": "^2.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
|
"zustand": "^4.5.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.41",
|
||||||
|
"tailwindcss": "^3.4.10",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
6
apps/frontend/src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
gen/schemas/
|
||||||
25
apps/frontend/src-tauri/Cargo.toml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "ti-pote-desktop"
|
||||||
|
version = "0.0.1"
|
||||||
|
description = "Ti-Pote desktop companion app"
|
||||||
|
authors = ["Arthur"]
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ti_pote_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-store = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# This feature is used for production builds or when a dev server is not specified,
|
||||||
|
# DO NOT REMOVE!!
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
3
apps/frontend/src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
10
apps/frontend/src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default permissions for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"store:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
apps/frontend/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
apps/frontend/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/frontend/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/frontend/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
apps/frontend/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
apps/frontend/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
apps/frontend/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
apps/frontend/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/frontend/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/frontend/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/frontend/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
apps/frontend/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
apps/frontend/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
apps/frontend/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
BIN
apps/frontend/src-tauri/icons/icon.icns
Normal file
BIN
apps/frontend/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/frontend/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 816 B |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
apps/frontend/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
7
apps/frontend/src-tauri/src/lib.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
6
apps/frontend/src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
ti_pote_desktop_lib::run()
|
||||||
|
}
|
||||||
40
apps/frontend/src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Ti-Pote",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"identifier": "com.tipote.desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "pnpm build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Ti-Pote",
|
||||||
|
"width": 1100,
|
||||||
|
"height": 760,
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": {}
|
||||||
|
}
|
||||||
52
apps/frontend/src/App.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
import { useAuth } from './context/AuthContext';
|
||||||
|
import { DashboardPage } from './pages/DashboardPage';
|
||||||
|
import { LoginPage } from './pages/LoginPage';
|
||||||
|
import { PairRobotPage } from './pages/PairRobotPage';
|
||||||
|
import { RegisterPage } from './pages/RegisterPage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router.
|
||||||
|
*
|
||||||
|
* - Public routes (login/register) auto-redirect to "/" when the user
|
||||||
|
* is already authenticated — avoids the annoying loop where a logged-in
|
||||||
|
* user clicks back and lands on a login form.
|
||||||
|
* - Protected routes are gated by <ProtectedRoute>.
|
||||||
|
*/
|
||||||
|
export function App() {
|
||||||
|
const { status } = useAuth();
|
||||||
|
const authed = status === 'authenticated';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={authed ? <Navigate to="/" replace /> : <LoginPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/register"
|
||||||
|
element={authed ? <Navigate to="/" replace /> : <RegisterPage />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/pair"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PairRobotPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/frontend/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate a subtree behind an authenticated user.
|
||||||
|
*
|
||||||
|
* - While the auth state is still bootstrapping (refreshing the session
|
||||||
|
* from storage), render a neutral splash.
|
||||||
|
* - If the user is unauthenticated, redirect to /login while keeping
|
||||||
|
* the original destination in location state so we can bounce back
|
||||||
|
* after login.
|
||||||
|
*/
|
||||||
|
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||||
|
const { status } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-400 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated') {
|
||||||
|
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
148
apps/frontend/src/components/ui.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
// ─── Button ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: Variant;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||||
|
primary:
|
||||||
|
'bg-brand-500 hover:bg-brand-400 active:bg-brand-600 text-white shadow-lg shadow-brand-500/30',
|
||||||
|
secondary: 'bg-slate-800 hover:bg-slate-700 text-slate-100 border border-slate-700',
|
||||||
|
ghost: 'bg-transparent hover:bg-slate-800/60 text-slate-300',
|
||||||
|
danger: 'bg-red-600 hover:bg-red-500 text-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
loading = false,
|
||||||
|
disabled,
|
||||||
|
className = '',
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...rest}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5',
|
||||||
|
'text-sm font-medium transition-all duration-150',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-brand-400/50 focus:ring-offset-2 focus:ring-offset-slate-950',
|
||||||
|
VARIANT_CLASSES[variant],
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 animate-spin"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
className="opacity-25"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
className="opacity-75"
|
||||||
|
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ label, error, className = '', id, ...rest }: InputProps) {
|
||||||
|
const inputId = id || rest.name;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="text-xs font-medium text-slate-400">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
{...rest}
|
||||||
|
className={[
|
||||||
|
'rounded-xl border bg-slate-900/60 px-3.5 py-2.5 text-sm text-slate-100',
|
||||||
|
'placeholder:text-slate-500',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-brand-400/40',
|
||||||
|
'transition-colors',
|
||||||
|
error
|
||||||
|
? 'border-red-500/60 focus:border-red-400'
|
||||||
|
: 'border-slate-700 focus:border-brand-400/60',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-2xl border border-slate-800 bg-slate-900/50 backdrop-blur-xl',
|
||||||
|
'shadow-2xl shadow-black/40',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── StatusBadge ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: 'online' | 'offline' | 'updating' }) {
|
||||||
|
const styles = {
|
||||||
|
online: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30',
|
||||||
|
offline: 'bg-slate-500/10 text-slate-400 border-slate-500/30',
|
||||||
|
updating: 'bg-amber-500/10 text-amber-400 border-amber-500/30',
|
||||||
|
}[status];
|
||||||
|
|
||||||
|
const dot = {
|
||||||
|
online: 'bg-emerald-400 shadow-emerald-400/60',
|
||||||
|
offline: 'bg-slate-500',
|
||||||
|
updating: 'bg-amber-400 shadow-amber-400/60 animate-pulse',
|
||||||
|
}[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${styles}`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full shadow-[0_0_8px] ${dot}`} />
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
hasStoredSession,
|
||||||
|
type LoginInput,
|
||||||
|
type Me,
|
||||||
|
type RegisterInput,
|
||||||
|
} from '../lib/api';
|
||||||
|
|
||||||
|
type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
status: AuthStatus;
|
||||||
|
user: Me | null;
|
||||||
|
login: (input: LoginInput) => Promise<void>;
|
||||||
|
register: (input: RegisterInput) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshMe: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<AuthStatus>('loading');
|
||||||
|
const [user, setUser] = useState<Me | null>(null);
|
||||||
|
|
||||||
|
// Bootstrap: if we have a refresh token on disk, try /auth/me
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (!(await hasStoredSession())) {
|
||||||
|
if (!cancelled) setStatus('unauthenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const me = await api.me();
|
||||||
|
if (cancelled) return;
|
||||||
|
setUser(me);
|
||||||
|
setStatus('authenticated');
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
|
setUser(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (input: LoginInput) => {
|
||||||
|
await api.login(input);
|
||||||
|
const me = await api.me();
|
||||||
|
setUser(me);
|
||||||
|
setStatus('authenticated');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const register = useCallback(async (input: RegisterInput) => {
|
||||||
|
await api.register(input);
|
||||||
|
const me = await api.me();
|
||||||
|
setUser(me);
|
||||||
|
setStatus('authenticated');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await api.logout();
|
||||||
|
setUser(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshMe = useCallback(async () => {
|
||||||
|
const me = await api.me();
|
||||||
|
setUser(me);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({ status, user, login, register, logout, refreshMe }),
|
||||||
|
[status, user, login, register, logout, refreshMe],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
233
apps/frontend/src/lib/api.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Thin typed wrapper around fetch with:
|
||||||
|
* - base URL from VITE_API_URL (default http://localhost:3000)
|
||||||
|
* - automatic `Authorization: Bearer <access>` injection
|
||||||
|
* - transparent refresh-token rotation on 401
|
||||||
|
* - typed error class
|
||||||
|
*
|
||||||
|
* Token persistence is delegated to `lib/storage.ts` (Tauri store in
|
||||||
|
* desktop builds, localStorage in pure browser dev).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { storage } from './storage';
|
||||||
|
|
||||||
|
const BASE_URL =
|
||||||
|
(import.meta.env.VITE_API_URL as string | undefined)?.replace(/\/$/, '') ||
|
||||||
|
'http://localhost:3000';
|
||||||
|
|
||||||
|
const API_PREFIX = '/api';
|
||||||
|
|
||||||
|
const ACCESS_KEY = 'auth.accessToken';
|
||||||
|
const REFRESH_KEY = 'auth.refreshToken';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Tokens {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Me {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
homeId: string;
|
||||||
|
type: 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterInput {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
displayName: string;
|
||||||
|
homeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginInput {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceSummary {
|
||||||
|
id: string;
|
||||||
|
homeId: string;
|
||||||
|
name: string;
|
||||||
|
status: 'online' | 'offline' | 'updating';
|
||||||
|
firmwareVersion: string | null;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingConfirmResult {
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Error ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
public readonly body?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getAccessToken(): Promise<string | null> {
|
||||||
|
return storage.get<string>(ACCESS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRefreshToken(): Promise<string | null> {
|
||||||
|
return storage.get<string>(REFRESH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTokens(tokens: Tokens): Promise<void> {
|
||||||
|
await storage.set(ACCESS_KEY, tokens.accessToken);
|
||||||
|
await storage.set(REFRESH_KEY, tokens.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearTokens(): Promise<void> {
|
||||||
|
await storage.delete(ACCESS_KEY);
|
||||||
|
await storage.delete(REFRESH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasStoredSession(): Promise<boolean> {
|
||||||
|
const [a, r] = await Promise.all([getAccessToken(), getRefreshToken()]);
|
||||||
|
return Boolean(a && r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core fetch with refresh-on-401 ─────────────────────────────────
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
body?: unknown;
|
||||||
|
auth?: boolean; // default true
|
||||||
|
// Internal: prevents infinite refresh loops
|
||||||
|
_retried?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||||
|
const { method = 'GET', body, auth = true, _retried = false } = opts;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (auth) {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}${API_PREFIX}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attempt transparent refresh on 401 (once)
|
||||||
|
if (res.status === 401 && auth && !_retried) {
|
||||||
|
const refreshed = await tryRefresh();
|
||||||
|
if (refreshed) {
|
||||||
|
return request<T>(path, { ...opts, _retried: true });
|
||||||
|
}
|
||||||
|
await clearTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isJson = res.headers.get('content-type')?.includes('application/json');
|
||||||
|
const payload: unknown = isJson ? await res.json().catch(() => null) : null;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
(payload &&
|
||||||
|
typeof payload === 'object' &&
|
||||||
|
'message' in payload &&
|
||||||
|
String((payload as { message: unknown }).message)) ||
|
||||||
|
res.statusText ||
|
||||||
|
`HTTP ${res.status}`;
|
||||||
|
throw new ApiError(res.status, message, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshInFlight: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
async function tryRefresh(): Promise<boolean> {
|
||||||
|
if (refreshInFlight) return refreshInFlight;
|
||||||
|
|
||||||
|
refreshInFlight = (async () => {
|
||||||
|
const refreshToken = await getRefreshToken();
|
||||||
|
if (!refreshToken) return false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}${API_PREFIX}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const tokens = (await res.json()) as Tokens;
|
||||||
|
await saveTokens(tokens);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
// Release slot after microtask so concurrent callers can await
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshInFlight = null;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Typed endpoint wrappers ────────────────────────────────────────
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Auth
|
||||||
|
async register(input: RegisterInput): Promise<Tokens> {
|
||||||
|
const tokens = await request<Tokens>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: input,
|
||||||
|
auth: false,
|
||||||
|
});
|
||||||
|
await saveTokens(tokens);
|
||||||
|
return tokens;
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(input: LoginInput): Promise<Tokens> {
|
||||||
|
const tokens = await request<Tokens>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: input,
|
||||||
|
auth: false,
|
||||||
|
});
|
||||||
|
await saveTokens(tokens);
|
||||||
|
return tokens;
|
||||||
|
},
|
||||||
|
|
||||||
|
async me(): Promise<Me> {
|
||||||
|
return request<Me>('/auth/me');
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
await clearTokens();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
async listDevices(): Promise<DeviceSummary[]> {
|
||||||
|
return request<DeviceSummary[]>('/devices');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pairing
|
||||||
|
async confirmPairing(code: string): Promise<PairingConfirmResult> {
|
||||||
|
return request<PairingConfirmResult>('/pairing/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { code },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
100
apps/frontend/src/lib/storage.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Persistent key-value storage.
|
||||||
|
*
|
||||||
|
* In a Tauri desktop build we prefer the `@tauri-apps/plugin-store` plugin
|
||||||
|
* (encrypted, per-app location). In a plain browser dev build we fall back
|
||||||
|
* to `localStorage`. The same async API is exposed in both cases so callers
|
||||||
|
* never care which backend they are talking to.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type StorageBackend = {
|
||||||
|
get<T = unknown>(key: string): Promise<T | null>;
|
||||||
|
set(key: string, value: unknown): Promise<void>;
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
clear(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let backendPromise: Promise<StorageBackend> | null = null;
|
||||||
|
|
||||||
|
function isTauri(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
// Tauri v2 sets these globals on window at runtime
|
||||||
|
('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBackend(): Promise<StorageBackend> {
|
||||||
|
if (isTauri()) {
|
||||||
|
try {
|
||||||
|
const { Store } = await import('@tauri-apps/plugin-store');
|
||||||
|
const store = await Store.load('ti-pote.json');
|
||||||
|
return {
|
||||||
|
async get<T>(key: string) {
|
||||||
|
const value = await store.get<T>(key);
|
||||||
|
return value ?? null;
|
||||||
|
},
|
||||||
|
async set(key, value) {
|
||||||
|
await store.set(key, value);
|
||||||
|
await store.save();
|
||||||
|
},
|
||||||
|
async delete(key) {
|
||||||
|
await store.delete(key);
|
||||||
|
await store.save();
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
await store.clear();
|
||||||
|
await store.save();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[storage] Tauri store unavailable, falling back to localStorage', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser fallback
|
||||||
|
return {
|
||||||
|
async get<T>(key: string) {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (raw == null) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async set(key, value) {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
},
|
||||||
|
async delete(key) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
localStorage.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackend(): Promise<StorageBackend> {
|
||||||
|
if (!backendPromise) backendPromise = createBackend();
|
||||||
|
return backendPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storage = {
|
||||||
|
async get<T = unknown>(key: string): Promise<T | null> {
|
||||||
|
const b = await getBackend();
|
||||||
|
return b.get<T>(key);
|
||||||
|
},
|
||||||
|
async set(key: string, value: unknown): Promise<void> {
|
||||||
|
const b = await getBackend();
|
||||||
|
return b.set(key, value);
|
||||||
|
},
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
const b = await getBackend();
|
||||||
|
return b.delete(key);
|
||||||
|
},
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
const b = await getBackend();
|
||||||
|
return b.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
16
apps/frontend/src/main.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { App } from './App';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
118
apps/frontend/src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button, Card, StatusBadge } from '../components/ui';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { api, ApiError, type DeviceSummary } from '../lib/api';
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [devices, setDevices] = useState<DeviceSummary[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function fetchDevices() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const list = await api.listDevices();
|
||||||
|
setDevices(list);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Erreur réseau');
|
||||||
|
setDevices([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchDevices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-full w-full max-w-5xl flex-col gap-6 p-8">
|
||||||
|
{/* ─ Header ─ */}
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wider text-slate-500">Tableau de bord</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold tracking-tight">
|
||||||
|
Bonjour{user ? `, ${user.email.split('@')[0]}` : ''} 👋
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => void logout()}>
|
||||||
|
Déconnexion
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ─ Actions ─ */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="text-lg font-medium">Tes robots</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => void fetchDevices()}>
|
||||||
|
Rafraîchir
|
||||||
|
</Button>
|
||||||
|
<Link to="/pair">
|
||||||
|
<Button>+ Associer un robot</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─ Device list ─ */}
|
||||||
|
{loading && (
|
||||||
|
<Card className="p-8 text-center text-sm text-slate-400">Chargement…</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<Card className="border-red-500/30 bg-red-500/5 p-6 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && devices && devices.length === 0 && (
|
||||||
|
<Card className="flex flex-col items-center gap-4 p-12 text-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-800 text-4xl">
|
||||||
|
🤖
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Aucun robot associé</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Allume ton Ti-Pote puis associe-le avec le code qui s'affichera.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/pair">
|
||||||
|
<Button>Associer un robot</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && devices && devices.length > 0 && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{devices.map((d) => (
|
||||||
|
<Card key={d.id} className="p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate text-base font-medium">{d.name}</h3>
|
||||||
|
<p className="mt-0.5 truncate font-mono text-xs text-slate-500">
|
||||||
|
{d.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={d.status} />
|
||||||
|
</div>
|
||||||
|
<dl className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
|
||||||
|
<div>
|
||||||
|
<dt className="inline text-slate-500">Firmware : </dt>
|
||||||
|
<dd className="inline">{d.firmwareVersion || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="inline text-slate-500">Vu pour la dernière fois : </dt>
|
||||||
|
<dd className="inline">
|
||||||
|
{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'jamais'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/frontend/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Card, Input } from '../components/ui';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { ApiError } from '../lib/api';
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
from?: { pathname: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const from = (location.state as LocationState | null)?.from?.pathname || '/';
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login({ email, password });
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(
|
||||||
|
err.status === 401
|
||||||
|
? 'Email ou mot de passe incorrect.'
|
||||||
|
: err.message || 'Erreur de connexion.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError('Impossible de joindre le serveur.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center p-8">
|
||||||
|
<Card className="w-full max-w-md p-8">
|
||||||
|
<header className="mb-6 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-500/10 text-3xl">
|
||||||
|
🤖
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Bon retour !</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">Connecte-toi à ton Ti-Pote</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
placeholder="toi@exemple.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Mot de passe"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" loading={submitting} className="mt-2 w-full">
|
||||||
|
Se connecter
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-slate-400">
|
||||||
|
Pas encore de compte ?{' '}
|
||||||
|
<Link to="/register" className="font-medium text-brand-400 hover:text-brand-300">
|
||||||
|
Créer un compte
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
apps/frontend/src/pages/PairRobotPage.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Card } from '../components/ui';
|
||||||
|
import { api, ApiError } from '../lib/api';
|
||||||
|
|
||||||
|
const CODE_LENGTH = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairing flow (user side).
|
||||||
|
*
|
||||||
|
* The robot-client calls POST /pairing/request on first boot, displays a
|
||||||
|
* 6-digit code, and polls GET /pairing/status/:requestId until it sees a
|
||||||
|
* `confirmed` response.
|
||||||
|
*
|
||||||
|
* Here we just collect the 6 digits from the user and POST them to
|
||||||
|
* /pairing/confirm — the backend then flips the pairing request to
|
||||||
|
* confirmed and the robot picks up its device credentials on its next poll.
|
||||||
|
*/
|
||||||
|
export function PairRobotPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [digits, setDigits] = useState<string[]>(() =>
|
||||||
|
Array.from({ length: CODE_LENGTH }, () => ''),
|
||||||
|
);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<{ deviceId: string; deviceName: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
|
const code = useMemo(() => digits.join(''), [digits]);
|
||||||
|
const isComplete = code.length === CODE_LENGTH && digits.every((d) => /\d/.test(d));
|
||||||
|
|
||||||
|
function setDigitAt(index: number, value: string) {
|
||||||
|
const cleaned = value.replace(/\D/g, '').slice(0, 1);
|
||||||
|
setDigits((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = cleaned;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (cleaned && index < CODE_LENGTH - 1) {
|
||||||
|
inputsRef.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(index: number, e: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
||||||
|
inputsRef.current[index - 1]?.focus();
|
||||||
|
} else if (e.key === 'ArrowLeft' && index > 0) {
|
||||||
|
inputsRef.current[index - 1]?.focus();
|
||||||
|
} else if (e.key === 'ArrowRight' && index < CODE_LENGTH - 1) {
|
||||||
|
inputsRef.current[index + 1]?.focus();
|
||||||
|
} else if (e.key === 'Enter' && isComplete) {
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaste(e: ClipboardEvent<HTMLInputElement>) {
|
||||||
|
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
|
||||||
|
if (!pasted) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const next = Array.from({ length: CODE_LENGTH }, (_, i) => pasted[i] ?? '');
|
||||||
|
setDigits(next);
|
||||||
|
const lastFilled = Math.min(pasted.length, CODE_LENGTH) - 1;
|
||||||
|
inputsRef.current[lastFilled < 0 ? 0 : lastFilled]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await api.confirmPairing(code);
|
||||||
|
setSuccess(result);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
if (err.status === 400) {
|
||||||
|
setError('Code invalide ou expiré. Vérifie le code affiché sur le robot.');
|
||||||
|
} else {
|
||||||
|
setError(err.message || "Erreur lors de l'association.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('Impossible de joindre le serveur.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Success screen ─────────────────────────────────────────────
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center p-8">
|
||||||
|
<Card className="w-full max-w-md p-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 text-4xl">
|
||||||
|
✅
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Robot associé !</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">
|
||||||
|
<span className="font-medium text-slate-200">{success.deviceName}</span> fait
|
||||||
|
maintenant partie de ton foyer.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-xs text-slate-500">{success.deviceId}</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setSuccess(null);
|
||||||
|
setDigits(Array.from({ length: CODE_LENGTH }, () => ''));
|
||||||
|
inputsRef.current[0]?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Associer un autre
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1" onClick={() => navigate('/')}>
|
||||||
|
Terminer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input screen ───────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center p-8">
|
||||||
|
<Card className="w-full max-w-md p-8">
|
||||||
|
<header className="mb-6 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-500/10 text-3xl">
|
||||||
|
🔗
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Associer un robot</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Saisis le code à 6 chiffres affiché sur ton Ti-Pote
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-5"
|
||||||
|
>
|
||||||
|
<div className="flex justify-center gap-2" onPaste={onPaste}>
|
||||||
|
{digits.map((d, i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
inputsRef.current[i] = el;
|
||||||
|
}}
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="\d*"
|
||||||
|
maxLength={1}
|
||||||
|
value={d}
|
||||||
|
onChange={(e) => setDigitAt(i, e.target.value)}
|
||||||
|
onKeyDown={(e) => onKeyDown(i, e)}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
className="h-14 w-12 rounded-xl border border-slate-700 bg-slate-900/60 text-center text-2xl font-semibold tabular-nums text-slate-100 shadow-inner focus:border-brand-400/60 focus:outline-none focus:ring-2 focus:ring-brand-400/40"
|
||||||
|
aria-label={`Chiffre ${i + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-center text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" loading={submitting} disabled={!isComplete} className="w-full">
|
||||||
|
Associer ce robot
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-1 text-sm text-slate-400">
|
||||||
|
<Link to="/" className="hover:text-slate-200">
|
||||||
|
← Retour au tableau de bord
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-lg border border-slate-800 bg-slate-950/50 p-4 text-xs text-slate-500">
|
||||||
|
<p className="mb-1 font-medium text-slate-400">💡 Comment obtenir le code ?</p>
|
||||||
|
<p>
|
||||||
|
Allume ton Ti-Pote. Lors du premier démarrage, il annonce vocalement un code à
|
||||||
|
6 chiffres et l'affiche (écran / LED / logs). Ce code n'est valable que 10 minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
apps/frontend/src/pages/RegisterPage.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Card, Input } from '../components/ui';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { ApiError } from '../lib/api';
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const { register } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [homeName, setHomeName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Le mot de passe doit faire au moins 8 caractères.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await register({ email, password, displayName, homeName });
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(
|
||||||
|
err.status === 409
|
||||||
|
? 'Cette adresse email est déjà utilisée.'
|
||||||
|
: err.message || "Erreur lors de l'inscription.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError('Impossible de joindre le serveur.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center p-8">
|
||||||
|
<Card className="w-full max-w-md p-8">
|
||||||
|
<header className="mb-6 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-500/10 text-3xl">
|
||||||
|
✨
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Crée ton compte</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Associe un Ti-Pote à ta maison en quelques secondes
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
label="Ton prénom"
|
||||||
|
name="displayName"
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
placeholder="Arthur"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Nom de ton foyer"
|
||||||
|
name="homeName"
|
||||||
|
required
|
||||||
|
placeholder="Chez moi"
|
||||||
|
value={homeName}
|
||||||
|
onChange={(e) => setHomeName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
placeholder="toi@exemple.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Mot de passe"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
placeholder="8 caractères minimum"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" loading={submitting} className="mt-2 w-full">
|
||||||
|
Créer mon compte
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-slate-400">
|
||||||
|
Déjà un compte ?{' '}
|
||||||
|
<Link to="/login" className="font-medium text-brand-400 hover:text-brand-300">
|
||||||
|
Se connecter
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/frontend/src/styles/index.css
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-slate-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/frontend/tailwind.config.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
'Inter',
|
||||||
|
'-apple-system',
|
||||||
|
'system-ui',
|
||||||
|
'Segoe UI',
|
||||||
|
'Roboto',
|
||||||
|
'sans-serif',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
26
apps/frontend/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
apps/frontend/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
32
apps/frontend/vite.config.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// @ts-expect-error process is provided by Node at config time
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Tauri expects a fixed port and silences console noise
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host
|
||||||
|
? {
|
||||||
|
protocol: 'ws',
|
||||||
|
host,
|
||||||
|
port: 1421,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
ignored: ['**/src-tauri/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||