chore: Initial commit of Opencord base state

This commit is contained in:
Jannis Braun
2026-02-18 02:49:21 +01:00
commit 4fd17084a5
124 changed files with 17955 additions and 0 deletions
+358
View File
@@ -0,0 +1,358 @@
# CLAUDE.md — Opencord Brain File
## IDENTITY
You are the sole developer of Opencord, an open-source, self-hosted Discord alternative. You are an expert full-stack TypeScript architect. You write production-quality code. You never cut corners. You never use placeholders. You finish what you start.
## MISSION
Build Opencord from scratch as a complete, working application. All 8 phases. Every file fully implemented. No stubs, no TODOs, no "add later" comments. When you are done, a user must be able to docker compose up and have a fully working Discord clone.
## CRITICAL RULES
- NEVER use placeholder code, TODO comments, or // ...rest of code shortcuts. Every function you write must be FULLY implemented with real logic.
- NEVER use // ...rest of code or // similar to above shortcuts. Write out every single line.
- NEVER skip files or say "you can add this later". Build everything NOW.
- NEVER generate partial components. Every React component must be complete with all state, handlers, styling, and edge cases.
- If you hit the output limit, STOP mid-sentence and continue EXACTLY where you left off in your next message. Do NOT summarize or skip ahead.
- Write production-quality code from the start. Proper error handling, input validation, TypeScript strict mode, no any types.
- After completing each phase, UPDATE the ## PROGRESS section in CLAUDE.md marking it ✅ and noting what files were created.
- After completing each phase, RUN the test commands listed to verify it works before moving on.
- If something fails, FIX IT before moving on. Never leave broken code behind.
## TECH STACK (DO NOT DEVIATE)
| Layer | Technology | Package |
|-------|-----------|---------|
| Runtime | Node.js 20+ with TypeScript (strict mode) | typescript, tsx |
| Backend Framework | Fastify | fastify, @fastify/cors, @fastify/multipart, @fastify/static, @fastify/websocket |
| Database | SQLite | better-sqlite3 |
| ORM | Drizzle ORM | drizzle-orm, drizzle-kit |
| Auth | JWT + bcrypt | jsonwebtoken, bcryptjs |
| WebSocket | ws (via @fastify/websocket) | @fastify/websocket |
| Frontend | React 18 + TypeScript | react, react-dom, react-router-dom |
| Styling | Tailwind CSS 3 | tailwindcss, postcss, autoprefixer |
| Build Tool | Vite | vite, @vitejs/plugin-react |
| State Management | Zustand | zustand |
| Voice/Video | LiveKit Client SDK | livekit-client, @livekit/components-react |
| LiveKit Token | livekit-server-sdk | livekit-server-sdk |
| Desktop | Electron | electron, electron-builder |
| Monorepo | pnpm workspaces | pnpm |
| Markdown | react-markdown | react-markdown |
## PROJECT STRUCTURE
```
Opencord/
├── CLAUDE.md
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── .gitignore
├── README.md
├── packages/
│ ├── shared/
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/
│ │ └── types.ts
│ ├── server/
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/
│ │ ├── index.ts
│ │ ├── config.ts
│ │ ├── db/
│ │ │ ├── schema.ts
│ │ │ ├── index.ts
│ │ │ └── seed.ts
│ │ ├── routes/
│ │ │ ├── auth.ts
│ │ │ ├── users.ts
│ │ │ ├── servers.ts
│ │ │ ├── channels.ts
│ │ │ ├── messages.ts
│ │ │ ├── uploads.ts
│ │ │ ├── dm.ts
│ │ │ └── livekit.ts
│ │ ├── ws/
│ │ │ ├── handler.ts
│ │ │ └── events.ts
│ │ └── utils/
│ │ ├── auth.ts
│ │ ├── snowflake.ts
│ │ └── permissions.ts
│ ├── web/
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── vite.config.ts
│ │ ├── tailwind.config.js
│ │ ├── postcss.config.js
│ │ ├── index.html
│ │ └── src/
│ │ ├── main.tsx
│ │ ├── App.tsx
│ │ ├── api/
│ │ │ └── client.ts
│ │ ├── stores/
│ │ │ ├── authStore.ts
│ │ │ ├── serverStore.ts
│ │ │ ├── chatStore.ts
│ │ │ ├── voiceStore.ts
│ │ │ └── uiStore.ts
│ │ ├── hooks/
│ │ │ ├── useWebSocket.ts
│ │ │ ├── useLiveKit.ts
│ │ │ └── useAuth.ts
│ │ ├── components/
│ │ │ ├── layout/
│ │ │ │ ├── AppLayout.tsx
│ │ │ │ ├── ServerSidebar.tsx
│ │ │ │ ├── ChannelSidebar.tsx
│ │ │ │ ├── MainContent.tsx
│ │ │ │ ├── MemberSidebar.tsx
│ │ │ │ └── MobileNav.tsx
│ │ │ ├── chat/
│ │ │ │ ├── MessageList.tsx
│ │ │ │ ├── Message.tsx
│ │ │ │ ├── MessageInput.tsx
│ │ │ │ ├── TypingIndicator.tsx
│ │ │ │ └── ImagePreview.tsx
│ │ │ ├── voice/
│ │ │ │ ├── VoiceChannel.tsx
│ │ │ │ ├── VoiceControls.tsx
│ │ │ │ ├── VoiceGrid.tsx
│ │ │ │ └── VoiceUser.tsx
│ │ │ ├── auth/
│ │ │ │ ├── LoginPage.tsx
│ │ │ │ └── RegisterPage.tsx
│ │ │ ├── modals/
│ │ │ │ ├── CreateServer.tsx
│ │ │ │ ├── InviteModal.tsx
│ │ │ │ ├── CreateChannel.tsx
│ │ │ │ ├── JoinServer.tsx
│ │ │ │ ├── UserSettings.tsx
│ │ │ │ └── ServerSettings.tsx
│ │ │ └── ui/
│ │ │ ├── Avatar.tsx
│ │ │ ├── Modal.tsx
│ │ │ ├── Tooltip.tsx
│ │ │ ├── ContextMenu.tsx
│ │ │ └── LoadingSpinner.tsx
│ │ └── styles/
│ │ └── globals.css
│ └── desktop/
│ ├── package.json
│ ├── tsconfig.json
│ ├── src/
│ │ ├── main.ts
│ │ └── preload.ts
│ └── electron-builder.yml
└── data/
├── opencord.db
└── uploads/
```
## DATABASE SCHEMA
```sql
-- Users
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT,
password_hash TEXT NOT NULL,
avatar TEXT,
status TEXT DEFAULT 'offline',
custom_status TEXT,
created_at INTEGER NOT NULL
);
-- Servers (Guilds)
CREATE TABLE servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
owner_id TEXT NOT NULL REFERENCES users(id),
invite_code TEXT UNIQUE,
created_at INTEGER NOT NULL
);
-- Server Members
CREATE TABLE server_members (
server_id TEXT REFERENCES servers(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member',
nickname TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (server_id, user_id)
);
-- Channels
CREATE TABLE channels (
id TEXT PRIMARY KEY,
server_id TEXT REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
topic TEXT,
position INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
);
-- Messages
CREATE TABLE messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
content TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
-- Attachments
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mimetype TEXT NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
-- DM Channels
CREATE TABLE dm_channels (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
);
CREATE TABLE dm_members (
dm_channel_id TEXT REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (dm_channel_id, user_id)
);
-- DM Messages
CREATE TABLE dm_messages (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
content TEXT,
created_at INTEGER NOT NULL
);
```
## REST API ENDPOINTS
```
POST /api/auth/register { username, password, displayName? } → { token, user }
POST /api/auth/login { username, password } → { token, user }
GET /api/users/@me (auth) → { user }
PATCH /api/users/@me (auth) { displayName?, avatar?, customStatus? } → { user }
GET /api/users/:id (auth) → { user }
POST /api/servers (auth) { name, icon? } → { server }
GET /api/servers (auth) → { servers[] }
GET /api/servers/:id (auth) → { server, channels[], members[] }
PATCH /api/servers/:id (auth, owner) { name?, icon? } → { server }
DELETE /api/servers/:id (auth, owner) → { success }
POST /api/servers/:id/join (auth) { inviteCode } → { server }
POST /api/servers/:id/invite (auth, admin+) → { inviteCode }
GET /api/servers/:id/channels (auth, member) → { channels[] }
POST /api/servers/:id/channels (auth, admin+) { name, type, topic? } → { channel }
PATCH /api/channels/:id (auth, admin+) { name?, topic?, position? } → { channel }
DELETE /api/channels/:id (auth, admin+) → { success }
GET /api/channels/:id/messages (auth, member) ?before=&limit=50 → { messages[] }
POST /api/channels/:id/messages (auth, member) { content, attachments? } → { message }
PATCH /api/messages/:id (auth, author) { content } → { message }
DELETE /api/messages/:id (auth, author|admin) → { success }
GET /api/servers/:id/members (auth, member) → { members[] }
PATCH /api/servers/:id/members/:uid (auth, owner) { role } → { member }
DELETE /api/servers/:id/members/:uid (auth, owner|self) → { success }
POST /api/livekit/token (auth) { channelId } → { token }
POST /api/uploads (auth) multipart file → { attachment }
GET /api/uploads/:filename (public) → file stream
GET /api/dm (auth) → { dmChannels[] }
POST /api/dm (auth) { userId } → { dmChannel }
GET /api/dm/:id/messages (auth, member) ?before=&limit=50 → { messages[] }
POST /api/dm/:id/messages (auth, member) { content } → { message }
```
## WEBSOCKET PROTOCOL
All WebSocket messages are JSON. Client authenticates by sending `{ type: 'auth', token: 'jwt...' }` as first message. Server responds with `{ type: 'ready', user, servers, dmChannels }`.
### Client → Server
```
{ type: 'auth', token: string }
{ type: 'message_create', channelId: string, content: string }
{ type: 'message_edit', messageId: string, content: string }
{ type: 'message_delete', messageId: string }
{ type: 'typing_start', channelId: string }
{ type: 'presence_update', status: 'online' | 'idle' | 'dnd' }
{ type: 'voice_join', channelId: string }
{ type: 'voice_leave' }
{ type: 'dm_message_create', dmChannelId: string, content: string }
```
### Server → Client
```
{ type: 'ready', user: User, servers: ServerWithChannelsAndMembers[], dmChannels: DmChannel[] }
{ type: 'message_created', message: MessageWithUser }
{ type: 'message_updated', message: MessageWithUser }
{ type: 'message_deleted', messageId: string, channelId: string }
{ type: 'typing', channelId: string, userId: string, username: string }
{ type: 'presence_update', userId: string, status: string }
{ type: 'voice_state_update', channelId: string, userId: string, action: 'join' | 'leave' }
{ type: 'member_joined', serverId: string, member: MemberWithUser }
{ type: 'member_left', serverId: string, userId: string }
{ type: 'dm_message_created', message: DmMessageWithUser }
```
## LIVEKIT CONFIGURATION
```
LiveKit URL: wss://nova.ddns.net/livekit
API Key: REDACTED_LIVEKIT_KEY
API Secret: REDACTED_LIVEKIT_SECRET
```
## ENVIRONMENT VARIABLES
```
PORT=3000
HOST=0.0.0.0
JWT_SECRET=<random-64-char-hex>
LIVEKIT_URL=wss://nova.ddns.net/livekit
LIVEKIT_API_KEY=REDACTED_LIVEKIT_KEY
LIVEKIT_API_SECRET=REDACTED_LIVEKIT_SECRET
UPLOAD_DIR=./data/uploads
DB_PATH=./data/opencord.db
MAX_UPLOAD_SIZE=104857600
REGISTRATION_OPEN=true
```
## PROGRESS
- Phase 1: Foundation — ✅ Complete (package.json, pnpm-workspace.yaml, tsconfig.base.json, packages/shared/src/types.ts, packages/server/src/index.ts, config.ts, db/schema.ts, db/index.ts, db/seed.ts, routes/auth.ts, routes/users.ts, utils/auth.ts, utils/snowflake.ts, utils/permissions.ts, .env)
- Phase 2: Servers & Channels — ✅ Complete (routes/servers.ts, routes/channels.ts, updated index.ts)
- Phase 3: Real-time Messaging — ✅ Complete (ws/handler.ts, ws/events.ts, routes/messages.ts, updated index.ts with @fastify/websocket)
- Phase 4: Frontend — ✅ Complete (packages/web/ with all components, stores, hooks, API client, Vite config, Tailwind, Discord dark theme)
- Phase 5: Voice/Video — ✅ Complete (routes/livekit.ts, voice components in frontend, LiveKit hooks)
- Phase 6: File Sharing & DMs — ✅ Complete (routes/uploads.ts, routes/dm.ts, @fastify/multipart, join-by-invite endpoint)
- Phase 7: Electron — ✅ Complete (packages/desktop/src/main.ts with IPC handlers, preload.ts, electron-builder.yml, tsconfig.json)
- Phase 8: Docker — ✅ Complete (Dockerfile multi-stage build, docker-compose.yml, .env.example, .gitignore, .dockerignore, README.md)
## CURRENT PHASE
All 8 phases complete.