diff --git a/README.md b/README.md index 0174a88..beeaffa 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,114 @@ # Nexo UI -Alternative web application to Notion, built with React and TypeScript. - -## Features - -- **Authentication**: Login and registration with JWT -- **Dual Sidebar Layout** (Slack-style): - - Left sidebar with space icons for quick switching - - Main sidebar with documents and favorites - - Fully responsive (mobile-optimized header + overlay) -- **Spaces Management**: - - Visual space icons with custom colors - - One-click space switching - - Create/manage multiple workspaces -- **Documents**: Hierarchical document organization -- **Favorites**: Quick access to favorite documents -- **Dark Mode**: System-aware theme with manual toggle -- **Rich Text Editor**: Powered by Tiptap (MIT license) - -## Tech Stack - -- **Frontend**: React 18 + TypeScript -- **Build Tool**: Vite -- **Styling**: TailwindCSS v4 -- **UI Components**: Radix UI -- **State Management**: TanStack Query -- **HTTP Client**: Axios -- **Editor**: Tiptap -- **Icons**: Lucide React -- **Routing**: React Router v7 +Frontend for Nexo — a self-hosted alternative to Notion, built with React and TypeScript. ## Getting Started ### Prerequisites - Node.js 18+ -- npm or yarn -- Backend API running on `http://127.0.0.1:8080` +- pnpm (or npm/yarn) +- Nexo backend running ### Installation ```bash -# Install dependencies -npm install +pnpm install # Generate TypeScript types from OpenAPI spec -npm run generate:api +pnpm run generate:api ``` ### Development ```bash -# Start development server -npm run dev +pnpm run dev ``` -The application will be available at `http://localhost:5173` +App available at `http://localhost:5173`. ### Build ```bash -# Build for production -npm run build +pnpm run build ``` -The built files will be in the `dist/` directory, ready to be embedded in the Go binary. +Output in `dist/` — served as static files embedded in the Go binary. -### Scripts +--- -- `npm run dev` - Start development server -- `npm run build` - Build for production -- `npm run preview` - Preview production build -- `npm run lint` - Run ESLint -- `npm run generate:api` - Generate TypeScript types from OpenAPI spec +## Configuration -## Project Structure +All variables are prefixed `VITE_` and must be set at **build time** (Vite inlines them). +Create a `.env.local` for local overrides (never commit it). + +### Environment variables +| Variable | Default | Description | +|----------|---------|-------------| +| `VITE_API_URL` | *(dev: `http://127.0.0.1:8080/api/v1`, prod: `/api/v1`)* | Full base URL of the backend API. Set this in production if the API lives on a different origin than the frontend (e.g. `https://api.example.com/api/v1`). | +| `VITE_COLLAB_WS_URL` | *(auto-detected from page protocol)* | WebSocket URL for real-time collaboration. Auto-resolves to `wss://` when the page is served over HTTPS, `ws://` over HTTP. Override if needed: `wss://api.example.com/ws/collab` | + +### `.env` files + +| File | Purpose | +|------|---------| +| `.env` | Committed defaults (no secrets) | +| `.env.local` | Local overrides — gitignored | +| `.env.production` | Production build values | + +Example `.env.production`: + +```env +VITE_API_URL=https://api.example.com/api/v1 +VITE_COLLAB_WS_URL=wss://api.example.com/ws/collab ``` -src/ -├── api/ # API client and generated types -├── components/ # React components -│ ├── ui/ # Reusable UI components (Button, Input, Dialog, Tooltip, etc.) -│ ├── layout/ # Layout components -│ │ ├── main-layout.tsx # Responsive double sidebar layout -│ │ ├── spaces-sidebar.tsx # Left sidebar with space icons -│ │ ├── sidebar.tsx # Main sidebar with docs/favorites -│ │ └── mobile-header.tsx # Mobile-only header -│ └── spaces/ # Space-related components -│ ├── space-icon.tsx # Reusable space icon -│ ├── space-switcher.tsx # Space dropdown (mobile) -│ └── create-space-modal.tsx # Create space modal -├── contexts/ # React contexts (Auth, Space, Theme) -├── hooks/ # Custom React hooks -│ ├── use-media-query.ts # Responsive breakpoint detection -│ ├── use-spaces.ts # Space management -│ ├── use-documents.ts # Document operations -│ └── use-favorites.ts # Favorites management -├── pages/ # Page components -├── lib/ # Utilities -└── main.tsx # Application entry point + +Example `.env.local` (local dev against a non-default port): + +```env +VITE_API_URL=http://127.0.0.1:9000/api/v1 +VITE_COLLAB_WS_URL=ws://127.0.0.1:9000/ws/collab ``` -## API Integration +--- -The application communicates with a Go backend API. The API specification is located in `spec/api-spec.json`. +## Tech Stack -TypeScript types are automatically generated from the OpenAPI spec using `openapi-typescript`. +- **React 18** + TypeScript +- **Vite** — build tool +- **TailwindCSS v4** — styling +- **Radix UI** — accessible primitives +- **TanStack Query** — server state +- **Axios** — HTTP client +- **React Router v7** — routing +- **Y.js** + `y-websocket` — real-time collaboration -## Configuration +## Project Structure + +``` +src/ +├── api/ # Axios client + generated types +├── components/ # Shared components +│ ├── ui/ # Design-system primitives +│ ├── editor/ # Rich text and database editors +│ └── error-boundary.tsx +├── contexts/ # React contexts (Auth, Space, Theme…) +├── hooks/ # Custom hooks +├── lib/ # Utilities (formula evaluator, query client…) +├── pages/ # Route-level components +└── main.tsx +``` + +## Scripts -- **API Base URL**: Configured in `vite.config.ts` (proxy to `/api`) -- **Theme**: System-aware with manual toggle -- **Authentication**: JWT stored in localStorage +| Script | Description | +|--------|-------------| +| `pnpm dev` | Dev server with HMR | +| `pnpm build` | Production build | +| `pnpm preview` | Preview the production build | +| `pnpm lint` | ESLint | +| `pnpm generate:api` | Regenerate TypeScript types from `spec/api-spec.json` | ## License diff --git a/src/App.tsx b/src/App.tsx index 9639e2d..f60a8b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense } from 'react' +import { ErrorBoundary } from '@/components/error-boundary' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { QueryClientProvider } from '@tanstack/react-query' import { queryClient } from '@/lib/query-client' @@ -35,6 +36,7 @@ function PageLoader() { function App() { return ( + @@ -116,6 +118,7 @@ function App() { + ) } diff --git a/src/api/client.ts b/src/api/client.ts index 8309b0e..72f04c7 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,9 +1,8 @@ import axios from 'axios' -// Utiliser l'URL complète pour le développement et production -const API_BASE_URL = import.meta.env.DEV - ? 'http://127.0.0.1:8080/api/v1' - : '/api/v1' +const API_BASE_URL = import.meta.env.VITE_API_URL ?? ( + import.meta.env.DEV ? 'http://127.0.0.1:8080/api/v1' : '/api/v1' +) export const apiClient = axios.create({ baseURL: API_BASE_URL, @@ -55,9 +54,9 @@ apiClient.interceptors.response.use( ) export const healthClient = axios.create({ - baseURL: import.meta.env.DEV - ? 'http://127.0.0.1:8080/api/health' - : '/api/health', + baseURL: import.meta.env.VITE_API_URL + ? import.meta.env.VITE_API_URL.replace('/api/v1', '/api/health') + : (import.meta.env.DEV ? 'http://127.0.0.1:8080/api/health' : '/api/health'), headers: { 'Content-Type': 'application/json', }, diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx new file mode 100644 index 0000000..b8f9aef --- /dev/null +++ b/src/components/error-boundary.tsx @@ -0,0 +1,44 @@ +import { Component, type ReactNode, type ErrorInfo } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + state: State = { hasError: false, error: null } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('[ErrorBoundary]', error, info.componentStack) + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) return this.props.fallback + return ( +
+

Something went wrong

+

+ {this.state.error?.message ?? 'An unexpected error occurred.'} +

+ +
+ ) + } + return this.props.children + } +} diff --git a/src/hooks/ui/useEditableField.ts b/src/hooks/ui/useEditableField.ts index 379e5b1..9386c09 100644 --- a/src/hooks/ui/useEditableField.ts +++ b/src/hooks/ui/useEditableField.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' /** * Hook for text input with local state during editing. @@ -11,22 +11,23 @@ export function useEditableText( onEndEdit?: () => void ) { const [localValue, setLocalValue] = useState('') + const timerRef = useRef | null>(null) - // Sync local value when entering edit mode useEffect(() => { if (isEditing) { setLocalValue((externalValue as string) || '') } }, [isEditing, externalValue]) + useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, []) + const handleChange = useCallback((e: React.ChangeEvent) => { setLocalValue(e.target.value) }, []) const handleBlur = useCallback(() => { - // Save the value when leaving edit mode onChange(localValue) - setTimeout(() => onEndEdit?.(), 50) + timerRef.current = setTimeout(() => onEndEdit?.(), 50) }, [localValue, onChange, onEndEdit]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -53,22 +54,23 @@ export function useEditableNumber( onEndEdit?: () => void ) { const [localValue, setLocalValue] = useState('') + const timerRef = useRef | null>(null) - // Sync local value when entering edit mode useEffect(() => { if (isEditing) { setLocalValue(externalValue !== null && externalValue !== undefined ? String(externalValue) : '') } }, [isEditing, externalValue]) + useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, []) + const handleChange = useCallback((e: React.ChangeEvent) => { setLocalValue(e.target.value) }, []) const handleBlur = useCallback(() => { - // Save the value when leaving edit mode onChange(localValue ? Number(localValue) : null) - setTimeout(() => onEndEdit?.(), 50) + timerRef.current = setTimeout(() => onEndEdit?.(), 50) }, [localValue, onChange, onEndEdit]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { diff --git a/src/hooks/use-collaboration.ts b/src/hooks/use-collaboration.ts index df539fa..c0adb66 100644 --- a/src/hooks/use-collaboration.ts +++ b/src/hooks/use-collaboration.ts @@ -62,9 +62,11 @@ function buildCursor(user: { name?: string; color?: string }): HTMLElement { return wrap } -const DEFAULT_WS_URL = import.meta.env.VITE_COLLAB_WS_URL || ( - import.meta.env.DEV ? 'ws://127.0.0.1:8080/ws/collab' : `ws://${window.location.host}/ws/collab` -) +const DEFAULT_WS_URL = import.meta.env.VITE_COLLAB_WS_URL || (() => { + if (import.meta.env.DEV) return 'ws://127.0.0.1:8080/ws/collab' + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${proto}//${window.location.host}/ws/collab` +})() export function useCollaboration({ roomId, diff --git a/src/lib/database/formula.ts b/src/lib/database/formula.ts index 83dcfb2..49e8e10 100644 --- a/src/lib/database/formula.ts +++ b/src/lib/database/formula.ts @@ -1,5 +1,3 @@ -// Formula evaluation utilities - export interface ColumnRef { id: string title: string @@ -11,9 +9,86 @@ export interface FormulaResult { error?: string } +// Safe recursive-descent parser — no eval() or Function() constructor. +function safeEval(expr: string): number { + expr = expr.replace(/\s+/g, '') + let pos = 0 + + function parseExpr(): number { return parseAddSub() } + + function parseAddSub(): number { + let left = parseMulDiv() + while (pos < expr.length && (expr[pos] === '+' || expr[pos] === '-')) { + const op = expr[pos++] + const right = parseMulDiv() + left = op === '+' ? left + right : left - right + } + return left + } + + function parseMulDiv(): number { + let left = parseUnary() + while (pos < expr.length && (expr[pos] === '*' || expr[pos] === '/')) { + const op = expr[pos++] + const right = parseUnary() + if (op === '/') { + if (right === 0) throw new Error('division by zero') + left = left / right + } else { + left = left * right + } + } + return left + } + + function parseUnary(): number { + if (pos < expr.length && expr[pos] === '-') { pos++; return -parsePrimary() } + if (pos < expr.length && expr[pos] === '+') { pos++; return parsePrimary() } + return parsePrimary() + } + + function parsePrimary(): number { + if (pos < expr.length && expr[pos] === '(') { + pos++ + const val = parseExpr() + if (pos >= expr.length || expr[pos] !== ')') throw new Error("missing ')'") + pos++ + return val + } + const start = pos + if (pos < expr.length && (expr[pos] === '-' || expr[pos] === '+')) pos++ + while (pos < expr.length && (expr[pos] >= '0' && expr[pos] <= '9' || expr[pos] === '.')) pos++ + if (pos === start) throw new Error(`unexpected token at ${pos}`) + const n = parseFloat(expr.slice(start, pos)) + if (isNaN(n)) throw new Error('invalid number') + return n + } + + const result = parseExpr() + if (pos !== expr.length) throw new Error(`unexpected character '${expr[pos]}' at ${pos}`) + return result +} + +function safeEvalCondition(expr: string): boolean { + const m = expr.replace(/\s+/g, '').match(/^(.+?)(>=|<=|==|!=|>|<)(.+)$/) + if (m) { + const l = safeEval(m[1]), r = safeEval(m[3]) + switch (m[2]) { + case '>': return l > r + case '<': return l < r + case '>=': return l >= r + case '<=': return l <= r + case '==': return l === r + case '!=': return l !== r + } + } + return safeEval(expr) !== 0 +} + /** - * Evaluate a formula expression - * Supports: column references (prop("column_id")), basic math (+, -, *, /), and functions + * Evaluate a formula expression. + * Supports: prop("column_id"), basic math (+, -, *, /), and IF/ABS/ROUND/MIN/MAX. + * Does NOT use eval() or Function() — fully safe against code injection. */ export function evaluateFormula( formula: string, @@ -23,9 +98,8 @@ export function evaluateFormula( if (!formula) return { value: null } try { - // Replace prop("column_id") or prop("column_name") with actual values + // Replace prop("column_id") with numeric values let expression = formula.replace(/prop\s*\(\s*["']([^"']+)["']\s*\)/g, (_, ref) => { - // Try to find column by id first, then by name const col = columns.find(c => c.id === ref || c.title === ref) if (!col) return '0' const val = row[col.id] @@ -36,72 +110,46 @@ export function evaluateFormula( return isNaN(num) ? '0' : String(num) }) - // Handle built-in functions - // IF(condition, then, else) + // IF(condition, thenVal, elseVal) expression = expression.replace( /IF\s*\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)/gi, (_, cond, thenVal, elseVal) => { - try { - // Simple condition evaluation (supports >, <, >=, <=, ==, !=) - const condResult = Function('"use strict"; return (' + cond + ')')() - return condResult ? thenVal : elseVal - } catch { - return '0' - } + try { return safeEvalCondition(cond) ? thenVal : elseVal } catch { return '0' } } ) // ABS(value) expression = expression.replace(/ABS\s*\(\s*([^)]+)\s*\)/gi, (_, val) => { - try { - const num = Function('"use strict"; return (' + val + ')')() - return String(Math.abs(num)) - } catch { - return '0' - } + try { return String(Math.abs(safeEval(val))) } catch { return '0' } }) - // ROUND(value, decimals) - expression = expression.replace(/ROUND\s*\(\s*([^,]+)\s*,?\s*([^)]*)\s*\)/gi, (_, val, dec) => { + // ROUND(value, decimals?) + expression = expression.replace(/ROUND\s*\(\s*([^,)]+)\s*,?\s*([^)]*)\s*\)/gi, (_, val, dec) => { try { - const num = Function('"use strict"; return (' + val + ')')() - const decimals = dec ? parseInt(dec) : 0 - return String(Number(num.toFixed(decimals))) - } catch { - return '0' - } + const n = safeEval(val) + const d = dec ? parseInt(dec, 10) : 0 + return String(Number(n.toFixed(isNaN(d) ? 0 : d))) + } catch { return '0' } }) // MIN(a, b, ...) expression = expression.replace(/MIN\s*\(\s*([^)]+)\s*\)/gi, (_, args) => { try { - const values = args.split(',').map((v: string) => Function('"use strict"; return (' + v.trim() + ')')()) - return String(Math.min(...values)) - } catch { - return '0' - } + const vals = args.split(',').map((v: string) => safeEval(v.trim())) + return String(Math.min(...vals)) + } catch { return '0' } }) // MAX(a, b, ...) expression = expression.replace(/MAX\s*\(\s*([^)]+)\s*\)/gi, (_, args) => { try { - const values = args.split(',').map((v: string) => Function('"use strict"; return (' + v.trim() + ')')()) - return String(Math.max(...values)) - } catch { - return '0' - } + const vals = args.split(',').map((v: string) => safeEval(v.trim())) + return String(Math.max(...vals)) + } catch { return '0' } }) - // Evaluate the final expression (basic math only) - // Security: only allow numbers, operators, parentheses, and whitespace - if (!/^[\d\s+\-*/.()]+$/.test(expression)) { - return { value: null, error: 'Invalid formula' } - } - - const result = Function('"use strict"; return (' + expression + ')')() - if (typeof result === 'number' && !isNaN(result)) { - return { value: result } - } + const result = safeEval(expression) + if (!isNaN(result)) return { value: result } return { value: null, error: 'Invalid result' } } catch (e) { return { value: null, error: String(e) }