Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 73 additions & 76 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -35,6 +36,7 @@ function PageLoader() {

function App() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<BrowserRouter>
Expand Down Expand Up @@ -116,6 +118,7 @@ function App() {
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
)
}

Expand Down
13 changes: 6 additions & 7 deletions src/api/client.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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',
},
Expand Down
44 changes: 44 additions & 0 deletions src/components/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
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 (
<div className="flex flex-col items-center justify-center h-screen gap-4 p-8 text-center">
<h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="text-sm text-muted-foreground max-w-md">
{this.state.error?.message ?? 'An unexpected error occurred.'}
</p>
<button
className="px-4 py-2 text-sm rounded-md border hover:bg-accent"
onClick={() => this.setState({ hasError: false, error: null })}
>
Try again
</button>
</div>
)
}
return this.props.children
}
}
16 changes: 9 additions & 7 deletions src/hooks/ui/useEditableField.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,22 +11,23 @@ export function useEditableText(
onEndEdit?: () => void
) {
const [localValue, setLocalValue] = useState<string>('')
const timerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
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) => {
Expand All @@ -53,22 +54,23 @@ export function useEditableNumber(
onEndEdit?: () => void
) {
const [localValue, setLocalValue] = useState<string>('')
const timerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
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) => {
Expand Down
8 changes: 5 additions & 3 deletions src/hooks/use-collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading