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
+16
View File
@@ -0,0 +1,16 @@
node_modules
.git
.gitignore
.env
.env.local
data
dist
dist-electron
*.md
*.log
.DS_Store
.idea
.vscode
packages/desktop
npm
livekit
+22
View File
@@ -0,0 +1,22 @@
# Opencord Configuration
# Copy this file to .env and fill in the values
# Server
PORT=3000
HOST=0.0.0.0
# Authentication — generate a random secret: openssl rand -hex 32
JWT_SECRET=change_me_to_a_random_64_char_hex_string
# LiveKit Voice/Video (optional — leave empty to disable voice features)
LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# Storage
UPLOAD_DIR=./data/uploads
DB_PATH=./data/opencord.db
MAX_UPLOAD_SIZE=104857600
# Registration — set to false to disable new user registration
REGISTRATION_OPEN=true
+46
View File
@@ -0,0 +1,46 @@
# Dependencies
node_modules/
.pnpm-store/
# Build output
dist/
dist-electron/
packages/web/dist/
packages/server/dist/
packages/shared/dist/
packages/desktop/dist/
packages/desktop/dist-electron/
# Data
data/
*.db
*.db-journal
# Environment
.env
.env.local
.env.*.local
# OS files
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# IDE
.idea/
.vscode/
*.code-workspace
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# TypeScript
*.tsbuildinfo
# Misc
.cache/
tmp/
+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.
+84
View File
@@ -0,0 +1,84 @@
# ============================================================
# Opencord — Multi-stage Docker build
# ============================================================
# Stage 1: Install dependencies and build frontend
FROM node:20-slim AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Copy workspace config
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY tsconfig.base.json ./
# Copy package.json files for all workspace packages
COPY packages/shared/package.json packages/shared/
COPY packages/server/package.json packages/server/
COPY packages/web/package.json packages/web/
# Install dependencies
RUN pnpm install --frozen-lockfile || pnpm install
# Copy source code (excluding desktop — not needed in Docker)
COPY packages/shared/ packages/shared/
COPY packages/server/ packages/server/
COPY packages/web/ packages/web/
# Build the web frontend
RUN cd packages/web && pnpm run build
# ============================================================
# Stage 2: Production runtime
FROM node:20-slim AS runtime
RUN corepack enable && corepack prepare pnpm@latest --activate
# Install build dependencies for better-sqlite3 native module
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 make g++ && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy workspace config
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY tsconfig.base.json ./
# Copy package.json files
COPY packages/shared/package.json packages/shared/
COPY packages/server/package.json packages/server/
COPY packages/web/package.json packages/web/
# Install production dependencies only (plus tsx for running TS)
RUN pnpm install --frozen-lockfile || pnpm install
# Copy shared source (needed at runtime since server imports types directly)
COPY packages/shared/ packages/shared/
# Copy server source
COPY packages/server/ packages/server/
# Copy built frontend from builder stage
COPY --from=builder /app/packages/web/dist packages/web/dist
# Create data directories
RUN mkdir -p /app/data/uploads
# Set environment defaults
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
ENV DB_PATH=/app/data/opencord.db
ENV UPLOAD_DIR=/app/data/uploads
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
# Run the server using tsx from the server package directory
WORKDIR /app/packages/server
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
+143
View File
@@ -0,0 +1,143 @@
# Opencord
Open-source, self-hosted Discord alternative built with TypeScript.
## Features
- Real-time text messaging with WebSocket
- Servers, channels, and member management
- Voice and video chat via LiveKit
- Direct messages
- File uploads and image sharing
- Markdown message formatting
- Typing indicators and presence status
- Role-based permissions (owner, admin, member)
- Invite system with shareable codes
- Desktop app (Electron)
- Mobile-responsive web UI
- Docker deployment
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Backend | Fastify + TypeScript |
| Database | SQLite (better-sqlite3) + Drizzle ORM |
| Auth | JWT + bcrypt |
| Real-time | WebSocket (ws) |
| Frontend | React 18 + Tailwind CSS + Zustand |
| Voice/Video | LiveKit |
| Desktop | Electron |
| Build | Vite + pnpm workspaces |
## Quick Start with Docker
```bash
# Clone the repository
git clone https://github.com/your-username/opencord.git
cd opencord
# Create environment file
cp .env.example .env
# Generate a JWT secret
echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env
# Start Opencord
docker compose up -d
```
Open `http://localhost:3000` in your browser. A default server "Opencord" is created automatically.
**Default admin account:** `admin` / `admin123` (change this after first login).
## Development Setup
### Prerequisites
- Node.js 20+
- pnpm 8+
### Install
```bash
pnpm install
```
### Configure
```bash
cp .env.example .env
# Edit .env with your settings (generate a JWT_SECRET)
```
### Run
```bash
# Start both server and web dev server
pnpm dev
# Or start individually
pnpm dev:server # API server on :3000
pnpm dev:web # Vite dev server on :5173
```
### Build
```bash
pnpm build
```
This builds the shared types, server, and web frontend. The server serves the built frontend in production mode.
## Project Structure
```
Opencord/
├── packages/
│ ├── shared/ # Shared TypeScript types
│ ├── server/ # Fastify API + WebSocket server
│ ├── web/ # React frontend (Vite + Tailwind)
│ └── desktop/ # Electron desktop app
├── data/ # SQLite DB + uploads (created at runtime)
├── Dockerfile
├── docker-compose.yml
└── .env.example
```
## Voice & Video
Voice and video requires a [LiveKit](https://livekit.io/) server. Set these in your `.env`:
```
LIVEKIT_URL=wss://your-livekit-server
LIVEKIT_API_KEY=your-api-key
LIVEKIT_API_SECRET=your-api-secret
```
Without LiveKit configured, text chat works fully but voice/video channels will not connect.
## API
The server exposes a REST API and WebSocket endpoint:
- **REST API**: `http://localhost:3000/api/*`
- **WebSocket**: `ws://localhost:3000/ws`
- **Health check**: `GET /api/health`
See [CLAUDE.md](CLAUDE.md) for the full API reference.
## Desktop App
The Electron desktop app wraps the web UI and adds system tray, notifications, and native window controls.
```bash
cd packages/desktop
pnpm build:ts # Compile TypeScript
pnpm dev # Run in development
pnpm build # Package for distribution
```
## License
MIT
+27
View File
@@ -0,0 +1,27 @@
version: "3.8"
services:
opencord:
build: .
container_name: opencord
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
volumes:
- opencord-data:/app/data
env_file:
- .env
environment:
- NODE_ENV=production
- DB_PATH=/app/data/opencord.db
- UPLOAD_DIR=/app/data/uploads
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
opencord-data:
driver: local
+22
View File
@@ -0,0 +1,22 @@
{
"name": "opencord",
"version": "1.0.0",
"private": true,
"description": "Open-source, self-hosted Discord alternative",
"scripts": {
"dev:server": "pnpm --filter @opencord/server dev",
"dev:web": "pnpm --filter @opencord/web dev",
"dev": "pnpm --filter @opencord/server dev & pnpm --filter @opencord/web dev",
"build:shared": "pnpm --filter @opencord/shared build",
"build:server": "pnpm --filter @opencord/server build",
"build:web": "pnpm --filter @opencord/web build",
"build": "pnpm --filter @opencord/shared build && pnpm --filter @opencord/server build & pnpm --filter @opencord/web build"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
},
"engines": {
"node": ">=20.0.0",
"pnpm": ">=8.0.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
appId: com.opencord.desktop
productName: Opencord
directories:
output: dist-electron
files:
- dist/**/*
- "!node_modules"
mac:
category: public.app-category.social-networking
target:
- dmg
- zip
icon: null
win:
target:
- nsis
icon: null
linux:
target:
- AppImage
- deb
category: Network
icon: null
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@opencord/desktop",
"version": "1.0.0",
"private": true,
"main": "dist/main.js",
"scripts": {
"build:ts": "tsc",
"dev": "tsc && electron .",
"build": "tsc && electron-builder",
"clean": "rm -rf dist dist-electron"
},
"dependencies": {
"electron-is-dev": "^3.0.1"
},
"devDependencies": {
"electron": "^33.2.0",
"electron-builder": "^25.1.8",
"typescript": "^5.7.2"
}
}
+194
View File
@@ -0,0 +1,194 @@
import {
app,
BrowserWindow,
Tray,
Menu,
Notification,
nativeImage,
ipcMain,
shell,
} from 'electron';
import path from 'path';
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let isQuitting = false;
const SERVER_URL = process.env.OPENCORD_URL || 'http://localhost:3000';
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 940,
minHeight: 500,
title: 'Opencord',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
backgroundColor: '#313338',
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
mainWindow.loadURL(SERVER_URL);
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
});
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault();
mainWindow?.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// Open external links in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
}
return { action: 'deny' };
});
}
function createTray(): void {
// Create a 16x16 tray icon (blue circle for Opencord)
const size = 16;
const canvas = Buffer.alloc(size * size * 4);
const cx = size / 2;
const cy = size / 2;
const r = size / 2 - 1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const idx = (y * size + x) * 4;
const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2);
if (dist <= r) {
canvas[idx] = 0x58; // R (blurple)
canvas[idx + 1] = 0x65; // G
canvas[idx + 2] = 0xf2; // B
canvas[idx + 3] = 0xff; // A
} else {
canvas[idx] = 0;
canvas[idx + 1] = 0;
canvas[idx + 2] = 0;
canvas[idx + 3] = 0;
}
}
}
const icon = nativeImage.createFromBuffer(canvas, { width: size, height: size });
tray = new Tray(icon);
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show Opencord',
click: () => {
mainWindow?.show();
mainWindow?.focus();
},
},
{
label: 'Hide',
click: () => {
mainWindow?.hide();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
isQuitting = true;
app.quit();
},
},
]);
tray.setToolTip('Opencord');
tray.setContextMenu(contextMenu);
tray.on('click', () => {
if (mainWindow?.isVisible()) {
mainWindow.hide();
} else {
mainWindow?.show();
mainWindow?.focus();
}
});
}
function showNotification(title: string, body: string): void {
if (Notification.isSupported()) {
const notification = new Notification({
title,
body,
silent: false,
});
notification.on('click', () => {
mainWindow?.show();
mainWindow?.focus();
});
notification.show();
}
}
function registerIpcHandlers(): void {
ipcMain.on('show-notification', (_event, data: { title: string; body: string }) => {
showNotification(data.title, data.body);
});
ipcMain.on('set-badge-count', (_event, count: number) => {
if (app.setBadgeCount) {
app.setBadgeCount(count);
}
});
ipcMain.on('minimize-window', () => {
mainWindow?.minimize();
});
ipcMain.on('maximize-window', () => {
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow?.maximize();
}
});
ipcMain.on('close-window', () => {
mainWindow?.close();
});
}
app.on('ready', () => {
registerIpcHandlers();
createWindow();
createTray();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
} else {
mainWindow.show();
}
});
app.on('before-quit', () => {
isQuitting = true;
});
+20
View File
@@ -0,0 +1,20 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('opencord', {
platform: process.platform,
showNotification: (title: string, body: string) => {
ipcRenderer.send('show-notification', { title, body });
},
setBadgeCount: (count: number) => {
ipcRenderer.send('set-badge-count', count);
},
minimize: () => {
ipcRenderer.send('minimize-window');
},
maximize: () => {
ipcRenderer.send('maximize-window');
},
close: () => {
ipcRenderer.send('close-window');
},
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2022"]
},
"include": ["src/**/*"]
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@opencord/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node --import tsx/esm src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"@fastify/websocket": "^10.0.1",
"@opencord/shared": "workspace:*",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.33.0",
"fastify": "^4.28.1",
"jsonwebtoken": "^9.0.2",
"livekit-server-sdk": "^2.6.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.11",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.0",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.24.0",
"tsx": "^4.16.0",
"typescript": "^5.4.0"
}
}
+49
View File
@@ -0,0 +1,49 @@
import { config as dotenvConfig } from 'dotenv';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenvConfig({ path: resolve(__dirname, '../../../.env') });
function env(key: string, defaultValue?: string): string {
const value = process.env[key] ?? defaultValue;
if (value === undefined) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
function envInt(key: string, defaultValue: number): number {
const value = process.env[key];
if (value === undefined) return defaultValue;
const parsed = parseInt(value, 10);
if (isNaN(parsed)) {
throw new Error(`Environment variable ${key} must be a number, got: ${value}`);
}
return parsed;
}
function envBool(key: string, defaultValue: boolean): boolean {
const value = process.env[key];
if (value === undefined) return defaultValue;
return value === 'true' || value === '1';
}
export const config = {
port: envInt('PORT', 3000),
host: env('HOST', '0.0.0.0'),
jwtSecret: env('JWT_SECRET', 'dev-secret-change-me-in-production-please-use-64-chars-hex-string'),
jwtExpiresIn: env('JWT_EXPIRES_IN', '30d'),
livekit: {
url: env('LIVEKIT_URL', 'wss://nova.ddns.net/livekit'),
apiKey: env('LIVEKIT_API_KEY', 'REDACTED_LIVEKIT_KEY'),
apiSecret: env('LIVEKIT_API_SECRET', 'REDACTED_LIVEKIT_SECRET'),
},
uploadDir: env('UPLOAD_DIR', resolve(__dirname, '../../../data/uploads')),
dbPath: env('DB_PATH', resolve(__dirname, '../../../data/opencord.db')),
maxUploadSize: envInt('MAX_UPLOAD_SIZE', 104857600),
registrationOpen: envBool('REGISTRATION_OPEN', true),
} as const;
+123
View File
@@ -0,0 +1,123 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { config } from '../config.js';
import * as schema from './schema.js';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
let sqlite: Database.Database;
function ensureDirectory(filePath: string): void {
const dir = dirname(filePath);
mkdirSync(dir, { recursive: true });
}
function createTables(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS 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
);
CREATE TABLE IF NOT EXISTS 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
);
CREATE TABLE IF NOT EXISTS server_members (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member',
nickname TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (server_id, user_id)
);
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL 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
);
CREATE TABLE IF NOT EXISTS 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
);
CREATE TABLE IF NOT EXISTS 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
);
CREATE TABLE IF NOT EXISTS dm_channels (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS dm_members (
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (dm_channel_id, user_id)
);
CREATE TABLE IF NOT EXISTS 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
);
`);
}
export function initDatabase() {
ensureDirectory(config.dbPath);
sqlite = new Database(config.dbPath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
createTables(sqlite);
console.log(`Database initialized at ${config.dbPath}`);
return drizzle(sqlite, { schema });
}
export type DB = ReturnType<typeof initDatabase>;
let db: DB;
export function getDb(): DB {
if (!db) {
db = initDatabase();
}
return db;
}
export function closeDatabase(): void {
if (sqlite) {
sqlite.close();
}
}
export { schema };
+80
View File
@@ -0,0 +1,80 @@
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
username: text('username').unique().notNull(),
displayName: text('display_name'),
passwordHash: text('password_hash').notNull(),
avatar: text('avatar'),
status: text('status').default('offline'),
customStatus: text('custom_status'),
createdAt: integer('created_at').notNull(),
});
export const servers = sqliteTable('servers', {
id: text('id').primaryKey(),
name: text('name').notNull(),
icon: text('icon'),
ownerId: text('owner_id').notNull().references(() => users.id),
inviteCode: text('invite_code').unique(),
createdAt: integer('created_at').notNull(),
});
export const serverMembers = sqliteTable('server_members', {
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
role: text('role').default('member'),
nickname: text('nickname'),
joinedAt: integer('joined_at').notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.serverId, table.userId] }),
}));
export const channels = sqliteTable('channels', {
id: text('id').primaryKey(),
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
type: text('type').notNull(),
topic: text('topic'),
position: integer('position').default(0),
createdAt: integer('created_at').notNull(),
});
export const messages = sqliteTable('messages', {
id: text('id').primaryKey(),
channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id),
content: text('content'),
editedAt: integer('edited_at'),
createdAt: integer('created_at').notNull(),
});
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
messageId: text('message_id').references(() => messages.id, { onDelete: 'cascade' }),
filename: text('filename').notNull(),
originalName: text('original_name').notNull(),
mimetype: text('mimetype').notNull(),
size: integer('size').notNull(),
createdAt: integer('created_at').notNull(),
});
export const dmChannels = sqliteTable('dm_channels', {
id: text('id').primaryKey(),
createdAt: integer('created_at').notNull(),
});
export const dmMembers = sqliteTable('dm_members', {
dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
}, (table) => ({
pk: primaryKey({ columns: [table.dmChannelId, table.userId] }),
}));
export const dmMessages = sqliteTable('dm_messages', {
id: text('id').primaryKey(),
dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id),
content: text('content'),
createdAt: integer('created_at').notNull(),
});
+69
View File
@@ -0,0 +1,69 @@
import { getDb, schema } from './index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { hashPassword } from '../utils/auth.js';
import { eq } from 'drizzle-orm';
export async function seedDatabase(): Promise<void> {
const db = getDb();
const existingServers = db.select().from(schema.servers).all();
if (existingServers.length > 0) {
console.log('Database already has data, skipping seed');
return;
}
console.log('Seeding database with default data...');
const adminId = generateSnowflake();
const adminPasswordHash = await hashPassword('admin123');
db.insert(schema.users).values({
id: adminId,
username: 'admin',
displayName: 'Admin',
passwordHash: adminPasswordHash,
status: 'offline',
createdAt: Date.now(),
}).run();
const serverId = generateSnowflake();
db.insert(schema.servers).values({
id: serverId,
name: 'Opencord',
ownerId: adminId,
inviteCode: 'opencord',
createdAt: Date.now(),
}).run();
db.insert(schema.serverMembers).values({
serverId: serverId,
userId: adminId,
role: 'owner',
joinedAt: Date.now(),
}).run();
const generalChannelId = generateSnowflake();
db.insert(schema.channels).values({
id: generalChannelId,
serverId: serverId,
name: 'general',
type: 'text',
topic: 'General discussion',
position: 0,
createdAt: Date.now(),
}).run();
const voiceChannelId = generateSnowflake();
db.insert(schema.channels).values({
id: voiceChannelId,
serverId: serverId,
name: 'General Voice',
type: 'voice',
position: 1,
createdAt: Date.now(),
}).run();
console.log('Database seeded successfully');
console.log(` Default server: Opencord (invite code: opencord)`);
console.log(` Admin user: admin / admin123`);
}
+98
View File
@@ -0,0 +1,98 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import websocket from '@fastify/websocket';
import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import { config } from './config.js';
import { initDatabase } from './db/index.js';
import { seedDatabase } from './db/seed.js';
import { authRoutes } from './routes/auth.js';
import { userRoutes } from './routes/users.js';
import { serverRoutes } from './routes/servers.js';
import { channelRoutes } from './routes/channels.js';
import { messageRoutes } from './routes/messages.js';
import { uploadRoutes } from './routes/uploads.js';
import { dmRoutes } from './routes/dm.js';
import { livekitRoutes } from './routes/livekit.js';
import { registerWebSocket } from './ws/handler.js';
import path from 'path';
import fs from 'fs';
async function main(): Promise<void> {
const app = Fastify({
logger: {
level: 'info',
},
});
await app.register(cors, {
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
await app.register(websocket);
await app.register(multipart, {
limits: {
fileSize: config.maxUploadSize,
},
});
// Serve built frontend in production
const webDistPath = path.resolve(import.meta.dirname ?? '.', '../../web/dist');
if (fs.existsSync(webDistPath)) {
await app.register(fastifyStatic, {
root: webDistPath,
prefix: '/',
wildcard: false,
});
}
initDatabase();
await seedDatabase();
await app.register(authRoutes);
await app.register(userRoutes);
await app.register(serverRoutes);
await app.register(channelRoutes);
await app.register(messageRoutes);
await app.register(uploadRoutes);
await app.register(dmRoutes);
await app.register(livekitRoutes);
await app.register(registerWebSocket);
app.get('/api/health', async () => {
return { status: 'ok', timestamp: Date.now() };
});
// SPA fallback - serve index.html for non-API routes
if (fs.existsSync(webDistPath)) {
app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith('/api/') || request.url.startsWith('/ws')) {
return reply.code(404).send({ error: 'Not found', statusCode: 404 });
}
return reply.sendFile('index.html');
});
}
try {
await app.listen({ port: config.port, host: config.host });
console.log(`Opencord server running at http://${config.host}:${config.port}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
const shutdown = async () => {
console.log('Shutting down...');
await app.close();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main();
+120
View File
@@ -0,0 +1,120 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { config } from '../config.js';
import type { RegisterRequest, LoginRequest, AuthResponse, User } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: RegisterRequest }>('/api/auth/register', async (request, reply) => {
const { username, password, displayName } = request.body;
if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
if (!password || typeof password !== 'string') {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}
const trimmedUsername = username.trim();
if (trimmedUsername.length < 3 || trimmedUsername.length > 32) {
return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 });
}
if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) {
return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 });
}
if (password.length < 6) {
return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 });
}
if (!config.registrationOpen) {
return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 });
}
const db = getDb();
const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmedUsername)).get();
if (existing) {
return reply.code(409).send({ error: 'Username already taken', statusCode: 409 });
}
const passwordHash = await hashPassword(password);
const userId = generateSnowflake();
const now = Date.now();
db.insert(schema.users).values({
id: userId,
username: trimmedUsername,
displayName: displayName?.trim() || null,
passwordHash,
status: 'online',
createdAt: now,
}).run();
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!user) {
return reply.code(500).send({ error: 'Failed to create user', statusCode: 500 });
}
const token = signJwt({ userId: user.id, username: user.username });
const response: AuthResponse = {
token,
user: sanitizeUser(user),
};
return reply.code(201).send(response);
});
app.post<{ Body: LoginRequest }>('/api/auth/login', async (request, reply) => {
const { username, password } = request.body;
if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
if (!password || typeof password !== 'string') {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.username, username.trim())).get();
if (!user) {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
const validPassword = await verifyPassword(password, user.passwordHash);
if (!validPassword) {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, user.id)).run();
const token = signJwt({ userId: user.id, username: user.username });
const response: AuthResponse = {
token,
user: sanitizeUser({ ...user, status: 'online' }),
};
return reply.code(200).send(response);
});
}
+188
View File
@@ -0,0 +1,188 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isAdmin, getChannelServerId } from '../utils/permissions.js';
import type {
CreateChannelRequest,
UpdateChannelRequest,
Channel,
} from '@opencord/shared';
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
return {
id: row.id,
serverId: row.serverId,
name: row.name,
type: row.type as Channel['type'],
topic: row.topic,
position: row.position ?? 0,
createdAt: row.createdAt,
};
}
export async function channelRoutes(app: FastifyInstance): Promise<void> {
// GET /api/servers/:id/channels - List channels in a server
app.get<{ Params: { id: string } }>('/api/servers/:id/channels', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const channels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.all();
// Sort by position
channels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
return reply.code(200).send(channels.map(rowToChannel));
});
// POST /api/servers/:id/channels - Create a channel (admin+)
app.post<{ Params: { id: string }; Body: CreateChannelRequest }>('/api/servers/:id/channels', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, type, topic } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Only admins can create channels', statusCode: 403 });
}
if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
}
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
}
if (!type || !['text', 'voice', 'video'].includes(type)) {
return reply.code(400).send({ error: 'Channel type must be "text", "voice", or "video"', statusCode: 400 });
}
// Get max position for ordering
const existingChannels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.all();
const maxPosition = existingChannels.reduce((max, ch) => Math.max(max, ch.position ?? 0), -1);
const channelId = generateSnowflake();
const now = Date.now();
db.insert(schema.channels).values({
id: channelId,
serverId: id,
name: trimmedName,
type,
topic: topic?.trim() || null,
position: maxPosition + 1,
createdAt: now,
}).run();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
if (!channel) {
return reply.code(500).send({ error: 'Failed to create channel', statusCode: 500 });
}
return reply.code(201).send(rowToChannel(channel));
});
// PATCH /api/channels/:id - Update a channel (admin+)
app.patch<{ Params: { id: string }; Body: UpdateChannelRequest }>('/api/channels/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, topic, position } = request.body;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const serverId = channel.serverId;
if (!isAdmin(serverId, request.userId)) {
return reply.code(403).send({ error: 'Only admins can update channels', statusCode: 403 });
}
const updates: Partial<typeof schema.channels.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
}
updates.name = trimmedName;
}
if (topic !== undefined) {
updates.topic = topic.trim() || null;
}
if (position !== undefined) {
if (typeof position !== 'number' || position < 0) {
return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 });
}
updates.position = position;
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.channels).set(updates).where(eq(schema.channels.id, id)).run();
const updated = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update channel', statusCode: 500 });
}
return reply.code(200).send(rowToChannel(updated));
});
// DELETE /api/channels/:id - Delete a channel (admin+)
app.delete<{ Params: { id: string } }>('/api/channels/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
if (!channel) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const serverId = channel.serverId;
if (!isAdmin(serverId, request.userId)) {
return reply.code(403).send({ error: 'Only admins can delete channels', statusCode: 403 });
}
// Delete messages in channel (attachments cascade), then channel
db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run();
db.delete(schema.channels).where(eq(schema.channels.id, id)).run();
return reply.code(200).send({ success: true });
});
}
+313
View File
@@ -0,0 +1,313 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, desc, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isDmMember } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
User,
DmChannel,
DmMessage,
DmMessageWithUser,
CreateDmRequest,
CreateDmMessageRequest,
PaginatedQuery,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export async function dmRoutes(app: FastifyInstance): Promise<void> {
// GET /api/dm - List user's DM channels
app.get('/api/dm', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
const memberships = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, request.userId))
.all();
const dmChannels: DmChannel[] = [];
for (const membership of memberships) {
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, membership.dmChannelId))
.get();
if (!dmChannel) continue;
const dmMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, membership.dmChannelId))
.all();
const memberUserIds = dmMemberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
// Get last message
const allMessages = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, membership.dmChannelId))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(1)
.all();
const lastMessage = allMessages[0] ?? null;
dmChannels.push({
id: dmChannel.id,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
lastMessage: lastMessage ? {
id: lastMessage.id,
dmChannelId: lastMessage.dmChannelId,
userId: lastMessage.userId,
content: lastMessage.content,
createdAt: lastMessage.createdAt,
} : null,
});
}
// Sort by last message timestamp (newest first)
dmChannels.sort((a, b) => {
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
return bTime - aTime;
});
return reply.code(200).send(dmChannels);
});
// POST /api/dm - Create or get existing DM channel
app.post<{ Body: CreateDmRequest }>('/api/dm', {
preHandler: authenticate,
}, async (request, reply) => {
const { userId } = request.body;
if (!userId || typeof userId !== 'string') {
return reply.code(400).send({ error: 'userId is required', statusCode: 400 });
}
if (userId === request.userId) {
return reply.code(400).send({ error: 'Cannot create DM with yourself', statusCode: 400 });
}
const db = getDb();
// Check if target user exists
const targetUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
// Check if DM channel already exists between these two users
const myDms = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, request.userId))
.all();
for (const myDm of myDms) {
const otherMember = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, myDm.dmChannelId),
eq(schema.dmMembers.userId, userId),
))
.get();
if (otherMember) {
// DM channel already exists
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, myDm.dmChannelId))
.get();
if (!dmChannel) continue;
const dmMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, myDm.dmChannelId))
.all();
const memberUserIds = dmMemberRows.map(m => m.userId);
const users = db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all();
const result: DmChannel = {
id: dmChannel.id,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
lastMessage: null,
};
return reply.code(200).send(result);
}
}
// Create new DM channel
const dmChannelId = generateSnowflake();
const now = Date.now();
db.insert(schema.dmChannels).values({
id: dmChannelId,
createdAt: now,
}).run();
db.insert(schema.dmMembers).values({
dmChannelId,
userId: request.userId,
}).run();
db.insert(schema.dmMembers).values({
dmChannelId,
userId,
}).run();
const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
const members = [currentUserRow, targetUser]
.filter((u): u is NonNullable<typeof u> => u !== undefined)
.map(sanitizeUser);
const result: DmChannel = {
id: dmChannelId,
createdAt: now,
members,
lastMessage: null,
};
return reply.code(201).send(result);
});
// GET /api/dm/:id/messages - Get DM messages with pagination
app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const before = request.query.before;
const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100);
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
const db = getDb();
let messageRows: (typeof schema.dmMessages.$inferSelect)[];
if (before) {
messageRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, id))
.orderBy(desc(schema.dmMessages.createdAt))
.all()
.filter(m => m.id < before)
.slice(0, limit);
} else {
messageRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, id))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(limit)
.all();
}
messageRows.reverse();
if (messageRows.length === 0) {
return reply.code(200).send([]);
}
// Batch fetch users
const userIds = [...new Set(messageRows.map(m => m.userId))];
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
const userMap = new Map(users.map(u => [u.id, u]));
const messages: DmMessageWithUser[] = messageRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return {
id: m.id,
dmChannelId: m.dmChannelId,
userId: m.userId,
content: m.content,
createdAt: m.createdAt,
user: sanitizeUser(user),
};
})
.filter((m): m is DmMessageWithUser => m !== null);
return reply.code(200).send(messages);
});
// POST /api/dm/:id/messages - Send a DM message
app.post<{ Params: { id: string }; Body: CreateDmMessageRequest }>('/api/dm/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content } = request.body;
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return reply.code(400).send({ error: 'Message content is required', statusCode: 400 });
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
db.insert(schema.dmMessages).values({
id: messageId,
dmChannelId: id,
userId: request.userId,
content: content.trim(),
createdAt: now,
}).run();
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const message: DmMessageWithUser = {
id: messageId,
dmChannelId: id,
userId: request.userId,
content: content.trim(),
createdAt: now,
user: sanitizeUser(user),
};
// Broadcast via WebSocket to all DM members
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, id))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_created',
message,
});
}
return reply.code(201).send(message);
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { FastifyInstance } from 'fastify';
import { AccessToken } from 'livekit-server-sdk';
import { authenticate } from '../utils/auth.js';
import { config } from '../config.js';
import { getChannelServerId, isMember } from '../utils/permissions.js';
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@opencord/shared';
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: LiveKitTokenRequest }>('/api/livekit/token', {
preHandler: authenticate,
}, async (request, reply) => {
const { channelId } = request.body;
if (!channelId || typeof channelId !== 'string') {
return reply.code(400).send({ error: 'channelId is required', statusCode: 400 });
}
const serverId = getChannelServerId(channelId);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const identity = `${request.userId}:${request.username}`;
const token = new AccessToken(config.livekit.apiKey, config.livekit.apiSecret, {
identity,
ttl: '1h',
});
token.addGrant({
room: channelId,
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
});
const jwt = await token.toJwt();
const response: LiveKitTokenResponse = { token: jwt };
return reply.code(200).send(response);
});
}
+294
View File
@@ -0,0 +1,294 @@
import type { FastifyInstance } from 'fastify';
import { eq, lt, desc, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, getChannelServerId, isAdmin } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
CreateMessageRequest,
UpdateMessageRequest,
PaginatedQuery,
User,
MessageWithUser,
Attachment,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
function buildMessageWithUser(
message: typeof schema.messages.$inferSelect,
user: typeof schema.users.$inferSelect,
attachmentRows: (typeof schema.attachments.$inferSelect)[],
): MessageWithUser {
return {
id: message.id,
channelId: message.channelId,
userId: message.userId,
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
user: sanitizeUser(user),
attachments: attachmentRows.map(a => ({
id: a.id,
messageId: a.messageId ?? message.id,
filename: a.filename,
originalName: a.originalName,
mimetype: a.mimetype,
size: a.size,
createdAt: a.createdAt,
})),
};
}
export async function messageRoutes(app: FastifyInstance): Promise<void> {
// GET /api/channels/:id/messages - Get messages with cursor pagination
app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/channels/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const before = request.query.before;
const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100);
const serverId = getChannelServerId(id);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const db = getDb();
let messageRows: (typeof schema.messages.$inferSelect)[];
if (before) {
messageRows = db.select()
.from(schema.messages)
.where(eq(schema.messages.channelId, id))
.orderBy(desc(schema.messages.createdAt))
.all()
.filter(m => m.id < before)
.slice(0, limit);
} else {
messageRows = db.select()
.from(schema.messages)
.where(eq(schema.messages.channelId, id))
.orderBy(desc(schema.messages.createdAt))
.limit(limit)
.all();
}
// Reverse to get chronological order
messageRows.reverse();
if (messageRows.length === 0) {
return reply.code(200).send([]);
}
// Batch fetch users
const userIds = [...new Set(messageRows.map(m => m.userId))];
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
const userMap = new Map(users.map(u => [u.id, u]));
// Batch fetch attachments
const messageIds = messageRows.map(m => m.id);
const allAttachments = db.select()
.from(schema.attachments)
.where(inArray(schema.attachments.messageId, messageIds))
.all();
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
for (const att of allAttachments) {
const mid = att.messageId ?? '';
if (!attachmentMap.has(mid)) {
attachmentMap.set(mid, []);
}
attachmentMap.get(mid)!.push(att);
}
const messages: MessageWithUser[] = messageRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? []);
})
.filter((m): m is MessageWithUser => m !== null);
return reply.code(200).send(messages);
});
// POST /api/channels/:id/messages - Create a message
app.post<{ Params: { id: string }; Body: CreateMessageRequest }>('/api/channels/:id/messages', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds } = request.body;
const serverId = getChannelServerId(id);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!isMember(serverId, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
(!attachmentIds || attachmentIds.length === 0)) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
db.insert(schema.messages).values({
id: messageId,
channelId: id,
userId: request.userId,
content: content?.trim() || null,
createdAt: now,
}).run();
// Link attachments to message
if (attachmentIds && attachmentIds.length > 0) {
for (const attId of attachmentIds) {
db.update(schema.attachments)
.set({ messageId })
.where(eq(schema.attachments.id, attId))
.run();
}
}
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const attachmentRows = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.messageId, messageId))
.all();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) {
return reply.code(500).send({ error: 'Failed to create message', statusCode: 500 });
}
const messageWithUser = buildMessageWithUser(message, user, attachmentRows);
// Broadcast via WebSocket
connectionManager.sendToServer(serverId, {
type: 'message_created',
message: messageWithUser,
});
return reply.code(201).send(messageWithUser);
});
// PATCH /api/messages/:id - Edit a message (author only)
app.patch<{ Params: { id: string }; Body: UpdateMessageRequest }>('/api/messages/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content } = request.body;
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return reply.code(400).send({ error: 'Content is required', statusCode: 400 });
}
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get();
if (!message) {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
if (message.userId !== request.userId) {
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
}
const now = Date.now();
db.update(schema.messages)
.set({ content: content.trim(), editedAt: now })
.where(eq(schema.messages.id, id))
.run();
const updatedMessage = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get();
if (!updatedMessage) {
return reply.code(500).send({ error: 'Failed to update message', statusCode: 500 });
}
const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const attachmentRows = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.messageId, id))
.all();
const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows);
// Broadcast edit
const serverId = getChannelServerId(message.channelId);
if (serverId) {
connectionManager.sendToServer(serverId, {
type: 'message_updated',
message: messageWithUser,
});
}
return reply.code(200).send(messageWithUser);
});
// DELETE /api/messages/:id - Delete a message (author or admin)
app.delete<{ Params: { id: string } }>('/api/messages/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get();
if (!message) {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
const serverId = getChannelServerId(message.channelId);
if (!serverId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const isAuthor = message.userId === request.userId;
const isAdminUser = isAdmin(serverId, request.userId);
if (!isAuthor && !isAdminUser) {
return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
}
// Delete attachments then message
db.delete(schema.attachments).where(eq(schema.attachments.messageId, id)).run();
db.delete(schema.messages).where(eq(schema.messages.id, id)).run();
// Broadcast deletion
connectionManager.sendToServer(serverId, {
type: 'message_deleted',
messageId: id,
channelId: message.channelId,
});
return reply.code(200).send({ success: true });
});
}
+528
View File
@@ -0,0 +1,528 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isOwner, isAdmin } from '../utils/permissions.js';
import crypto from 'crypto';
import type {
CreateServerRequest,
UpdateServerRequest,
JoinServerRequest,
UpdateMemberRequest,
User,
Server,
Channel,
MemberWithUser,
ServerWithChannelsAndMembers,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
function rowToServer(row: typeof schema.servers.$inferSelect): Server {
return {
id: row.id,
name: row.name,
icon: row.icon,
ownerId: row.ownerId,
inviteCode: row.inviteCode,
createdAt: row.createdAt,
};
}
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
return {
id: row.id,
serverId: row.serverId,
name: row.name,
type: row.type as Channel['type'],
topic: row.topic,
position: row.position ?? 0,
createdAt: row.createdAt,
};
}
function generateInviteCode(): string {
return crypto.randomBytes(4).toString('hex');
}
export async function serverRoutes(app: FastifyInstance): Promise<void> {
// POST /api/servers - Create a new server
app.post<{ Body: CreateServerRequest }>('/api/servers', {
preHandler: authenticate,
}, async (request, reply) => {
const { name, icon } = request.body;
if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Server name is required', statusCode: 400 });
}
const trimmedName = name.trim();
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Server name must be between 1 and 100 characters', statusCode: 400 });
}
const db = getDb();
const serverId = generateSnowflake();
const channelId = generateSnowflake();
const now = Date.now();
const inviteCode = generateInviteCode();
// Create the server
db.insert(schema.servers).values({
id: serverId,
name: trimmedName,
icon: icon ?? null,
ownerId: request.userId,
inviteCode,
createdAt: now,
}).run();
// Add owner as member with 'owner' role
db.insert(schema.serverMembers).values({
serverId,
userId: request.userId,
role: 'owner',
joinedAt: now,
}).run();
// Create default #general text channel
db.insert(schema.channels).values({
id: channelId,
serverId,
name: 'general',
type: 'text',
position: 0,
createdAt: now,
}).run();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
if (!server) {
return reply.code(500).send({ error: 'Failed to create server', statusCode: 500 });
}
return reply.code(201).send(rowToServer(server));
});
// GET /api/servers - List user's servers
app.get('/api/servers', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
const memberships = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.userId, request.userId))
.all();
if (memberships.length === 0) {
return reply.code(200).send([]);
}
const serverIds = memberships.map(m => m.serverId);
const servers = db.select()
.from(schema.servers)
.where(inArray(schema.servers.id, serverIds))
.all();
return reply.code(200).send(servers.map(rowToServer));
});
// GET /api/servers/:id - Get server detail with channels and members
app.get<{ Params: { id: string } }>('/api/servers/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const channels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const members: MemberWithUser[] = memberRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return {
serverId: m.serverId,
userId: m.userId,
role: (m.role ?? 'member') as MemberWithUser['role'],
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(user),
};
})
.filter((m): m is MemberWithUser => m !== null);
const result: ServerWithChannelsAndMembers = {
...rowToServer(server),
channels: channels.map(rowToChannel),
members,
};
return reply.code(200).send(result);
});
// PATCH /api/servers/:id - Update server (owner only)
app.patch<{ Params: { id: string }; Body: UpdateServerRequest }>('/api/servers/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, icon } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can update the server', statusCode: 403 });
}
const updates: Partial<typeof schema.servers.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim();
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Server name must be between 1 and 100 characters', statusCode: 400 });
}
updates.name = trimmedName;
}
if (icon !== undefined) {
updates.icon = icon;
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.servers).set(updates).where(eq(schema.servers.id, id)).run();
const updated = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update server', statusCode: 500 });
}
return reply.code(200).send(rowToServer(updated));
});
// DELETE /api/servers/:id - Delete server (owner only)
app.delete<{ Params: { id: string } }>('/api/servers/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can delete the server', statusCode: 403 });
}
// Delete all channels (messages cascade), members, then server
db.delete(schema.channels).where(eq(schema.channels.serverId, id)).run();
db.delete(schema.serverMembers).where(eq(schema.serverMembers.serverId, id)).run();
db.delete(schema.servers).where(eq(schema.servers.id, id)).run();
return reply.code(200).send({ success: true });
});
// POST /api/servers/:id/invite - Generate invite code (admin+)
app.post<{ Params: { id: string } }>('/api/servers/:id/invite', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Only admins can generate invite codes', statusCode: 403 });
}
const inviteCode = generateInviteCode();
db.update(schema.servers).set({ inviteCode }).where(eq(schema.servers.id, id)).run();
return reply.code(200).send({ inviteCode });
});
// POST /api/servers/:id/join - Join server by invite code
app.post<{ Params: { id: string }; Body: JoinServerRequest }>('/api/servers/:id/join', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { inviteCode } = request.body;
if (!inviteCode || typeof inviteCode !== 'string') {
return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 });
}
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (server.inviteCode !== inviteCode) {
return reply.code(400).send({ error: 'Invalid invite code', statusCode: 400 });
}
if (isMember(id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 });
}
const now = Date.now();
db.insert(schema.serverMembers).values({
serverId: id,
userId: request.userId,
role: 'member',
joinedAt: now,
}).run();
return reply.code(200).send(rowToServer(server));
});
// POST /api/servers/join - Join server by invite code (no server ID needed)
app.post<{ Body: JoinServerRequest }>('/api/servers/join', {
preHandler: authenticate,
}, async (request, reply) => {
const { inviteCode } = request.body;
if (!inviteCode || typeof inviteCode !== 'string') {
return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 });
}
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.inviteCode, inviteCode)).get();
if (!server) {
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
}
if (isMember(server.id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 });
}
const now = Date.now();
db.insert(schema.serverMembers).values({
serverId: server.id,
userId: request.userId,
role: 'member',
joinedAt: now,
}).run();
return reply.code(200).send(rowToServer(server));
});
// GET /api/servers/:id/members - List server members
app.get<{ Params: { id: string } }>('/api/servers/:id/members', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
}
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const members: MemberWithUser[] = memberRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return {
serverId: m.serverId,
userId: m.userId,
role: (m.role ?? 'member') as MemberWithUser['role'],
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(user),
};
})
.filter((m): m is MemberWithUser => m !== null);
return reply.code(200).send(members);
});
// PATCH /api/servers/:id/members/:uid - Update member role (owner only)
app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/servers/:id/members/:uid', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
const { role } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
if (!isOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can change member roles', statusCode: 403 });
}
if (uid === request.userId) {
return reply.code(400).send({ error: 'You cannot change your own role', statusCode: 400 });
}
if (!role || !['admin', 'member'].includes(role)) {
return reply.code(400).send({ error: 'Role must be "admin" or "member"', statusCode: 400 });
}
const member = db.select()
.from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.get();
if (!member) {
return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
}
db.update(schema.serverMembers)
.set({ role })
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.run();
const updatedMember = db.select()
.from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.get();
if (!updatedMember) {
return reply.code(500).send({ error: 'Failed to update member', statusCode: 500 });
}
const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const result: MemberWithUser = {
serverId: updatedMember.serverId,
userId: updatedMember.userId,
role: (updatedMember.role ?? 'member') as MemberWithUser['role'],
nickname: updatedMember.nickname,
joinedAt: updatedMember.joinedAt,
user: sanitizeUser(user),
};
return reply.code(200).send(result);
});
// DELETE /api/servers/:id/members/:uid - Kick member (owner) or leave (self)
app.delete<{ Params: { id: string; uid: string } }>('/api/servers/:id/members/:uid', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
}
const isSelf = uid === request.userId;
const isServerOwnerUser = isOwner(id, request.userId);
if (!isSelf && !isServerOwnerUser) {
return reply.code(403).send({ error: 'Only the server owner can kick members', statusCode: 403 });
}
// Owner cannot leave their own server - they must delete it
if (isSelf && isServerOwnerUser) {
return reply.code(400).send({ error: 'Server owner cannot leave. Transfer ownership or delete the server.', statusCode: 400 });
}
const member = db.select()
.from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.get();
if (!member) {
return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
}
// Cannot kick the owner
if (member.role === 'owner') {
return reply.code(400).send({ error: 'Cannot remove the server owner', statusCode: 400 });
}
db.delete(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
))
.run();
return reply.code(200).send({ success: true });
});
}
+106
View File
@@ -0,0 +1,106 @@
import type { FastifyInstance } from 'fastify';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { config } from '../config.js';
import { getDb, schema } from '../db/index.js';
import { eq } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
import { pipeline } from 'stream/promises';
import type { Attachment } from '@opencord/shared';
export async function uploadRoutes(app: FastifyInstance): Promise<void> {
// Ensure upload directory exists
if (!fs.existsSync(config.uploadDir)) {
fs.mkdirSync(config.uploadDir, { recursive: true });
}
// POST /api/uploads - Upload a file
app.post('/api/uploads', {
preHandler: authenticate,
}, async (request, reply) => {
const data = await request.file();
if (!data) {
return reply.code(400).send({ error: 'No file provided', statusCode: 400 });
}
const originalName = data.filename;
const mimetype = data.mimetype;
// Generate unique filename
const id = generateSnowflake();
const ext = path.extname(originalName);
const filename = `${id}${ext}`;
const filepath = path.join(config.uploadDir, filename);
// Save file to disk
const writeStream = fs.createWriteStream(filepath);
await pipeline(data.file, writeStream);
// Get file size
const stats = fs.statSync(filepath);
const size = stats.size;
// Check size limit
if (size > config.maxUploadSize) {
fs.unlinkSync(filepath);
return reply.code(413).send({ error: 'File too large', statusCode: 413 });
}
const now = Date.now();
const db = getDb();
// Save attachment record
db.insert(schema.attachments).values({
id,
filename,
originalName,
mimetype,
size,
createdAt: now,
}).run();
const attachment: Attachment = {
id,
messageId: '',
filename,
originalName,
mimetype,
size,
createdAt: now,
};
return reply.code(201).send(attachment);
});
// GET /api/uploads/:filename - Serve uploaded file
app.get<{ Params: { filename: string } }>('/api/uploads/:filename', async (request, reply) => {
const { filename } = request.params;
// Prevent directory traversal
const safeName = path.basename(filename);
const filepath = path.join(config.uploadDir, safeName);
if (!fs.existsSync(filepath)) {
return reply.code(404).send({ error: 'File not found', statusCode: 404 });
}
// Get mimetype from DB or guess from extension
const db = getDb();
const attachment = db.select().from(schema.attachments).where(eq(schema.attachments.filename, safeName)).get();
const mimetype = attachment?.mimetype ?? 'application/octet-stream';
const originalName = attachment?.originalName ?? safeName;
// Set caching headers
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
reply.header('Content-Type', mimetype);
// For non-image files, set Content-Disposition to download
if (!mimetype.startsWith('image/') && !mimetype.startsWith('video/') && !mimetype.startsWith('audio/')) {
reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`);
}
const stream = fs.createReadStream(filepath);
return reply.send(stream);
});
}
+90
View File
@@ -0,0 +1,90 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import type { User, UpdateUserRequest } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export async function userRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!user) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
return reply.code(200).send(sanitizeUser(user));
});
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const { displayName, avatar, customStatus } = request.body;
const db = getDb();
const updateData: Record<string, string | null | undefined> = {};
if (displayName !== undefined) {
if (displayName !== null && typeof displayName === 'string') {
const trimmed = displayName.trim();
if (trimmed.length > 32) {
return reply.code(400).send({ error: 'Display name must be 32 characters or less', statusCode: 400 });
}
updateData.displayName = trimmed || null;
} else {
updateData.displayName = null;
}
}
if (avatar !== undefined) {
updateData.avatar = avatar;
}
if (customStatus !== undefined) {
if (customStatus !== null && typeof customStatus === 'string') {
const trimmed = customStatus.trim();
if (trimmed.length > 128) {
return reply.code(400).send({ error: 'Custom status must be 128 characters or less', statusCode: 400 });
}
updateData.customStatus = trimmed || null;
} else {
updateData.customStatus = null;
}
}
if (Object.keys(updateData).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run();
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!updatedUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
return reply.code(200).send(sanitizeUser(updatedUser));
});
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get();
if (!user) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
return reply.code(200).send(sanitizeUser(user));
});
}
+58
View File
@@ -0,0 +1,58 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export interface JwtPayload {
userId: string;
username: string;
}
export function signJwt(payload: JwtPayload): string {
const options: jwt.SignOptions = {
expiresIn: config.jwtExpiresIn as unknown as jwt.SignOptions['expiresIn'],
};
return jwt.sign(payload, config.jwtSecret, options);
}
export function verifyJwt(token: string): JwtPayload {
const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;
return decoded;
}
export async function authenticate(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
reply.code(401).send({ error: 'Missing or invalid authorization header', statusCode: 401 });
return;
}
const token = authHeader.slice(7);
try {
const payload = verifyJwt(token);
(request as FastifyRequest & { userId: string; username: string }).userId = payload.userId;
(request as FastifyRequest & { userId: string; username: string }).username = payload.username;
} catch {
reply.code(401).send({ error: 'Invalid or expired token', statusCode: 401 });
}
}
declare module 'fastify' {
interface FastifyRequest {
userId: string;
username: string;
}
}
+55
View File
@@ -0,0 +1,55 @@
import { eq, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import type { MemberRole } from '@opencord/shared';
export function getMember(serverId: string, userId: string) {
const db = getDb();
return db.select().from(schema.serverMembers)
.where(and(
eq(schema.serverMembers.serverId, serverId),
eq(schema.serverMembers.userId, userId),
))
.get();
}
export function isMember(serverId: string, userId: string): boolean {
return getMember(serverId, userId) !== undefined;
}
export function getMemberRole(serverId: string, userId: string): MemberRole | null {
const member = getMember(serverId, userId);
return member ? (member.role as MemberRole) : null;
}
export function isOwner(serverId: string, userId: string): boolean {
const role = getMemberRole(serverId, userId);
return role === 'owner';
}
export function isAdmin(serverId: string, userId: string): boolean {
const role = getMemberRole(serverId, userId);
return role === 'owner' || role === 'admin';
}
export function isServerOwner(serverId: string, userId: string): boolean {
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
return server?.ownerId === userId;
}
export function getChannelServerId(channelId: string): string | null {
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
return channel?.serverId ?? null;
}
export function isDmMember(dmChannelId: string, userId: string): boolean {
const db = getDb();
const member = db.select().from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, dmChannelId),
eq(schema.dmMembers.userId, userId),
))
.get();
return member !== undefined;
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Discord-style Snowflake ID Generator
*
* Structure (64-bit):
* - 42 bits: milliseconds since custom epoch (Jan 1, 2024)
* - 10 bits: worker/process ID
* - 12 bits: sequence number (per-millisecond)
*
* This gives us:
* - ~139 years of IDs from epoch
* - 1024 workers
* - 4096 IDs per millisecond per worker
*/
const EPOCH = 1704067200000n; // Jan 1, 2024 00:00:00 UTC
const WORKER_ID = BigInt(process.pid % 1024);
let sequence = 0n;
let lastTimestamp = -1n;
export function generateSnowflake(): string {
let timestamp = BigInt(Date.now());
if (timestamp === lastTimestamp) {
sequence = (sequence + 1n) & 0xFFFn; // 12-bit mask
if (sequence === 0n) {
// Sequence exhausted, wait for next millisecond
while (timestamp <= lastTimestamp) {
timestamp = BigInt(Date.now());
}
}
} else {
sequence = 0n;
}
lastTimestamp = timestamp;
const id =
((timestamp - EPOCH) << 22n) |
(WORKER_ID << 12n) |
sequence;
return id.toString();
}
export function snowflakeToTimestamp(snowflake: string): number {
const id = BigInt(snowflake);
const timestamp = (id >> 22n) + EPOCH;
return Number(timestamp);
}
+409
View File
@@ -0,0 +1,409 @@
import { eq, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js';
import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js';
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
function getMessageWithUser(messageId: string): MessageWithUser | null {
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) return null;
const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get();
if (!user) return null;
const attachmentRows = db.select()
.from(schema.attachments)
.where(eq(schema.attachments.messageId, messageId))
.all();
const attachments: Attachment[] = attachmentRows.map(a => ({
id: a.id,
messageId: a.messageId ?? messageId,
filename: a.filename,
originalName: a.originalName,
mimetype: a.mimetype,
size: a.size,
createdAt: a.createdAt,
}));
return {
id: message.id,
channelId: message.channelId,
userId: message.userId,
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
user: sanitizeUser(user),
attachments,
};
}
// Typing timeout tracking
const typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
export function handleClientEvent(
event: Record<string, unknown>,
userId: string,
username: string,
): void {
const type = event.type as string;
switch (type) {
case 'message_create':
handleMessageCreate(event, userId);
break;
case 'message_edit':
handleMessageEdit(event, userId);
break;
case 'message_delete':
handleMessageDelete(event, userId);
break;
case 'typing_start':
handleTypingStart(event, userId, username);
break;
case 'presence_update':
handlePresenceUpdate(event, userId);
break;
case 'voice_join':
handleVoiceJoin(event, userId);
break;
case 'voice_leave':
handleVoiceLeave(userId);
break;
case 'dm_message_create':
handleDmMessageCreate(event, userId);
break;
default:
connectionManager.sendToUser(userId, {
type: 'error',
message: `Unknown event type: ${type}`,
});
}
}
function handleMessageCreate(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string;
const content = event.content as string;
if (!channelId || typeof channelId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
return;
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
return;
}
const serverId = getChannelServerId(channelId);
if (!serverId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
return;
}
if (!isMember(serverId, userId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' });
return;
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
db.insert(schema.messages).values({
id: messageId,
channelId,
userId,
content: content.trim(),
createdAt: now,
}).run();
const messageWithUser = getMessageWithUser(messageId);
if (messageWithUser) {
// Broadcast to all server members (including sender)
connectionManager.sendToServer(serverId, {
type: 'message_created',
message: messageWithUser,
});
}
}
function handleMessageEdit(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
const content = event.content as string;
if (!messageId || typeof messageId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
return;
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
return;
}
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
return;
}
if (message.userId !== userId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'You can only edit your own messages' });
return;
}
const now = Date.now();
db.update(schema.messages)
.set({ content: content.trim(), editedAt: now })
.where(eq(schema.messages.id, messageId))
.run();
const serverId = getChannelServerId(message.channelId);
if (!serverId) return;
const updatedMessage = getMessageWithUser(messageId);
if (updatedMessage) {
connectionManager.sendToServer(serverId, {
type: 'message_updated',
message: updatedMessage,
});
}
}
function handleMessageDelete(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
if (!messageId || typeof messageId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
return;
}
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
return;
}
const serverId = getChannelServerId(message.channelId);
if (!serverId) return;
// Allow author or admin to delete
const isAuthor = message.userId === userId;
const memberRow = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverId))
.all()
.find(m => m.userId === userId);
const isAdminRole = memberRow?.role === 'admin' || memberRow?.role === 'owner';
if (!isAuthor && !isAdminRole) {
connectionManager.sendToUser(userId, { type: 'error', message: 'You cannot delete this message' });
return;
}
// Delete attachments then message
db.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run();
db.delete(schema.messages).where(eq(schema.messages.id, messageId)).run();
connectionManager.sendToServer(serverId, {
type: 'message_deleted',
messageId,
channelId: message.channelId,
});
}
function handleTypingStart(event: Record<string, unknown>, userId: string, username: string): void {
const channelId = event.channelId as string;
if (!channelId || typeof channelId !== 'string') return;
const serverId = getChannelServerId(channelId);
if (!serverId) return;
if (!isMember(serverId, userId)) return;
// Clear previous typing timeout for this user+channel
const key = `${userId}:${channelId}`;
const existing = typingTimeouts.get(key);
if (existing) {
clearTimeout(existing);
}
// Broadcast typing event (exclude sender)
connectionManager.sendToServer(serverId, {
type: 'typing',
channelId,
userId,
username,
}, userId);
// Auto-expire typing after 5 seconds
const timeout = setTimeout(() => {
typingTimeouts.delete(key);
}, 5000);
typingTimeouts.set(key, timeout);
}
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
const status = event.status as string;
if (!status || !['online', 'idle', 'dnd'].includes(status)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Status must be "online", "idle", or "dnd"' });
return;
}
const db = getDb();
db.update(schema.users).set({ status }).where(eq(schema.users.id, userId)).run();
// Broadcast to all servers user is in
const userServers = connectionManager.getUserServers(userId);
for (const serverId of userServers) {
connectionManager.sendToServer(serverId, {
type: 'presence_update',
userId,
status,
}, userId);
}
// Also send to self (other tabs)
connectionManager.sendToUser(userId, {
type: 'presence_update',
userId,
status,
});
}
function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string;
if (!channelId || typeof channelId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
return;
}
const serverId = getChannelServerId(channelId);
if (!serverId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
return;
}
if (!isMember(serverId, userId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' });
return;
}
// Leave any current voice channel
const previousChannel = connectionManager.leaveAllVoice(userId);
if (previousChannel) {
const prevServerId = getChannelServerId(previousChannel);
if (prevServerId) {
connectionManager.sendToServer(prevServerId, {
type: 'voice_state_update',
channelId: previousChannel,
userId,
action: 'leave',
});
}
}
// Join new voice channel
connectionManager.joinVoice(channelId, userId);
// Broadcast to server
connectionManager.sendToServer(serverId, {
type: 'voice_state_update',
channelId,
userId,
action: 'join',
});
}
function handleVoiceLeave(userId: string): void {
const channelId = connectionManager.leaveAllVoice(userId);
if (channelId) {
const serverId = getChannelServerId(channelId);
if (serverId) {
connectionManager.sendToServer(serverId, {
type: 'voice_state_update',
channelId,
userId,
action: 'leave',
});
}
}
}
function handleDmMessageCreate(event: Record<string, unknown>, userId: string): void {
const dmChannelId = event.dmChannelId as string;
const content = event.content as string;
if (!dmChannelId || typeof dmChannelId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
return;
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
return;
}
if (!isDmMember(dmChannelId, userId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this DM channel' });
return;
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
db.insert(schema.dmMessages).values({
id: messageId,
dmChannelId,
userId,
content: content.trim(),
createdAt: now,
}).run();
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!user) return;
const dmMessage: DmMessageWithUser = {
id: messageId,
dmChannelId,
userId,
content: content.trim(),
createdAt: now,
user: sanitizeUser(user),
};
// Send to all DM members
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_created',
message: dmMessage,
});
}
}
+440
View File
@@ -0,0 +1,440 @@
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import { verifyJwt } from '../utils/auth.js';
import { getDb, schema } from '../db/index.js';
import { eq, inArray } from 'drizzle-orm';
import { handleClientEvent } from './events.js';
import type {
User,
ServerWithChannelsAndMembers,
MemberWithUser,
Channel,
DmChannel,
ServerEvent,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export interface AuthenticatedSocket {
ws: WebSocket;
userId: string;
username: string;
}
class ConnectionManager {
// userId → Set of WebSocket connections (multiple tabs)
private connections: Map<string, Set<WebSocket>> = new Map();
// userId → Set of server IDs the user belongs to
private userServers: Map<string, Set<string>> = new Map();
// channelId → Set of userIds in voice channel
private voiceStates: Map<string, Set<string>> = new Map();
// ws → userId (reverse lookup)
private wsToUser: Map<WebSocket, string> = new Map();
addConnection(userId: string, ws: WebSocket): void {
if (!this.connections.has(userId)) {
this.connections.set(userId, new Set());
}
this.connections.get(userId)!.add(ws);
this.wsToUser.set(ws, userId);
}
removeConnection(ws: WebSocket): string | undefined {
const userId = this.wsToUser.get(ws);
if (!userId) return undefined;
this.wsToUser.delete(ws);
const userConnections = this.connections.get(userId);
if (userConnections) {
userConnections.delete(ws);
if (userConnections.size === 0) {
this.connections.delete(userId);
}
}
return userId;
}
getUserConnections(userId: string): Set<WebSocket> {
return this.connections.get(userId) ?? new Set();
}
isUserOnline(userId: string): boolean {
const conns = this.connections.get(userId);
return conns !== undefined && conns.size > 0;
}
setUserServers(userId: string, serverIds: string[]): void {
this.userServers.set(userId, new Set(serverIds));
}
addUserServer(userId: string, serverId: string): void {
if (!this.userServers.has(userId)) {
this.userServers.set(userId, new Set());
}
this.userServers.get(userId)!.add(serverId);
}
getUserServers(userId: string): Set<string> {
return this.userServers.get(userId) ?? new Set();
}
// Voice state management
joinVoice(channelId: string, userId: string): void {
// Leave any existing voice channel first
this.leaveAllVoice(userId);
if (!this.voiceStates.has(channelId)) {
this.voiceStates.set(channelId, new Set());
}
this.voiceStates.get(channelId)!.add(userId);
}
leaveVoice(channelId: string, userId: string): void {
const users = this.voiceStates.get(channelId);
if (users) {
users.delete(userId);
if (users.size === 0) {
this.voiceStates.delete(channelId);
}
}
}
leaveAllVoice(userId: string): string | null {
for (const [channelId, users] of this.voiceStates) {
if (users.has(userId)) {
users.delete(userId);
if (users.size === 0) {
this.voiceStates.delete(channelId);
}
return channelId;
}
}
return null;
}
getVoiceUsers(channelId: string): Set<string> {
return this.voiceStates.get(channelId) ?? new Set();
}
getUserVoiceChannel(userId: string): string | null {
for (const [channelId, users] of this.voiceStates) {
if (users.has(userId)) {
return channelId;
}
}
return null;
}
// Send to a specific user (all their connections)
sendToUser(userId: string, event: ServerEvent): void {
const connections = this.getUserConnections(userId);
const message = JSON.stringify(event);
for (const ws of connections) {
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(message);
}
}
}
// Send to all members of a server
sendToServer(serverId: string, event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event);
for (const [userId, serverIds] of this.userServers) {
if (serverIds.has(serverId) && userId !== excludeUserId) {
const connections = this.getUserConnections(userId);
for (const ws of connections) {
if (ws.readyState === 1) {
ws.send(message);
}
}
}
}
}
// Send to all connections of all online users (for global events)
sendToAll(event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event);
for (const [userId, connections] of this.connections) {
if (userId !== excludeUserId) {
for (const ws of connections) {
if (ws.readyState === 1) {
ws.send(message);
}
}
}
}
}
getAllOnlineUserIds(): string[] {
return Array.from(this.connections.keys());
}
}
export const connectionManager = new ConnectionManager();
function buildReadyPayload(userId: string): {
user: User;
servers: ServerWithChannelsAndMembers[];
dmChannels: DmChannel[];
} {
const db = getDb();
// Get user
const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!userRow) {
throw new Error('User not found');
}
const user = sanitizeUser(userRow);
// Get user's server memberships
const memberships = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.userId, userId))
.all();
const serverIds = memberships.map(m => m.serverId);
const servers: ServerWithChannelsAndMembers[] = [];
if (serverIds.length > 0) {
const serverRows = db.select()
.from(schema.servers)
.where(inArray(schema.servers.id, serverIds))
.all();
for (const serverRow of serverRows) {
const channels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, serverRow.id))
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverRow.id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
const users = memberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const members: MemberWithUser[] = memberRows
.map(m => {
const u = userMap.get(m.userId);
if (!u) return null;
return {
serverId: m.serverId,
userId: m.userId,
role: (m.role ?? 'member') as MemberWithUser['role'],
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(u),
};
})
.filter((m): m is MemberWithUser => m !== null);
servers.push({
id: serverRow.id,
name: serverRow.name,
icon: serverRow.icon,
ownerId: serverRow.ownerId,
inviteCode: serverRow.inviteCode,
createdAt: serverRow.createdAt,
channels: channels.map(ch => ({
id: ch.id,
serverId: ch.serverId,
name: ch.name,
type: ch.type as Channel['type'],
topic: ch.topic,
position: ch.position ?? 0,
createdAt: ch.createdAt,
})),
members,
});
}
}
// Store user's server IDs for broadcasting
connectionManager.setUserServers(userId, serverIds);
// Get DM channels
const dmMemberships = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, userId))
.all();
const dmChannels: DmChannel[] = [];
for (const dm of dmMemberships) {
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, dm.dmChannelId))
.get();
if (!dmChannel) continue;
const dmMemberRows = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dm.dmChannelId))
.all();
const dmMemberUserIds = dmMemberRows.map(m => m.userId);
const dmUsers = dmMemberUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, dmMemberUserIds)).all()
: [];
// Get last message
const lastMessage = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, dm.dmChannelId))
.orderBy(schema.dmMessages.createdAt)
.all();
const last = lastMessage.length > 0 ? lastMessage[lastMessage.length - 1] : null;
dmChannels.push({
id: dmChannel.id,
createdAt: dmChannel.createdAt,
members: dmUsers.map(sanitizeUser),
lastMessage: last ? {
id: last.id,
dmChannelId: last.dmChannelId,
userId: last.userId,
content: last.content,
createdAt: last.createdAt,
} : null,
});
}
return { user, servers, dmChannels };
}
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
app.get('/ws', { websocket: true }, (socket, request) => {
const ws = socket as unknown as WebSocket;
let authenticated = false;
let userId: string | undefined;
let username: string | undefined;
// Set auth timeout - must authenticate within 10 seconds
const authTimeout = setTimeout(() => {
if (!authenticated) {
ws.send(JSON.stringify({ type: 'error', message: 'Authentication timeout' }));
ws.close();
}
}, 10000);
ws.on('message', (data: Buffer | string) => {
let parsed: Record<string, unknown>;
try {
const raw = typeof data === 'string' ? data : data.toString('utf-8');
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
return;
}
if (!authenticated) {
// First message must be auth
if (parsed.type !== 'auth' || typeof parsed.token !== 'string') {
ws.send(JSON.stringify({ type: 'error', message: 'First message must be auth' }));
ws.close();
return;
}
try {
const payload = verifyJwt(parsed.token);
userId = payload.userId;
username = payload.username;
authenticated = true;
clearTimeout(authTimeout);
// Update user status to online
const db = getDb();
db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, userId)).run();
// Add connection
connectionManager.addConnection(userId, ws);
// Build and send ready payload
const readyData = buildReadyPayload(userId);
ws.send(JSON.stringify({
type: 'ready',
...readyData,
}));
// Broadcast presence update to all servers
const userServers = connectionManager.getUserServers(userId);
for (const serverId of userServers) {
connectionManager.sendToServer(serverId, {
type: 'presence_update',
userId,
status: 'online',
}, userId);
}
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
ws.close();
}
return;
}
// Handle authenticated events
if (userId && username) {
handleClientEvent(parsed, userId, username);
}
});
ws.on('close', () => {
clearTimeout(authTimeout);
if (userId) {
const removedUserId = connectionManager.removeConnection(ws);
// If user has no more connections, set offline
if (removedUserId && !connectionManager.isUserOnline(removedUserId)) {
const db = getDb();
db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, removedUserId)).run();
// Leave voice if in one
const leftChannel = connectionManager.leaveAllVoice(removedUserId);
if (leftChannel) {
// Get channel's server to broadcast
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get();
if (channel) {
connectionManager.sendToServer(channel.serverId, {
type: 'voice_state_update',
channelId: leftChannel,
userId: removedUserId,
action: 'leave',
});
}
}
// Broadcast offline to all servers
const userServers = connectionManager.getUserServers(removedUserId);
for (const serverId of userServers) {
connectionManager.sendToServer(serverId, {
type: 'presence_update',
userId: removedUserId,
status: 'offline',
});
}
}
}
});
ws.on('error', () => {
clearTimeout(authTimeout);
});
});
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../shared" }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@opencord/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/types.ts",
"types": "./src/types.ts",
"exports": {
".": "./src/types.ts"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.4.0"
}
}
+229
View File
@@ -0,0 +1,229 @@
// ─── User Types ─────────────────────────────────────────────────────────────
export interface User {
id: string;
username: string;
displayName: string | null;
avatar: string | null;
status: UserStatus;
customStatus: string | null;
createdAt: number;
}
export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline';
export interface UserWithPassword extends User {
passwordHash: string;
}
// ─── Server (Guild) Types ───────────────────────────────────────────────────
export interface Server {
id: string;
name: string;
icon: string | null;
ownerId: string;
inviteCode: string | null;
createdAt: number;
}
export interface ServerWithChannelsAndMembers extends Server {
channels: Channel[];
members: MemberWithUser[];
}
// ─── Member Types ───────────────────────────────────────────────────────────
export type MemberRole = 'owner' | 'admin' | 'member';
export interface Member {
serverId: string;
userId: string;
role: MemberRole;
nickname: string | null;
joinedAt: number;
}
export interface MemberWithUser extends Member {
user: User;
}
// ─── Channel Types ──────────────────────────────────────────────────────────
export type ChannelType = 'text' | 'voice' | 'video';
export interface Channel {
id: string;
serverId: string;
name: string;
type: ChannelType;
topic: string | null;
position: number;
createdAt: number;
}
// ─── Message Types ──────────────────────────────────────────────────────────
export interface Message {
id: string;
channelId: string;
userId: string;
content: string | null;
editedAt: number | null;
createdAt: number;
}
export interface MessageWithUser extends Message {
user: User;
attachments: Attachment[];
}
// ─── Attachment Types ───────────────────────────────────────────────────────
export interface Attachment {
id: string;
messageId: string;
filename: string;
originalName: string;
mimetype: string;
size: number;
createdAt: number;
}
// ─── DM Types ───────────────────────────────────────────────────────────────
export interface DmChannel {
id: string;
createdAt: number;
members: User[];
lastMessage?: DmMessage | null;
}
export interface DmMessage {
id: string;
dmChannelId: string;
userId: string;
content: string | null;
createdAt: number;
}
export interface DmMessageWithUser extends DmMessage {
user: User;
}
// ─── WebSocket Event Types ──────────────────────────────────────────────────
// Client → Server Events
export type ClientEvent =
| { 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 Events
export type ServerEvent =
| { 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 }
| { type: 'error'; message: string };
// ─── API Request/Response Types ─────────────────────────────────────────────
export interface RegisterRequest {
username: string;
password: string;
displayName?: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface CreateServerRequest {
name: string;
icon?: string;
}
export interface CreateChannelRequest {
name: string;
type: ChannelType;
topic?: string;
}
export interface UpdateChannelRequest {
name?: string;
topic?: string;
position?: number;
}
export interface UpdateServerRequest {
name?: string;
icon?: string;
}
export interface UpdateUserRequest {
displayName?: string;
avatar?: string;
customStatus?: string;
}
export interface UpdateMemberRequest {
role: MemberRole;
}
export interface CreateMessageRequest {
content: string;
attachments?: string[];
}
export interface UpdateMessageRequest {
content: string;
}
export interface JoinServerRequest {
inviteCode: string;
}
export interface LiveKitTokenRequest {
channelId: string;
}
export interface LiveKitTokenResponse {
token: string;
}
export interface CreateDmRequest {
userId: string;
}
export interface CreateDmMessageRequest {
content: string;
}
export interface PaginatedQuery {
before?: string;
limit?: number;
}
export interface ApiError {
error: string;
statusCode: number;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*"]
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Opencord</title>
</head>
<body class="bg-discord-bg-primary text-discord-text-primary">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@opencord/web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@opencord/shared": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"react-markdown": "^9.0.1",
"zustand": "^5.0.2",
"livekit-client": "^2.9.0",
"@livekit/components-react": "^2.7.4"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+21
View File
@@ -0,0 +1,21 @@
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { Routes, Route, Navigate } from 'react-router-dom';
import { LoginPage } from './components/auth/LoginPage';
import { RegisterPage } from './components/auth/RegisterPage';
import { AppLayout } from './components/layout/AppLayout';
import { useAuthStore } from './stores/authStore';
function ProtectedRoute({ children }) {
const token = useAuthStore((s) => s.token);
if (!token)
return _jsx(Navigate, { to: "/login", replace: true });
return _jsx(_Fragment, { children: children });
}
function AuthRedirect({ children }) {
const token = useAuthStore((s) => s.token);
if (token)
return _jsx(Navigate, { to: "/channels/@me", replace: true });
return _jsx(_Fragment, { children: children });
}
export function App() {
return (_jsxs(Routes, { children: [_jsx(Route, { path: "/login", element: _jsx(AuthRedirect, { children: _jsx(LoginPage, {}) }) }), _jsx(Route, { path: "/register", element: _jsx(AuthRedirect, { children: _jsx(RegisterPage, {}) }) }), _jsx(Route, { path: "/channels/:serverId/:channelId?", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) })] }));
}
+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { LoginPage } from './components/auth/LoginPage';
import { RegisterPage } from './components/auth/RegisterPage';
import { AppLayout } from './components/layout/AppLayout';
import { useAuthStore } from './stores/authStore';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function AuthRedirect({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
if (token) return <Navigate to="/channels/@me" replace />;
return <>{children}</>;
}
export function App() {
return (
<Routes>
<Route
path="/login"
element={
<AuthRedirect>
<LoginPage />
</AuthRedirect>
}
/>
<Route
path="/register"
element={
<AuthRedirect>
<RegisterPage />
</AuthRedirect>
}
/>
<Route
path="/channels/:serverId/:channelId?"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/channels/@me" replace />} />
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
</Routes>
);
}
+105
View File
@@ -0,0 +1,105 @@
const BASE_URL = '/api';
function getToken() {
return localStorage.getItem('opencord_token');
}
async function request(method, path, body, requireAuth = true) {
const headers = {};
if (body) {
headers['Content-Type'] = 'application/json';
}
if (requireAuth) {
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || `HTTP ${response.status}`);
}
return response.json();
}
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
const token = getToken();
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${BASE_URL}/uploads`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
throw new Error(error.error || `HTTP ${response.status}`);
}
return response.json();
}
export const api = {
auth: {
register: (data) => request('POST', '/auth/register', data, false),
login: (data) => request('POST', '/auth/login', data, false),
},
users: {
me: () => request('GET', '/users/@me'),
update: (data) => request('PATCH', '/users/@me', data),
get: (id) => request('GET', `/users/${id}`),
},
servers: {
list: () => request('GET', '/servers'),
get: (id) => request('GET', `/servers/${id}`),
create: (data) => request('POST', '/servers', data),
update: (id, data) => request('PATCH', `/servers/${id}`, data),
delete: (id) => request('DELETE', `/servers/${id}`),
invite: (id) => request('POST', `/servers/${id}/invite`),
join: (id, data) => request('POST', `/servers/${id}/join`, data),
members: (id) => request('GET', `/servers/${id}/members`),
updateMember: (serverId, userId, data) => request('PATCH', `/servers/${serverId}/members/${userId}`, data),
removeMember: (serverId, userId) => request('DELETE', `/servers/${serverId}/members/${userId}`),
},
channels: {
list: (serverId) => request('GET', `/servers/${serverId}/channels`),
create: (serverId, data) => request('POST', `/servers/${serverId}/channels`, data),
update: (id, data) => request('PATCH', `/channels/${id}`, data),
delete: (id) => request('DELETE', `/channels/${id}`),
messages: (id, before, limit = 50) => {
const params = new URLSearchParams();
if (before)
params.set('before', before);
params.set('limit', String(limit));
return request('GET', `/channels/${id}/messages?${params}`);
},
sendMessage: (channelId, data) => request('POST', `/channels/${channelId}/messages`, data),
},
messages: {
update: (id, data) => request('PATCH', `/messages/${id}`, data),
delete: (id) => request('DELETE', `/messages/${id}`),
},
uploads: {
upload: uploadFile,
url: (filename) => `${BASE_URL}/uploads/${filename}`,
},
dm: {
list: () => request('GET', '/dm'),
create: (data) => request('POST', '/dm', data),
messages: (id, before, limit = 50) => {
const params = new URLSearchParams();
if (before)
params.set('before', before);
params.set('limit', String(limit));
return request('GET', `/dm/${id}/messages?${params}`);
},
sendMessage: (id, data) => request('POST', `/dm/${id}/messages`, data),
},
livekit: {
token: (channelId) => request('POST', '/livekit/token', { channelId }),
},
};
+163
View File
@@ -0,0 +1,163 @@
import type {
AuthResponse,
RegisterRequest,
LoginRequest,
User,
Server,
ServerWithChannelsAndMembers,
Channel,
MessageWithUser,
MemberWithUser,
Attachment,
DmChannel,
DmMessageWithUser,
CreateServerRequest,
UpdateServerRequest,
CreateChannelRequest,
UpdateChannelRequest,
CreateMessageRequest,
UpdateMessageRequest,
UpdateUserRequest,
JoinServerRequest,
UpdateMemberRequest,
LiveKitTokenResponse,
CreateDmRequest,
CreateDmMessageRequest,
} from '@opencord/shared';
const BASE_URL = '/api';
function getToken(): string | null {
return localStorage.getItem('opencord_token');
}
async function request<T>(
method: string,
path: string,
body?: unknown,
requireAuth = true,
): Promise<T> {
const headers: Record<string, string> = {};
if (body) {
headers['Content-Type'] = 'application/json';
}
if (requireAuth) {
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error((error as { error: string }).error || `HTTP ${response.status}`);
}
return response.json() as Promise<T>;
}
async function uploadFile(file: File): Promise<Attachment> {
const formData = new FormData();
formData.append('file', file);
const token = getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${BASE_URL}/uploads`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
throw new Error((error as { error: string }).error || `HTTP ${response.status}`);
}
return response.json() as Promise<Attachment>;
}
export const api = {
auth: {
register: (data: RegisterRequest) =>
request<AuthResponse>('POST', '/auth/register', data, false),
login: (data: LoginRequest) =>
request<AuthResponse>('POST', '/auth/login', data, false),
},
users: {
me: () => request<User>('GET', '/users/@me'),
update: (data: UpdateUserRequest) => request<User>('PATCH', '/users/@me', data),
get: (id: string) => request<User>('GET', `/users/${id}`),
},
servers: {
list: () => request<Server[]>('GET', '/servers'),
get: (id: string) => request<ServerWithChannelsAndMembers>('GET', `/servers/${id}`),
create: (data: CreateServerRequest) => request<Server>('POST', '/servers', data),
update: (id: string, data: UpdateServerRequest) => request<Server>('PATCH', `/servers/${id}`, data),
delete: (id: string) => request<{ success: boolean }>('DELETE', `/servers/${id}`),
invite: (id: string) => request<{ inviteCode: string }>('POST', `/servers/${id}/invite`),
join: (id: string, data: JoinServerRequest) => request<Server>('POST', `/servers/${id}/join`, data),
members: (id: string) => request<MemberWithUser[]>('GET', `/servers/${id}/members`),
updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) =>
request<MemberWithUser>('PATCH', `/servers/${serverId}/members/${userId}`, data),
removeMember: (serverId: string, userId: string) =>
request<{ success: boolean }>('DELETE', `/servers/${serverId}/members/${userId}`),
},
channels: {
list: (serverId: string) => request<Channel[]>('GET', `/servers/${serverId}/channels`),
create: (serverId: string, data: CreateChannelRequest) =>
request<Channel>('POST', `/servers/${serverId}/channels`, data),
update: (id: string, data: UpdateChannelRequest) => request<Channel>('PATCH', `/channels/${id}`, data),
delete: (id: string) => request<{ success: boolean }>('DELETE', `/channels/${id}`),
messages: (id: string, before?: string, limit = 50) => {
const params = new URLSearchParams();
if (before) params.set('before', before);
params.set('limit', String(limit));
return request<MessageWithUser[]>('GET', `/channels/${id}/messages?${params}`);
},
sendMessage: (channelId: string, data: CreateMessageRequest) =>
request<MessageWithUser>('POST', `/channels/${channelId}/messages`, data),
},
messages: {
update: (id: string, data: UpdateMessageRequest) => request<MessageWithUser>('PATCH', `/messages/${id}`, data),
delete: (id: string) => request<{ success: boolean }>('DELETE', `/messages/${id}`),
},
uploads: {
upload: uploadFile,
url: (filename: string) => `${BASE_URL}/uploads/${filename}`,
},
dm: {
list: () => request<DmChannel[]>('GET', '/dm'),
create: (data: CreateDmRequest) => request<DmChannel>('POST', '/dm', data),
messages: (id: string, before?: string, limit = 50) => {
const params = new URLSearchParams();
if (before) params.set('before', before);
params.set('limit', String(limit));
return request<DmMessageWithUser[]>('GET', `/dm/${id}/messages?${params}`);
},
sendMessage: (id: string, data: CreateDmMessageRequest) =>
request<DmMessageWithUser>('POST', `/dm/${id}/messages`, data),
},
livekit: {
token: (channelId: string) =>
request<LiveKitTokenResponse>('POST', '/livekit/token', { channelId }),
},
};
@@ -0,0 +1,32 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const login = useAuthStore((s) => s.login);
const isLoading = useAuthStore((s) => s.isLoading);
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username.trim()) {
setError('Username is required');
return;
}
if (!password) {
setError('Password is required');
return;
}
try {
await login(username.trim(), password);
navigate('/channels/@me');
}
catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-discord-bg-tertiary", children: _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl", children: [_jsxs("div", { className: "text-center mb-6", children: [_jsx("h1", { className: "text-2xl font-bold text-discord-text-primary", children: "Welcome back!" }), _jsx("p", { className: "text-discord-text-muted mt-1", children: "We're so excited to see you again!" })] }), _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Username ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "text", value: username, onChange: (e) => setUsername(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoFocus: true, autoComplete: "username" })] }), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Password ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "current-password" })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: isLoading ? 'Logging in...' : 'Log In' }), _jsxs("p", { className: "mt-3 text-sm text-discord-text-muted", children: ["Need an account?", ' ', _jsx(Link, { to: "/register", className: "text-[#00aff4] hover:underline", children: "Register" })] })] })] }) }));
}
@@ -0,0 +1,94 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const login = useAuthStore((s) => s.login);
const isLoading = useAuthStore((s) => s.isLoading);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!username.trim()) {
setError('Username is required');
return;
}
if (!password) {
setError('Password is required');
return;
}
try {
await login(username.trim(), password);
navigate('/channels/@me');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-discord-bg-tertiary">
<div className="w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-discord-text-primary">Welcome back!</h1>
<p className="text-discord-text-muted mt-1">We're so excited to see you again!</p>
</div>
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
{error}
</div>
)}
<div className="mb-5">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Username <span className="text-discord-red">*</span>
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all"
autoFocus
autoComplete="username"
/>
</div>
<div className="mb-5">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Password <span className="text-discord-red">*</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all"
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Logging in...' : 'Log In'}
</button>
<p className="mt-3 text-sm text-discord-text-muted">
Need an account?{' '}
<Link to="/register" className="text-[#00aff4] hover:underline">
Register
</Link>
</p>
</form>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function RegisterPage() {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const register = useAuthStore((s) => s.register);
const isLoading = useAuthStore((s) => s.isLoading);
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username.trim()) {
setError('Username is required');
return;
}
if (username.trim().length < 3 || username.trim().length > 32) {
setError('Username must be between 3 and 32 characters');
return;
}
if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) {
setError('Username can only contain letters, numbers, and underscores');
return;
}
if (!password) {
setError('Password is required');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await register(username.trim(), password, displayName.trim() || undefined);
navigate('/channels/@me');
}
catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
}
};
return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-discord-bg-tertiary", children: _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl", children: [_jsx("div", { className: "text-center mb-6", children: _jsx("h1", { className: "text-2xl font-bold text-discord-text-primary", children: "Create an account" }) }), _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Username ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "text", value: username, onChange: (e) => setUsername(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoFocus: true, autoComplete: "username" })] }), _jsxs("div", { className: "mb-5", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Display Name" }), _jsx("input", { type: "text", value: displayName, onChange: (e) => setDisplayName(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "name" })] }), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Password ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "new-password" })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: isLoading ? 'Creating account...' : 'Continue' }), _jsxs("p", { className: "mt-3 text-sm text-discord-text-muted", children: ["Already have an account?", ' ', _jsx(Link, { to: "/login", className: "text-[#00aff4] hover:underline", children: "Log In" })] })] })] }) }));
}
@@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function RegisterPage() {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const register = useAuthStore((s) => s.register);
const isLoading = useAuthStore((s) => s.isLoading);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!username.trim()) {
setError('Username is required');
return;
}
if (username.trim().length < 3 || username.trim().length > 32) {
setError('Username must be between 3 and 32 characters');
return;
}
if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) {
setError('Username can only contain letters, numbers, and underscores');
return;
}
if (!password) {
setError('Password is required');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await register(username.trim(), password, displayName.trim() || undefined);
navigate('/channels/@me');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-discord-bg-tertiary">
<div className="w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-discord-text-primary">Create an account</h1>
</div>
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
{error}
</div>
)}
<div className="mb-5">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Username <span className="text-discord-red">*</span>
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all"
autoFocus
autoComplete="username"
/>
</div>
<div className="mb-5">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all"
autoComplete="name"
/>
</div>
<div className="mb-5">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Password <span className="text-discord-red">*</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all"
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating account...' : 'Continue'}
</button>
<p className="mt-3 text-sm text-discord-text-muted">
Already have an account?{' '}
<Link to="/login" className="text-[#00aff4] hover:underline">
Log In
</Link>
</p>
</form>
</div>
</div>
);
}
@@ -0,0 +1,10 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useUIStore } from '../../stores/uiStore';
export function ImagePreview() {
const imageUrl = useUIStore((s) => s.imagePreviewUrl);
const closeImagePreview = useUIStore((s) => s.closeImagePreview);
const activeModal = useUIStore((s) => s.activeModal);
if (activeModal !== 'imagePreview' || !imageUrl)
return null;
return (_jsxs("div", { className: "fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in cursor-pointer", onClick: closeImagePreview, children: [_jsx("button", { className: "absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10", onClick: closeImagePreview, children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) }), _jsx("img", { src: imageUrl, alt: "Preview", className: "max-w-[90vw] max-h-[90vh] object-contain rounded shadow-2xl", onClick: (e) => e.stopPropagation() })] }));
}
@@ -0,0 +1,32 @@
import React from 'react';
import { useUIStore } from '../../stores/uiStore';
export function ImagePreview() {
const imageUrl = useUIStore((s) => s.imagePreviewUrl);
const closeImagePreview = useUIStore((s) => s.closeImagePreview);
const activeModal = useUIStore((s) => s.activeModal);
if (activeModal !== 'imagePreview' || !imageUrl) return null;
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in cursor-pointer"
onClick={closeImagePreview}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10"
onClick={closeImagePreview}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
<img
src={imageUrl}
alt="Preview"
className="max-w-[90vw] max-h-[90vh] object-contain rounded shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Avatar } from '../ui/Avatar';
import { ContextMenu } from '../ui/ContextMenu';
import { useAuthStore } from '../../stores/authStore';
import { useChatStore } from '../../stores/chatStore';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
function formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday)
return `Today at ${time}`;
if (isYesterday)
return `Yesterday at ${time}`;
return `${date.toLocaleDateString()} ${time}`;
}
function formatHoverTime(timestamp) {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function Message({ message, isCompact, isFirstInGroup }) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content ?? '');
const [isHovered, setIsHovered] = useState(false);
const currentUser = useAuthStore((s) => s.user);
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useServerStore((s) => s.members);
const openImagePreview = useUIStore((s) => s.openImagePreview);
const isAuthor = currentUser?.id === message.userId;
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
const canDelete = isAuthor || isAdminUser;
const contextMenuItems = [];
if (isAuthor) {
contextMenuItems.push({
label: 'Edit Message',
onClick: () => {
setEditContent(message.content ?? '');
setIsEditing(true);
},
});
}
if (canDelete) {
contextMenuItems.push({
label: 'Delete Message',
onClick: () => deleteMessage(message.id),
danger: true,
});
}
const handleEditSubmit = async (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (editContent.trim()) {
await editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}
if (e.key === 'Escape') {
setIsEditing(false);
setEditContent(message.content ?? '');
}
};
const displayName = message.user.displayName ?? message.user.username;
const roleColor = (() => {
const member = members.find(m => m.userId === message.userId);
if (member?.role === 'owner')
return 'text-discord-red';
if (member?.role === 'admin')
return 'text-discord-blurple';
return 'text-white';
})();
const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [_jsx("div", { className: "w-[72px] flex-shrink-0 flex items-start justify-center", children: isFirstInGroup ? (_jsx(Avatar, { src: message.user.avatar, name: displayName, size: 40, className: "mt-0.5 cursor-pointer" })) : (_jsx("span", { className: `text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`, children: formatHoverTime(message.createdAt) })) }), _jsxs("div", { className: "flex-1 min-w-0", children: [isFirstInGroup && (_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: `font-medium cursor-pointer hover:underline ${roleColor}`, children: displayName }), _jsx("span", { className: "text-xs text-discord-text-muted", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-2 bg-discord-bg-input rounded text-discord-text-primary outline-none resize-none text-sm", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-xs text-discord-text-muted mt-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-[#00aff4] hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => {
if (editContent.trim()) {
editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}, className: "text-[#00aff4] hover:underline", children: "save" })] })] })) : (_jsxs(_Fragment, { children: [message.content && (_jsxs("div", { className: "text-discord-text-primary text-sm leading-[1.375rem] break-words", children: [_jsx(ReactMarkdown, { components: {
p: ({ children }) => _jsx("span", { children: children }),
a: ({ href, children }) => (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[#00aff4] hover:underline", children: children })),
code: ({ children }) => (_jsx("code", { className: "px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono", children: children })),
pre: ({ children }) => (_jsx("pre", { className: "mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto", children: children })),
strong: ({ children }) => _jsx("strong", { className: "font-bold", children: children }),
em: ({ children }) => _jsx("em", { className: "italic", children: children }),
}, children: message.content }), message.editedAt && (_jsx("span", { className: "text-[10px] text-discord-text-muted ml-1", children: "(edited)" }))] })), message.attachments.length > 0 && (_jsx("div", { className: "mt-1 space-y-1", children: message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/');
if (isImage) {
return (_jsx("div", { className: "max-w-[400px]", children: _jsx("img", { src: `/api/uploads/${att.filename}`, alt: att.originalName, className: "max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow", onClick: () => openImagePreview(`/api/uploads/${att.filename}`), loading: "lazy" }) }, att.id));
}
return (_jsxs("a", { href: `/api/uploads/${att.filename}`, download: att.originalName, className: "flex items-center gap-2 p-3 bg-discord-bg-secondary rounded border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors max-w-[400px]", children: [_jsx("svg", { className: "w-6 h-6 text-discord-text-muted flex-shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-[#00aff4] text-sm truncate hover:underline", children: att.originalName }), _jsx("p", { className: "text-xs text-discord-text-muted", children: att.size < 1024 ? `${att.size} B` :
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
`${(att.size / 1048576).toFixed(1)} MB` })] })] }, att.id));
}) }))] }))] }), isHovered && !isEditing && contextMenuItems.length > 0 && (_jsxs("div", { className: "absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md", children: [isAuthor && (_jsx("button", { onClick: () => {
setEditContent(message.content ?? '');
setIsEditing(true);
}, className: "p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Edit", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M13.293 1.293a1 1 0 011.414 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM12 3l1 1-8 8-1.5.5.5-1.5L12 3z" }) }) })), canDelete && (_jsx("button", { onClick: () => deleteMessage(message.id), className: "p-1.5 text-discord-text-muted hover:text-discord-red transition-colors", title: "Delete", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" }) }) }))] }))] }));
if (contextMenuItems.length > 0) {
return _jsx(ContextMenu, { items: contextMenuItems, children: content });
}
return content;
}
@@ -0,0 +1,264 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import type { MessageWithUser } from '@opencord/shared';
import { Avatar } from '../ui/Avatar';
import { ContextMenu } from '../ui/ContextMenu';
import { useAuthStore } from '../../stores/authStore';
import { useChatStore } from '../../stores/chatStore';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
interface MessageProps {
message: MessageWithUser;
isCompact: boolean;
isFirstInGroup: boolean;
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
return `${date.toLocaleDateString()} ${time}`;
}
function formatHoverTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content ?? '');
const [isHovered, setIsHovered] = useState(false);
const currentUser = useAuthStore((s) => s.user);
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useServerStore((s) => s.members);
const openImagePreview = useUIStore((s) => s.openImagePreview);
const isAuthor = currentUser?.id === message.userId;
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
const canDelete = isAuthor || isAdminUser;
const contextMenuItems = [];
if (isAuthor) {
contextMenuItems.push({
label: 'Edit Message',
onClick: () => {
setEditContent(message.content ?? '');
setIsEditing(true);
},
});
}
if (canDelete) {
contextMenuItems.push({
label: 'Delete Message',
onClick: () => deleteMessage(message.id),
danger: true,
});
}
const handleEditSubmit = async (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (editContent.trim()) {
await editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}
if (e.key === 'Escape') {
setIsEditing(false);
setEditContent(message.content ?? '');
}
};
const displayName = message.user.displayName ?? message.user.username;
const roleColor = (() => {
const member = members.find(m => m.userId === message.userId);
if (member?.role === 'owner') return 'text-discord-red';
if (member?.role === 'admin') return 'text-discord-blurple';
return 'text-white';
})();
const content = (
<div
className={`group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Avatar or timestamp column */}
<div className="w-[72px] flex-shrink-0 flex items-start justify-center">
{isFirstInGroup ? (
<Avatar
src={message.user.avatar}
name={displayName}
size={40}
className="mt-0.5 cursor-pointer"
/>
) : (
<span className={`text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`}>
{formatHoverTime(message.createdAt)}
</span>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{isFirstInGroup && (
<div className="flex items-baseline gap-2">
<span className={`font-medium cursor-pointer hover:underline ${roleColor}`}>
{displayName}
</span>
<span className="text-xs text-discord-text-muted">
{formatTime(message.createdAt)}
</span>
</div>
)}
{isEditing ? (
<div className="mt-1">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleEditSubmit}
className="w-full p-2 bg-discord-bg-input rounded text-discord-text-primary outline-none resize-none text-sm"
rows={2}
autoFocus
/>
<p className="text-xs text-discord-text-muted mt-1">
escape to <button onClick={() => setIsEditing(false)} className="text-[#00aff4] hover:underline">cancel</button>
{' '}&bull; enter to <button onClick={() => {
if (editContent.trim()) {
editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}} className="text-[#00aff4] hover:underline">save</button>
</p>
</div>
) : (
<>
{message.content && (
<div className="text-discord-text-primary text-sm leading-[1.375rem] break-words">
<ReactMarkdown
components={{
p: ({ children }) => <span>{children}</span>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-[#00aff4] hover:underline">
{children}
</a>
),
code: ({ children }) => (
<code className="px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto">
{children}
</pre>
),
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{message.content}
</ReactMarkdown>
{message.editedAt && (
<span className="text-[10px] text-discord-text-muted ml-1">(edited)</span>
)}
</div>
)}
{/* Attachments */}
{message.attachments.length > 0 && (
<div className="mt-1 space-y-1">
{message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/');
if (isImage) {
return (
<div key={att.id} className="max-w-[400px]">
<img
src={`/api/uploads/${att.filename}`}
alt={att.originalName}
className="max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => openImagePreview(`/api/uploads/${att.filename}`)}
loading="lazy"
/>
</div>
);
}
return (
<a
key={att.id}
href={`/api/uploads/${att.filename}`}
download={att.originalName}
className="flex items-center gap-2 p-3 bg-discord-bg-secondary rounded border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors max-w-[400px]"
>
<svg className="w-6 h-6 text-discord-text-muted flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
<div className="min-w-0">
<p className="text-[#00aff4] text-sm truncate hover:underline">{att.originalName}</p>
<p className="text-xs text-discord-text-muted">
{att.size < 1024 ? `${att.size} B` :
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
`${(att.size / 1048576).toFixed(1)} MB`}
</p>
</div>
</a>
);
})}
</div>
)}
</>
)}
</div>
{/* Action buttons on hover */}
{isHovered && !isEditing && contextMenuItems.length > 0 && (
<div className="absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md">
{isAuthor && (
<button
onClick={() => {
setEditContent(message.content ?? '');
setIsEditing(true);
}}
className="p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="Edit"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.293 1.293a1 1 0 011.414 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM12 3l1 1-8 8-1.5.5.5-1.5L12 3z" />
</svg>
</button>
)}
{canDelete && (
<button
onClick={() => deleteMessage(message.id)}
className="p-1.5 text-discord-text-muted hover:text-discord-red transition-colors"
title="Delete"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
</svg>
</button>
)}
</div>
)}
</div>
);
if (contextMenuItems.length > 0) {
return <ContextMenu items={contextMenuItems}>{content}</ContextMenu>;
}
return content;
}
@@ -0,0 +1,99 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useRef, useCallback } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { wsSend } from '../../hooks/useWebSocket';
import { api } from '../../api/client';
export function MessageInput({ channelId, channelName }) {
const [content, setContent] = useState('');
const [files, setFiles] = useState([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef(null);
const textareaRef = useRef(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const typingTimeoutRef = useRef();
const handleTyping = useCallback(() => {
if (typingTimeoutRef.current)
return;
wsSend({ type: 'typing_start', channelId });
typingTimeoutRef.current = setTimeout(() => {
typingTimeoutRef.current = undefined;
}, 3000);
}, [channelId]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed && files.length === 0)
return;
setIsUploading(true);
try {
// Upload files first
const attachmentIds = [];
for (const file of files) {
const attachment = await api.uploads.upload(file);
attachmentIds.push(attachment.id);
}
await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined);
setContent('');
setFiles([]);
// Clear typing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = undefined;
}
}
catch (err) {
console.error('Failed to send message:', err);
}
finally {
setIsUploading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handlePaste = (e) => {
const items = e.clipboardData.items;
const pastedFiles = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file)
pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) {
setFiles((prev) => [...prev, ...pastedFiles]);
}
};
const handleDrop = (e) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
setFiles((prev) => [...prev, ...droppedFiles]);
}
};
const handleDragOver = (e) => {
e.preventDefault();
};
const removeFile = (index) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleChange = (e) => {
setContent(e.target.value);
handleTyping();
// Auto-resize textarea
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
return (_jsx("div", { className: "px-4 pb-6", children: _jsxs("div", { className: "bg-discord-bg-input rounded-lg", onDrop: handleDrop, onDragOver: handleDragOver, children: [files.length > 0 && (_jsx("div", { className: "p-2 border-b border-discord-bg-tertiary flex flex-wrap gap-2", children: files.map((file, i) => (_jsxs("div", { className: "relative group bg-discord-bg-secondary rounded p-2 max-w-[200px]", children: [file.type.startsWith('image/') ? (_jsx("img", { src: URL.createObjectURL(file), alt: file.name, className: "max-h-[100px] rounded object-cover" })) : (_jsxs("div", { className: "flex items-center gap-2 text-sm text-discord-text-secondary", children: [_jsx("svg", { className: "w-5 h-5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }) }), _jsx("span", { className: "truncate", children: file.name })] })), _jsx("button", { onClick: () => removeFile(i), className: "absolute -top-1 -right-1 w-5 h-5 bg-discord-red rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity", children: "\u2715" })] }, i))) })), _jsxs("div", { className: "flex items-end", children: [_jsx("button", { onClick: () => fileInputRef.current?.click(), className: "p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Attach file", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: (e) => {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
e.target.value = '';
} }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message #${channelName}`, className: "flex-1 py-3 bg-transparent text-discord-text-primary placeholder-discord-text-muted outline-none resize-none text-sm leading-[1.375rem] max-h-[300px]", rows: 1, disabled: isUploading }), isUploading && (_jsx("div", { className: "p-3 text-discord-text-muted", children: _jsxs("svg", { className: "w-5 h-5 animate-spin", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }) }))] })] }) }));
}
@@ -0,0 +1,193 @@
import React, { useState, useRef, useCallback } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { wsSend } from '../../hooks/useWebSocket';
import { api } from '../../api/client';
interface MessageInputProps {
channelId: string;
channelName: string;
}
export function MessageInput({ channelId, channelName }: MessageInputProps) {
const [content, setContent] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const handleTyping = useCallback(() => {
if (typingTimeoutRef.current) return;
wsSend({ type: 'typing_start', channelId });
typingTimeoutRef.current = setTimeout(() => {
typingTimeoutRef.current = undefined;
}, 3000);
}, [channelId]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed && files.length === 0) return;
setIsUploading(true);
try {
// Upload files first
const attachmentIds: string[] = [];
for (const file of files) {
const attachment = await api.uploads.upload(file);
attachmentIds.push(attachment.id);
}
await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined);
setContent('');
setFiles([]);
// Clear typing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = undefined;
}
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setIsUploading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
const pastedFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) {
setFiles((prev) => [...prev, ...pastedFiles]);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
setFiles((prev) => [...prev, ...droppedFiles]);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
handleTyping();
// Auto-resize textarea
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
return (
<div className="px-4 pb-6">
<div
className="bg-discord-bg-input rounded-lg"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* File previews */}
{files.length > 0 && (
<div className="p-2 border-b border-discord-bg-tertiary flex flex-wrap gap-2">
{files.map((file, i) => (
<div key={i} className="relative group bg-discord-bg-secondary rounded p-2 max-w-[200px]">
{file.type.startsWith('image/') ? (
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="max-h-[100px] rounded object-cover"
/>
) : (
<div className="flex items-center gap-2 text-sm text-discord-text-secondary">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="truncate">{file.name}</span>
</div>
)}
<button
onClick={() => removeFile(i)}
className="absolute -top-1 -right-1 w-5 h-5 bg-discord-red rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
</div>
))}
</div>
)}
<div className="flex items-end">
{/* File attach button */}
<button
onClick={() => fileInputRef.current?.click()}
className="p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="Attach file"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
e.target.value = '';
}}
/>
{/* Text input */}
<textarea
ref={textareaRef}
value={content}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={`Message #${channelName}`}
className="flex-1 py-3 bg-transparent text-discord-text-primary placeholder-discord-text-muted outline-none resize-none text-sm leading-[1.375rem] max-h-[300px]"
rows={1}
disabled={isUploading}
/>
{/* Send indicator */}
{isUploading && (
<div className="p-3 text-discord-text-muted">
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { Message } from './Message';
import { useChatStore } from '../../stores/chatStore';
import { LoadingSpinner } from '../ui/LoadingSpinner';
const EMPTY_MESSAGES = [];
function isSameGroup(prev, curr) {
if (prev.userId !== curr.userId)
return false;
const timeDiff = curr.createdAt - prev.createdAt;
return timeDiff < 5 * 60 * 1000; // 5 minutes
}
function formatDateDivider(timestamp) {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function shouldShowDateDivider(prev, curr) {
if (!prev)
return true;
const prevDate = new Date(prev.createdAt).toDateString();
const currDate = new Date(curr.createdAt).toDateString();
return prevDate !== currDate;
}
export function MessageList({ channelId }) {
const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES;
const loadMessages = useChatStore((s) => s.loadMessages);
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
const isLoading = useChatStore((s) => s.isLoading);
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
const bottomRef = useRef(null);
const containerRef = useRef(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const prevMessagesLength = useRef(0);
useEffect(() => {
loadMessages(channelId);
}, [channelId, loadMessages]);
// Auto-scroll to bottom on new messages (if near bottom)
useEffect(() => {
if (messages.length > prevMessagesLength.current && isNearBottom) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
prevMessagesLength.current = messages.length;
}, [messages.length, isNearBottom]);
// Scroll to bottom on initial load
useEffect(() => {
if (messages.length > 0 && prevMessagesLength.current === 0) {
bottomRef.current?.scrollIntoView();
}
}, [messages.length]);
const handleScroll = useCallback(async () => {
const container = containerRef.current;
if (!container)
return;
// Check if near bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
setIsNearBottom(distanceFromBottom < 100);
// Load more when scrolled to top
if (container.scrollTop < 50 && hasMore && !isLoadingMore) {
setIsLoadingMore(true);
const prevScrollHeight = container.scrollHeight;
const loaded = await loadMoreMessages(channelId);
if (loaded) {
// Maintain scroll position
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight - prevScrollHeight;
});
}
setIsLoadingMore(false);
}
}, [channelId, hasMore, isLoadingMore, loadMoreMessages]);
if (isLoading && messages.length === 0) {
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) }));
}
return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && (_jsxs("div", { className: "px-4 pt-6 pb-4", children: [_jsx("h3", { className: "text-2xl font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-muted text-sm mt-1", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-4 border-b border-discord-bg-hover" })] })), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => {
const prevMsg = messages[i - 1];
const showDate = shouldShowDateDivider(prevMsg, msg);
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
return (_jsxs(React.Fragment, { children: [showDate && (_jsxs("div", { className: "flex items-center px-4 my-4", children: [_jsx("div", { className: "flex-1 border-t border-discord-bg-hover" }), _jsx("span", { className: "px-2 text-xs font-semibold text-discord-text-muted", children: formatDateDivider(msg.createdAt) }), _jsx("div", { className: "flex-1 border-t border-discord-bg-hover" })] })), _jsx(Message, { message: msg, isCompact: !isFirstInGroup, isFirstInGroup: isFirstInGroup })] }, msg.id));
}) }), _jsx("div", { ref: bottomRef })] }));
}
@@ -0,0 +1,148 @@
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { Message } from './Message';
import { useChatStore } from '../../stores/chatStore';
import { LoadingSpinner } from '../ui/LoadingSpinner';
import type { MessageWithUser } from '@opencord/shared';
const EMPTY_MESSAGES: MessageWithUser[] = [];
interface MessageListProps {
channelId: string;
}
function isSameGroup(prev: MessageWithUser, curr: MessageWithUser): boolean {
if (prev.userId !== curr.userId) return false;
const timeDiff = curr.createdAt - prev.createdAt;
return timeDiff < 5 * 60 * 1000; // 5 minutes
}
function formatDateDivider(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function shouldShowDateDivider(prev: MessageWithUser | undefined, curr: MessageWithUser): boolean {
if (!prev) return true;
const prevDate = new Date(prev.createdAt).toDateString();
const currDate = new Date(curr.createdAt).toDateString();
return prevDate !== currDate;
}
export function MessageList({ channelId }: MessageListProps) {
const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES;
const loadMessages = useChatStore((s) => s.loadMessages);
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
const isLoading = useChatStore((s) => s.isLoading);
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const prevMessagesLength = useRef(0);
useEffect(() => {
loadMessages(channelId);
}, [channelId, loadMessages]);
// Auto-scroll to bottom on new messages (if near bottom)
useEffect(() => {
if (messages.length > prevMessagesLength.current && isNearBottom) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
prevMessagesLength.current = messages.length;
}, [messages.length, isNearBottom]);
// Scroll to bottom on initial load
useEffect(() => {
if (messages.length > 0 && prevMessagesLength.current === 0) {
bottomRef.current?.scrollIntoView();
}
}, [messages.length]);
const handleScroll = useCallback(async () => {
const container = containerRef.current;
if (!container) return;
// Check if near bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
setIsNearBottom(distanceFromBottom < 100);
// Load more when scrolled to top
if (container.scrollTop < 50 && hasMore && !isLoadingMore) {
setIsLoadingMore(true);
const prevScrollHeight = container.scrollHeight;
const loaded = await loadMoreMessages(channelId);
if (loaded) {
// Maintain scroll position
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight - prevScrollHeight;
});
}
setIsLoadingMore(false);
}
}, [channelId, hasMore, isLoadingMore, loadMoreMessages]);
if (isLoading && messages.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div
ref={containerRef}
className="flex-1 overflow-y-auto overflow-x-hidden"
onScroll={handleScroll}
>
{isLoadingMore && (
<div className="py-4">
<LoadingSpinner size={24} />
</div>
)}
{!hasMore && (
<div className="px-4 pt-6 pb-4">
<h3 className="text-2xl font-bold text-discord-text-primary">Welcome to the channel!</h3>
<p className="text-discord-text-muted text-sm mt-1">This is the start of the conversation.</p>
<div className="mt-4 border-b border-discord-bg-hover" />
</div>
)}
<div className="pb-6">
{messages.map((msg, i) => {
const prevMsg = messages[i - 1];
const showDate = shouldShowDateDivider(prevMsg, msg);
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
return (
<React.Fragment key={msg.id}>
{showDate && (
<div className="flex items-center px-4 my-4">
<div className="flex-1 border-t border-discord-bg-hover" />
<span className="px-2 text-xs font-semibold text-discord-text-muted">
{formatDateDivider(msg.createdAt)}
</span>
<div className="flex-1 border-t border-discord-bg-hover" />
</div>
)}
<Message
message={msg}
isCompact={!isFirstInGroup}
isFirstInGroup={isFirstInGroup}
/>
</React.Fragment>
);
})}
</div>
<div ref={bottomRef} />
</div>
);
}
@@ -0,0 +1,29 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useMemo } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { useAuthStore } from '../../stores/authStore';
export function TypingIndicator({ channelId }) {
const typingUsersRaw = useChatStore((s) => s.typingUsers.get(channelId));
const currentUserId = useAuthStore((s) => s.user?.id);
// Filter out current user and expired entries
const others = useMemo(() => {
if (!typingUsersRaw || typingUsersRaw.length === 0)
return [];
const now = Date.now();
return typingUsersRaw
.filter(t => now - t.timestamp < 5000 && t.userId !== currentUserId);
}, [typingUsersRaw, currentUserId]);
if (others.length === 0)
return null;
let text = '';
if (others.length === 1) {
text = `${others[0].username} is typing`;
}
else if (others.length === 2) {
text = `${others[0].username} and ${others[1].username} are typing`;
}
else {
text = 'Several people are typing';
}
return (_jsx("div", { className: "h-6 px-4 flex items-center text-xs text-discord-text-muted", children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsxs("span", { className: "flex gap-0.5", children: [_jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '0ms' } }), _jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '150ms' } }), _jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '300ms' } })] }), _jsx("span", { className: "font-medium", children: text }), _jsx("span", { children: "..." })] }) }));
}
@@ -0,0 +1,45 @@
import React, { useMemo } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { useAuthStore } from '../../stores/authStore';
interface TypingIndicatorProps {
channelId: string;
}
export function TypingIndicator({ channelId }: TypingIndicatorProps) {
const typingUsersRaw = useChatStore((s) => s.typingUsers.get(channelId));
const currentUserId = useAuthStore((s) => s.user?.id);
// Filter out current user and expired entries
const others = useMemo(() => {
if (!typingUsersRaw || typingUsersRaw.length === 0) return [];
const now = Date.now();
return typingUsersRaw
.filter(t => now - t.timestamp < 5000 && t.userId !== currentUserId);
}, [typingUsersRaw, currentUserId]);
if (others.length === 0) return null;
let text = '';
if (others.length === 1) {
text = `${others[0]!.username} is typing`;
} else if (others.length === 2) {
text = `${others[0]!.username} and ${others[1]!.username} are typing`;
} else {
text = 'Several people are typing';
}
return (
<div className="h-6 px-4 flex items-center text-xs text-discord-text-muted">
<div className="flex items-center gap-1">
<span className="flex gap-0.5">
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
<span className="font-medium">{text}</span>
<span>...</span>
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { ServerSidebar } from './ServerSidebar';
import { ChannelSidebar } from './ChannelSidebar';
import { MainContent } from './MainContent';
import { MemberSidebar } from './MemberSidebar';
import { MobileNav } from './MobileNav';
import { ImagePreview } from '../chat/ImagePreview';
import { CreateServerModal } from '../modals/CreateServer';
import { JoinServerModal } from '../modals/JoinServer';
import { CreateChannelModal } from '../modals/CreateChannel';
import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
export function AppLayout() {
const { serverId, channelId } = useParams();
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
const setShowDms = useUIStore((s) => s.setShowDms);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const isMobile = useUIStore((s) => s.isMobile);
// Initialize WebSocket
useWebSocket();
// Responsive detection
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, [setIsMobile]);
// Handle route params
useEffect(() => {
if (serverId === '@me') {
setShowDms(true);
setCurrentServer(null);
}
else if (serverId) {
setShowDms(false);
setCurrentServer(serverId);
loadServerDetail(serverId);
}
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
useEffect(() => {
if (channelId) {
setCurrentChannel(channelId);
loadMessages(channelId);
}
else {
setCurrentChannel(null);
}
}, [channelId, setCurrentChannel, loadMessages]);
if (isLoading || !user) {
return (_jsx("div", { className: "h-screen flex items-center justify-center bg-discord-bg-primary", children: _jsxs("div", { className: "text-center", children: [_jsxs("svg", { className: "animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }), _jsx("p", { className: "text-discord-text-muted", children: "Loading Opencord..." })] }) }));
}
return (_jsxs("div", { className: "h-screen flex overflow-hidden", children: [_jsx(MobileNav, {}), _jsx("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : ''}`, children: _jsxs("div", { className: "flex h-full", children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }) }), _jsxs("div", { className: "flex-1 flex min-w-0", children: [_jsx(MainContent, {}), _jsx(MemberSidebar, {})] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(ImagePreview, {})] }));
}
@@ -0,0 +1,107 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { ServerSidebar } from './ServerSidebar';
import { ChannelSidebar } from './ChannelSidebar';
import { MainContent } from './MainContent';
import { MemberSidebar } from './MemberSidebar';
import { MobileNav } from './MobileNav';
import { ImagePreview } from '../chat/ImagePreview';
import { CreateServerModal } from '../modals/CreateServer';
import { JoinServerModal } from '../modals/JoinServer';
import { CreateChannelModal } from '../modals/CreateChannel';
import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
export function AppLayout() {
const { serverId, channelId } = useParams<{ serverId?: string; channelId?: string }>();
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
const setShowDms = useUIStore((s) => s.setShowDms);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const isMobile = useUIStore((s) => s.isMobile);
// Initialize WebSocket
useWebSocket();
// Responsive detection
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, [setIsMobile]);
// Handle route params
useEffect(() => {
if (serverId === '@me') {
setShowDms(true);
setCurrentServer(null);
} else if (serverId) {
setShowDms(false);
setCurrentServer(serverId);
loadServerDetail(serverId);
}
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
useEffect(() => {
if (channelId) {
setCurrentChannel(channelId);
loadMessages(channelId);
} else {
setCurrentChannel(null);
}
}, [channelId, setCurrentChannel, loadMessages]);
if (isLoading || !user) {
return (
<div className="h-screen flex items-center justify-center bg-discord-bg-primary">
<div className="text-center">
<svg className="animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p className="text-discord-text-muted">Loading Opencord...</p>
</div>
</div>
);
}
return (
<div className="h-screen flex overflow-hidden">
<MobileNav />
{/* Server sidebar - always visible on desktop, toggled on mobile */}
<div className={`${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : ''}`}>
<div className="flex h-full">
<ServerSidebar />
<ChannelSidebar />
</div>
</div>
{/* Main content area */}
<div className="flex-1 flex min-w-0">
<MainContent />
<MemberSidebar />
</div>
{/* Modals */}
<CreateServerModal />
<JoinServerModal />
<CreateChannelModal />
<InviteModal />
<UserSettingsModal />
<ServerSettingsModal />
<ImagePreview />
</div>
);
}
@@ -0,0 +1,47 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { VoiceChannel } from '../voice/VoiceChannel';
import { VoiceControls } from '../voice/VoiceControls';
import { useVoiceStore } from '../../stores/voiceStore';
import { Avatar } from '../ui/Avatar';
import { wsSend } from '../../hooks/useWebSocket';
export function ChannelSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const openModal = useUIStore((s) => s.openModal);
const user = useAuthStore((s) => s.user);
const members = useServerStore((s) => s.members);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const setCurrentVoiceChannel = useVoiceStore((s) => s.setCurrentVoiceChannel);
const navigate = useNavigate();
const server = servers.find(s => s.id === currentServerId);
const currentMember = members.find(m => m.userId === user?.id);
const isAdminUser = currentMember?.role === 'admin' || currentMember?.role === 'owner';
const textChannels = channels.filter(c => c.type === 'text');
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
const handleChannelClick = (channelId) => {
setCurrentChannel(channelId);
navigate(`/channels/${currentServerId}/${channelId}`);
};
const handleVoiceJoin = (channelId) => {
setCurrentVoiceChannel(channelId);
wsSend({ type: 'voice_join', channelId });
};
const handleVoiceDisconnect = () => {
setCurrentVoiceChannel(null);
wsSend({ type: 'voice_leave' });
};
if (!server) {
return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary", children: _jsx("span", { className: "font-semibold text-discord-text-primary", children: "Direct Messages" }) }), _jsx("div", { className: "flex-1 p-2 text-discord-text-muted text-sm", children: _jsx("p", { className: "px-2 py-4", children: "Select or create a DM conversation" }) }), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-members flex items-center gap-2", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm font-medium truncate", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[10px] text-discord-text-muted truncate", children: ["@", user.username] })] }), _jsx("button", { onClick: () => openModal('userSettings'), className: "p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "User Settings", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] }))] }));
}
return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0", children: [_jsxs("button", { onClick: () => openModal('serverSettings'), className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors", children: [_jsx("span", { className: "font-bold text-discord-text-primary truncate", children: server.name }), _jsx("svg", { width: "18", height: "18", viewBox: "0 0 18 18", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto p-2 space-y-4", children: [textChannels.length > 0 && (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1", children: [_jsx("span", { className: "text-xs font-bold text-discord-text-muted uppercase tracking-wide", children: "Text Channels" }), isAdminUser && (_jsx("button", { onClick: () => openModal('createChannel'), className: "text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), textChannels.map((channel) => (_jsxs("button", { onClick: () => handleChannelClick(channel.id), className: `w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group ${currentChannelId === channel.id
? 'bg-discord-bg-active text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "truncate", children: channel.name })] }, channel.id)))] })), voiceChannels.length > 0 && (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1", children: [_jsx("span", { className: "text-xs font-bold text-discord-text-muted uppercase tracking-wide", children: "Voice Channels" }), isAdminUser && (_jsx("button", { onClick: () => openModal('createChannel'), className: "text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), voiceChannels.map((channel) => (_jsx(VoiceChannel, { channelId: channel.id, channelName: channel.name, onClick: () => handleVoiceJoin(channel.id) }, channel.id)))] })), _jsxs("button", { onClick: () => openModal('invite'), className: "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M14 2.5a.5.5 0 00-.5-.5h-6a.5.5 0 000 1h4.793L2.146 13.146a.5.5 0 00.708.708L13 3.707V8.5a.5.5 0 001 0v-6z" }) }), "Invite People"] })] }), currentVoiceChannelId && (_jsx(VoiceControls, { onDisconnect: handleVoiceDisconnect, onToggleMic: () => { }, onToggleCamera: () => { }, onToggleScreenShare: () => { } })), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-members flex items-center gap-2", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm font-medium truncate", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[10px] text-discord-text-muted truncate", children: ["@", user.username] })] }), _jsx("button", { onClick: () => openModal('userSettings'), className: "p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "User Settings", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] }))] }));
}
@@ -0,0 +1,202 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { VoiceChannel } from '../voice/VoiceChannel';
import { VoiceControls } from '../voice/VoiceControls';
import { useVoiceStore } from '../../stores/voiceStore';
import { Avatar } from '../ui/Avatar';
import { wsSend } from '../../hooks/useWebSocket';
export function ChannelSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const openModal = useUIStore((s) => s.openModal);
const user = useAuthStore((s) => s.user);
const members = useServerStore((s) => s.members);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const setCurrentVoiceChannel = useVoiceStore((s) => s.setCurrentVoiceChannel);
const navigate = useNavigate();
const server = servers.find(s => s.id === currentServerId);
const currentMember = members.find(m => m.userId === user?.id);
const isAdminUser = currentMember?.role === 'admin' || currentMember?.role === 'owner';
const textChannels = channels.filter(c => c.type === 'text');
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
const handleChannelClick = (channelId: string) => {
setCurrentChannel(channelId);
navigate(`/channels/${currentServerId}/${channelId}`);
};
const handleVoiceJoin = (channelId: string) => {
setCurrentVoiceChannel(channelId);
wsSend({ type: 'voice_join', channelId });
};
const handleVoiceDisconnect = () => {
setCurrentVoiceChannel(null);
wsSend({ type: 'voice_leave' });
};
if (!server) {
return (
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0">
<div className="h-12 px-4 flex items-center border-b border-discord-bg-tertiary">
<span className="font-semibold text-discord-text-primary">Direct Messages</span>
</div>
<div className="flex-1 p-2 text-discord-text-muted text-sm">
<p className="px-2 py-4">Select or create a DM conversation</p>
</div>
{/* User area at bottom */}
{user && (
<div className="h-[52px] px-2 bg-discord-bg-members flex items-center gap-2">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.displayName ?? user.username}</div>
<div className="text-[10px] text-discord-text-muted truncate">@{user.username}</div>
</div>
<button
onClick={() => openModal('userSettings')}
className="p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="User Settings"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
</button>
</div>
)}
</div>
);
}
return (
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0">
{/* Server header */}
<button
onClick={() => openModal('serverSettings')}
className="h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors"
>
<span className="font-bold text-discord-text-primary truncate">{server.name}</span>
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
<path d="M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" />
</svg>
</button>
{/* Channels */}
<div className="flex-1 overflow-y-auto p-2 space-y-4">
{/* Text Channels */}
{textChannels.length > 0 && (
<div>
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-xs font-bold text-discord-text-muted uppercase tracking-wide">Text Channels</span>
{isAdminUser && (
<button
onClick={() => openModal('createChannel')}
className="text-discord-text-muted hover:text-discord-text-secondary transition-colors"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
)}
</div>
{textChannels.map((channel) => (
<button
key={channel.id}
onClick={() => handleChannelClick(channel.id)}
className={`w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group ${
currentChannelId === channel.id
? 'bg-discord-bg-active text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
}`}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-60">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
</svg>
<span className="truncate">{channel.name}</span>
</button>
))}
</div>
)}
{/* Voice Channels */}
{voiceChannels.length > 0 && (
<div>
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-xs font-bold text-discord-text-muted uppercase tracking-wide">Voice Channels</span>
{isAdminUser && (
<button
onClick={() => openModal('createChannel')}
className="text-discord-text-muted hover:text-discord-text-secondary transition-colors"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
)}
</div>
{voiceChannels.map((channel) => (
<VoiceChannel
key={channel.id}
channelId={channel.id}
channelName={channel.name}
onClick={() => handleVoiceJoin(channel.id)}
/>
))}
</div>
)}
{/* Invite button */}
<button
onClick={() => openModal('invite')}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 2.5a.5.5 0 00-.5-.5h-6a.5.5 0 000 1h4.793L2.146 13.146a.5.5 0 00.708.708L13 3.707V8.5a.5.5 0 001 0v-6z" />
</svg>
Invite People
</button>
</div>
{/* Voice controls */}
{currentVoiceChannelId && (
<VoiceControls
onDisconnect={handleVoiceDisconnect}
onToggleMic={() => {}}
onToggleCamera={() => {}}
onToggleScreenShare={() => {}}
/>
)}
{/* User area */}
{user && (
<div className="h-[52px] px-2 bg-discord-bg-members flex items-center gap-2">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.displayName ?? user.username}</div>
<div className="text-[10px] text-discord-text-muted truncate">@{user.username}</div>
</div>
<button
onClick={() => openModal('userSettings')}
className="p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="User Settings"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,34 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { MessageList } from '../chat/MessageList';
import { MessageInput } from '../chat/MessageInput';
import { TypingIndicator } from '../chat/TypingIndicator';
import { VoiceGrid } from '../voice/VoiceGrid';
import { useVoiceStore } from '../../stores/voiceStore';
export function MainContent() {
const channels = useServerStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const currentServerId = useServerStore((s) => s.currentServerId);
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const showDms = useUIStore((s) => s.showDms);
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
// DM view or no server selected
if (showDms || !currentServerId) {
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm", children: _jsx("span", { className: "font-bold text-discord-text-primary", children: "Home" }) }), _jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsxs("div", { className: "text-center", children: [_jsx("h2", { className: "text-2xl font-bold text-discord-text-primary mb-2", children: "Welcome to Opencord!" }), _jsx("p", { children: "Select a server from the sidebar or start a direct message." })] }) })] }));
}
// No channel selected
if (!currentChannelId || !channel) {
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm", children: _jsx("span", { className: "text-discord-text-muted", children: "Select a channel" }) }), _jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsx("p", { children: "Select a text or voice channel to get started" }) })] }));
}
// Voice/Video channel view
if (isVoiceChannel) {
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name })] }) }), _jsx(VoiceGrid, { participants: [] })] }));
}
// Text channel view
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm flex-shrink-0", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: channel.name }), channel.topic && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-px h-6 bg-discord-bg-hover mx-1" }), _jsx("span", { className: "text-xs text-discord-text-muted truncate", children: channel.topic })] }))] }), _jsx("div", { className: "flex items-center gap-2 flex-shrink-0", children: _jsx("button", { onClick: toggleMemberList, className: `p-1 transition-colors ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: "Toggle Member List", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }) })] }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: channel.name })] }));
}
@@ -0,0 +1,113 @@
import React from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { MessageList } from '../chat/MessageList';
import { MessageInput } from '../chat/MessageInput';
import { TypingIndicator } from '../chat/TypingIndicator';
import { VoiceGrid } from '../voice/VoiceGrid';
import { useVoiceStore } from '../../stores/voiceStore';
export function MainContent() {
const channels = useServerStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const currentServerId = useServerStore((s) => s.currentServerId);
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const showDms = useUIStore((s) => s.showDms);
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
// DM view or no server selected
if (showDms || !currentServerId) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm">
<span className="font-bold text-discord-text-primary">Home</span>
</div>
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
<div className="text-center">
<h2 className="text-2xl font-bold text-discord-text-primary mb-2">Welcome to Opencord!</h2>
<p>Select a server from the sidebar or start a direct message.</p>
</div>
</div>
</div>
);
}
// No channel selected
if (!currentChannelId || !channel) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm">
<span className="text-discord-text-muted">Select a channel</span>
</div>
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
<p>Select a text or voice channel to get started</p>
</div>
</div>
);
}
// Voice/Video channel view
if (isVoiceChannel) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm">
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
</svg>
<span className="font-bold text-discord-text-primary">{channel.name}</span>
</div>
</div>
<VoiceGrid participants={[]} />
</div>
);
}
// Text channel view
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0">
{/* Channel header */}
<div className="h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
</svg>
<span className="font-bold text-discord-text-primary truncate">{channel.name}</span>
{channel.topic && (
<>
<div className="w-px h-6 bg-discord-bg-hover mx-1" />
<span className="text-xs text-discord-text-muted truncate">{channel.topic}</span>
</>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={toggleMemberList}
className={`p-1 transition-colors ${
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-primary'
}`}
title="Toggle Member List"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
</button>
</div>
</div>
{/* Messages */}
<MessageList channelId={currentChannelId} />
{/* Typing indicator */}
<TypingIndicator channelId={currentChannelId} />
{/* Message input */}
<MessageInput channelId={currentChannelId} channelName={channel.name} />
</div>
);
}
@@ -0,0 +1,24 @@
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const memberListOpen = useUIStore((s) => s.memberListOpen);
if (!memberListOpen)
return null;
const onlineMembers = members.filter(m => m.user.status !== 'offline');
const offlineMembers = members.filter(m => m.user.status === 'offline');
const roleColors = {
owner: 'text-discord-red',
admin: 'text-discord-blurple',
member: 'text-discord-text-primary',
};
return (_jsx("div", { className: "w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto", children: _jsxs("div", { className: "p-3", children: [onlineMembers.length > 0 && (_jsxs("div", { className: "mb-4", children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Online \u2014 ", onlineMembers.length] }), onlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { className: "flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: member.user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-sm font-medium truncate ${roleColors[member.role] ?? 'text-discord-text-primary'}`, children: displayName }), member.user.customStatus && (_jsx("div", { className: "text-xs text-discord-text-muted truncate", children: member.user.customStatus }))] })] }, member.userId));
})] })), offlineMembers.length > 0 && (_jsxs("div", { children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Offline \u2014 ", offlineMembers.length] }), offlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { className: "flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group opacity-50", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: "offline" }), _jsx("div", { className: "flex-1 min-w-0", children: _jsx("div", { className: "text-sm font-medium truncate text-discord-text-muted", children: displayName }) })] }, member.userId));
})] }))] }) }));
}
@@ -0,0 +1,89 @@
import React from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const memberListOpen = useUIStore((s) => s.memberListOpen);
if (!memberListOpen) return null;
const onlineMembers = members.filter(m => m.user.status !== 'offline');
const offlineMembers = members.filter(m => m.user.status === 'offline');
const roleColors: Record<string, string> = {
owner: 'text-discord-red',
admin: 'text-discord-blurple',
member: 'text-discord-text-primary',
};
return (
<div className="w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto">
<div className="p-3">
{/* Online */}
{onlineMembers.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1">
Online {onlineMembers.length}
</h3>
{onlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div
key={member.userId}
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status={member.user.status}
/>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${roleColors[member.role] ?? 'text-discord-text-primary'}`}>
{displayName}
</div>
{member.user.customStatus && (
<div className="text-xs text-discord-text-muted truncate">{member.user.customStatus}</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* Offline */}
{offlineMembers.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1">
Offline {offlineMembers.length}
</h3>
{offlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div
key={member.userId}
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group opacity-50"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status="offline"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate text-discord-text-muted">
{displayName}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,10 @@
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { useUIStore } from '../../stores/uiStore';
export function MobileNav() {
const isMobile = useUIStore((s) => s.isMobile);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
if (!isMobile)
return null;
return (_jsxs(_Fragment, { children: [_jsx("button", { onClick: toggleSidebar, className: "fixed top-3 left-3 z-40 p-1.5 rounded bg-discord-bg-secondary text-discord-text-primary md:hidden", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: sidebarOpen ? (_jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" })) : (_jsx("path", { d: "M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" })) }) }), sidebarOpen && (_jsx("div", { className: "fixed inset-0 bg-black/50 z-30 md:hidden", onClick: toggleSidebar }))] }));
}
@@ -0,0 +1,36 @@
import React from 'react';
import { useUIStore } from '../../stores/uiStore';
export function MobileNav() {
const isMobile = useUIStore((s) => s.isMobile);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
if (!isMobile) return null;
return (
<>
{/* Hamburger button in header */}
<button
onClick={toggleSidebar}
className="fixed top-3 left-3 z-40 p-1.5 rounded bg-discord-bg-secondary text-discord-text-primary md:hidden"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
{sidebarOpen ? (
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
) : (
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
)}
</svg>
</button>
{/* Backdrop when sidebar is open on mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 md:hidden"
onClick={toggleSidebar}
/>
)}
</>
);
}
@@ -0,0 +1,33 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Tooltip } from '../ui/Tooltip';
export function ServerSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const showDms = useUIStore((s) => s.showDms);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const navigate = useNavigate();
const handleServerClick = (serverId) => {
setCurrentServer(serverId);
setShowDms(false);
navigate(`/channels/${serverId}`);
};
const handleDmClick = () => {
setShowDms(true);
setCurrentServer(null);
navigate('/channels/@me');
};
return (_jsxs("div", { className: "w-[72px] bg-discord-bg-tertiary flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 gap-2", children: [_jsx(Tooltip, { content: "Direct Messages", position: "right", children: _jsx("button", { onClick: handleDmClick, className: `w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 flex items-center justify-center ${showDms
? 'bg-discord-blurple rounded-[16px]'
: 'bg-discord-bg-primary hover:bg-discord-blurple'}`, children: _jsx("svg", { width: "28", height: "20", viewBox: "0 0 28 20", fill: "white", children: _jsx("path", { d: "M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z", transform: "scale(0.85) translate(0, 0)" }) }) }) }), _jsx("div", { className: "w-8 h-0.5 bg-discord-bg-primary rounded-full" }), servers.map((server) => {
const isActive = currentServerId === server.id;
const firstLetter = server.name.charAt(0).toUpperCase();
return (_jsxs("div", { className: "relative", children: [isActive && (_jsx("div", { className: "absolute -left-1 top-1/2 -translate-y-1/2 w-1 h-10 bg-white rounded-r-full" })), _jsx(Tooltip, { content: server.name, position: "right", children: _jsx("button", { onClick: () => handleServerClick(server.id), className: `w-12 h-12 transition-all duration-200 flex items-center justify-center text-lg font-semibold ${isActive
? 'bg-discord-blurple rounded-[16px] text-white'
: 'bg-discord-bg-primary hover:bg-discord-blurple rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:text-white'}`, children: server.icon ? (_jsx("img", { src: server.icon.startsWith('http') ? server.icon : `/api/uploads/${server.icon}`, alt: server.name, className: "w-full h-full rounded-inherit object-cover" })) : (firstLetter) }) })] }, server.id));
}), _jsx(Tooltip, { content: "Add a Server", position: "right", children: _jsx("button", { onClick: () => openModal('createServer'), className: "w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 bg-discord-bg-primary hover:bg-discord-green text-discord-green hover:text-white flex items-center justify-center", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) }) }) }), _jsx(Tooltip, { content: "Join a Server", position: "right", children: _jsx("button", { onClick: () => openModal('joinServer'), className: "w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 bg-discord-bg-primary hover:bg-discord-green text-discord-green hover:text-white flex items-center justify-center", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" }) }) }) })] }));
}
@@ -0,0 +1,108 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Tooltip } from '../ui/Tooltip';
export function ServerSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const showDms = useUIStore((s) => s.showDms);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const navigate = useNavigate();
const handleServerClick = (serverId: string) => {
setCurrentServer(serverId);
setShowDms(false);
navigate(`/channels/${serverId}`);
};
const handleDmClick = () => {
setShowDms(true);
setCurrentServer(null);
navigate('/channels/@me');
};
return (
<div className="w-[72px] bg-discord-bg-tertiary flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 gap-2">
{/* DM Button */}
<Tooltip content="Direct Messages" position="right">
<button
onClick={handleDmClick}
className={`w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 flex items-center justify-center ${
showDms
? 'bg-discord-blurple rounded-[16px]'
: 'bg-discord-bg-primary hover:bg-discord-blurple'
}`}
>
<svg width="28" height="20" viewBox="0 0 28 20" fill="white">
<path d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z" transform="scale(0.85) translate(0, 0)" />
</svg>
</button>
</Tooltip>
{/* Divider */}
<div className="w-8 h-0.5 bg-discord-bg-primary rounded-full" />
{/* Server Icons */}
{servers.map((server) => {
const isActive = currentServerId === server.id;
const firstLetter = server.name.charAt(0).toUpperCase();
return (
<div key={server.id} className="relative">
{/* Active indicator */}
{isActive && (
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-1 h-10 bg-white rounded-r-full" />
)}
<Tooltip content={server.name} position="right">
<button
onClick={() => handleServerClick(server.id)}
className={`w-12 h-12 transition-all duration-200 flex items-center justify-center text-lg font-semibold ${
isActive
? 'bg-discord-blurple rounded-[16px] text-white'
: 'bg-discord-bg-primary hover:bg-discord-blurple rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:text-white'
}`}
>
{server.icon ? (
<img
src={server.icon.startsWith('http') ? server.icon : `/api/uploads/${server.icon}`}
alt={server.name}
className="w-full h-full rounded-inherit object-cover"
/>
) : (
firstLetter
)}
</button>
</Tooltip>
</div>
);
})}
{/* Add Server Button */}
<Tooltip content="Add a Server" position="right">
<button
onClick={() => openModal('createServer')}
className="w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 bg-discord-bg-primary hover:bg-discord-green text-discord-green hover:text-white flex items-center justify-center"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
</button>
</Tooltip>
{/* Join Server Button */}
<Tooltip content="Join a Server" position="right">
<button
onClick={() => openModal('joinServer')}
className="w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 bg-discord-bg-primary hover:bg-discord-green text-discord-green hover:text-white flex items-center justify-center"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" />
</svg>
</button>
</Tooltip>
</div>
);
}
@@ -0,0 +1,46 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
export function CreateChannelModal() {
const [name, setName] = useState('');
const [type, setType] = useState('text');
const [topic, setTopic] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const createChannel = useServerStore((s) => s.createChannel);
const currentServerId = useServerStore((s) => s.currentServerId);
const isOpen = activeModal === 'createChannel';
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Channel name is required');
return;
}
if (!currentServerId) {
setError('No server selected');
return;
}
setIsLoading(true);
try {
await createChannel(currentServerId, name.trim(), type, topic.trim() || undefined);
closeModal();
setName('');
setTopic('');
setType('text');
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create channel');
}
finally {
setIsLoading(false);
}
};
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Create Channel", children: _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Channel Type" }), _jsx("div", { className: "space-y-2", children: ['text', 'voice', 'video'].map((t) => (_jsxs("label", { className: `flex items-center gap-3 p-3 rounded cursor-pointer border ${type === t
? 'border-discord-blurple bg-discord-bg-hover'
: 'border-discord-bg-tertiary bg-discord-bg-secondary hover:bg-discord-bg-hover'}`, children: [_jsx("input", { type: "radio", name: "channelType", value: t, checked: type === t, onChange: () => setType(t), className: "hidden" }), _jsxs("div", { className: "w-5 h-5 text-discord-text-muted", children: [t === 'text' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) })), t === 'voice' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) })), t === 'video' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }))] }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium text-discord-text-primary capitalize", children: t }), _jsxs("div", { className: "text-xs text-discord-text-muted", children: [t === 'text' && 'Send messages, images, and files', t === 'voice' && 'Hang out with voice and video', t === 'video' && 'Share your screen and camera'] })] })] }, t))) })] }), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Channel Name" }), _jsx("input", { type: "text", value: name, onChange: (e) => setName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "new-channel", autoFocus: true })] }), type === 'text' && (_jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Topic (optional)" }), _jsx("input", { type: "text", value: topic, onChange: (e) => setTopic(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "What's this channel about?" })] })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Creating...' : 'Create Channel' })] })] }) }));
}
@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
export function CreateChannelModal() {
const [name, setName] = useState('');
const [type, setType] = useState<'text' | 'voice' | 'video'>('text');
const [topic, setTopic] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const createChannel = useServerStore((s) => s.createChannel);
const currentServerId = useServerStore((s) => s.currentServerId);
const isOpen = activeModal === 'createChannel';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Channel name is required');
return;
}
if (!currentServerId) {
setError('No server selected');
return;
}
setIsLoading(true);
try {
await createChannel(currentServerId, name.trim(), type, topic.trim() || undefined);
closeModal();
setName('');
setTopic('');
setType('text');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create channel');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Create Channel">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Channel Type
</label>
<div className="space-y-2">
{(['text', 'voice', 'video'] as const).map((t) => (
<label
key={t}
className={`flex items-center gap-3 p-3 rounded cursor-pointer border ${
type === t
? 'border-discord-blurple bg-discord-bg-hover'
: 'border-discord-bg-tertiary bg-discord-bg-secondary hover:bg-discord-bg-hover'
}`}
>
<input
type="radio"
name="channelType"
value={t}
checked={type === t}
onChange={() => setType(t)}
className="hidden"
/>
<div className="w-5 h-5 text-discord-text-muted">
{t === 'text' && (
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" /></svg>
)}
{t === 'voice' && (
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" /></svg>
)}
{t === 'video' && (
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" /></svg>
)}
</div>
<div>
<div className="text-sm font-medium text-discord-text-primary capitalize">{t}</div>
<div className="text-xs text-discord-text-muted">
{t === 'text' && 'Send messages, images, and files'}
{t === 'voice' && 'Hang out with voice and video'}
{t === 'video' && 'Share your screen and camera'}
</div>
</div>
</label>
))}
</div>
</div>
<div className="mb-4">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Channel Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
placeholder="new-channel"
autoFocus
/>
</div>
{type === 'text' && (
<div className="mb-4">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Topic (optional)
</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
placeholder="What's this channel about?"
/>
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create Channel'}
</button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,38 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { useNavigate } from 'react-router-dom';
export function CreateServerModal() {
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const createServer = useServerStore((s) => s.createServer);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const navigate = useNavigate();
const isOpen = activeModal === 'createServer';
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Server name is required');
return;
}
setIsLoading(true);
try {
const server = await createServer(name.trim());
closeModal();
setName('');
navigate(`/channels/${server.id}`);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create server');
}
finally {
setIsLoading(false);
}
};
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Create a Server", children: _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Server Name" }), _jsx("input", { type: "text", value: name, onChange: (e) => setName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "My Awesome Server", autoFocus: true })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Creating...' : 'Create' })] })] }) }));
}
@@ -0,0 +1,80 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { useNavigate } from 'react-router-dom';
export function CreateServerModal() {
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const createServer = useServerStore((s) => s.createServer);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const navigate = useNavigate();
const isOpen = activeModal === 'createServer';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Server name is required');
return;
}
setIsLoading(true);
try {
const server = await createServer(name.trim());
closeModal();
setName('');
navigate(`/channels/${server.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create server');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Server">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Server Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
placeholder="My Awesome Server"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,46 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
export function InviteModal() {
const [inviteCode, setInviteCode] = useState('');
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const generateInvite = useServerStore((s) => s.generateInvite);
const currentServerId = useServerStore((s) => s.currentServerId);
const isOpen = activeModal === 'invite';
useEffect(() => {
if (isOpen && currentServerId) {
setIsLoading(true);
generateInvite(currentServerId)
.then(code => {
setInviteCode(code);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
}
}, [isOpen, currentServerId, generateInvite]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(inviteCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
catch {
// Fallback: select the text
const input = document.querySelector('.invite-code-input');
if (input) {
input.select();
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
};
return (_jsxs(Modal, { isOpen: isOpen, onClose: closeModal, title: "Invite Friends", children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Share this invite code with friends to let them join your server." }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "text", value: isLoading ? 'Generating...' : inviteCode, readOnly: true, className: "invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-sm" }), _jsx("button", { onClick: handleCopy, disabled: isLoading, className: `px-4 py-2 text-sm font-medium rounded transition-colors ${copied
? 'bg-discord-green text-white'
: 'bg-discord-blurple hover:bg-discord-blurple-hover text-white'}`, children: copied ? 'Copied!' : 'Copy' })] })] }));
}
@@ -0,0 +1,72 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
export function InviteModal() {
const [inviteCode, setInviteCode] = useState('');
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const generateInvite = useServerStore((s) => s.generateInvite);
const currentServerId = useServerStore((s) => s.currentServerId);
const isOpen = activeModal === 'invite';
useEffect(() => {
if (isOpen && currentServerId) {
setIsLoading(true);
generateInvite(currentServerId)
.then(code => {
setInviteCode(code);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
}
}, [isOpen, currentServerId, generateInvite]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(inviteCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select the text
const input = document.querySelector<HTMLInputElement>('.invite-code-input');
if (input) {
input.select();
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
<p className="text-discord-text-secondary text-sm mb-4">
Share this invite code with friends to let them join your server.
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={isLoading ? 'Generating...' : inviteCode}
readOnly
className="invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-sm"
/>
<button
onClick={handleCopy}
disabled={isLoading}
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
copied
? 'bg-discord-green text-white'
: 'bg-discord-blurple hover:bg-discord-blurple-hover text-white'
}`}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,52 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useNavigate } from 'react-router-dom';
export function JoinServerModal() {
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const loadServers = useServerStore((s) => s.loadServers);
const navigate = useNavigate();
const isOpen = activeModal === 'joinServer';
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!inviteCode.trim()) {
setError('Invite code is required');
return;
}
setIsLoading(true);
try {
const token = localStorage.getItem('opencord_token');
const response = await fetch('/api/servers/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ inviteCode: inviteCode.trim() }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to join server');
}
const server = await response.json();
await loadServers();
closeModal();
setInviteCode('');
navigate(`/channels/${server.id}`);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join server');
}
finally {
setIsLoading(false);
}
};
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Join a Server", children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Enter an invite code to join an existing server." }), error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Invite Code" }), _jsx("input", { type: "text", value: inviteCode, onChange: (e) => setInviteCode(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "e.g. abc123", autoFocus: true })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Joining...' : 'Join Server' })] })] }) }));
}
@@ -0,0 +1,99 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useNavigate } from 'react-router-dom';
export function JoinServerModal() {
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const loadServers = useServerStore((s) => s.loadServers);
const navigate = useNavigate();
const isOpen = activeModal === 'joinServer';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!inviteCode.trim()) {
setError('Invite code is required');
return;
}
setIsLoading(true);
try {
const token = localStorage.getItem('opencord_token');
const response = await fetch('/api/servers/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ inviteCode: inviteCode.trim() }),
});
if (!response.ok) {
const data = await response.json() as { error: string };
throw new Error(data.error || 'Failed to join server');
}
const server = await response.json() as { id: string };
await loadServers();
closeModal();
setInviteCode('');
navigate(`/channels/${server.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join server');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Server">
<form onSubmit={handleSubmit}>
<p className="text-discord-text-secondary text-sm mb-4">
Enter an invite code to join an existing server.
</p>
{error && (
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Invite Code
</label>
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
placeholder="e.g. abc123"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Joining...' : 'Join Server'}
</button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,84 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { useNavigate } from 'react-router-dom';
export function ServerSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const members = useServerStore((s) => s.members);
const updateServer = useServerStore((s) => s.updateServer);
const deleteServer = useServerStore((s) => s.deleteServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const currentUser = useAuthStore((s) => s.user);
const navigate = useNavigate();
const [tab, setTab] = useState('overview');
const [serverName, setServerName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const isOpen = activeModal === 'serverSettings';
const server = servers.find(s => s.id === currentServerId);
const isOwnerUser = server?.ownerId === currentUser?.id;
React.useEffect(() => {
if (server) {
setServerName(server.name);
}
}, [server]);
if (!server || !currentServerId)
return null;
const handleSave = async () => {
setError('');
setIsLoading(true);
try {
await updateServer(currentServerId, { name: serverName.trim() });
setIsLoading(false);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update server');
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
try {
await deleteServer(currentServerId);
closeModal();
navigate('/channels/@me');
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete server');
}
};
const handleRoleChange = async (userId, role) => {
try {
await api.servers.updateMember(currentServerId, userId, { role });
await loadServerDetail(currentServerId);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
const handleKick = async (userId) => {
try {
await api.servers.removeMember(currentServerId, userId);
await loadServerDetail(currentServerId);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to kick member');
}
};
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Server Settings", maxWidth: "max-w-xl", children: _jsxs("div", { className: "flex gap-4", children: [_jsxs("div", { className: "w-32 flex-shrink-0 space-y-1", children: [_jsx("button", { onClick: () => setTab('overview'), className: `w-full text-left px-3 py-1.5 rounded text-sm ${tab === 'overview' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: "Overview" }), _jsx("button", { onClick: () => setTab('members'), className: `w-full text-left px-3 py-1.5 rounded text-sm ${tab === 'members' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: "Members" })] }), _jsxs("div", { className: "flex-1 min-w-0", children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), tab === 'overview' && (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Server Name" }), _jsx("input", { type: "text", value: serverName, onChange: (e) => setServerName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", disabled: !isOwnerUser })] }), isOwnerUser && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: handleSave, disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Saving...' : 'Save Changes' }), _jsxs("div", { className: "pt-4 border-t border-discord-bg-tertiary", children: [_jsx("h3", { className: "text-sm font-bold text-discord-red mb-2", children: "Danger Zone" }), _jsx("button", { onClick: handleDelete, className: "px-4 py-2 bg-discord-red hover:bg-discord-red-hover text-white text-sm font-medium rounded transition-colors", children: confirmDelete ? 'Click again to confirm deletion' : 'Delete Server' })] })] }))] })), tab === 'members' && (_jsx("div", { className: "space-y-2 max-h-[400px] overflow-y-auto", children: members.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { className: "flex items-center justify-between p-2 rounded hover:bg-discord-bg-hover", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: member.user.status }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", children: displayName }), _jsx("div", { className: "text-xs text-discord-text-muted capitalize", children: member.role })] })] }), isOwnerUser && member.userId !== currentUser?.id && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("select", { value: member.role, onChange: (e) => handleRoleChange(member.userId, e.target.value), className: "px-2 py-1 bg-discord-bg-tertiary rounded text-xs text-discord-text-secondary outline-none", children: [_jsx("option", { value: "member", children: "Member" }), _jsx("option", { value: "admin", children: "Admin" })] }), _jsx("button", { onClick: () => handleKick(member.userId), className: "px-2 py-1 text-xs text-discord-red hover:bg-discord-red/10 rounded transition-colors", children: "Kick" })] }))] }, member.userId));
}) }))] })] }) }));
}
@@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { useNavigate } from 'react-router-dom';
import type { MemberRole } from '@opencord/shared';
export function ServerSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const members = useServerStore((s) => s.members);
const updateServer = useServerStore((s) => s.updateServer);
const deleteServer = useServerStore((s) => s.deleteServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const currentUser = useAuthStore((s) => s.user);
const navigate = useNavigate();
const [tab, setTab] = useState<'overview' | 'members'>('overview');
const [serverName, setServerName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const isOpen = activeModal === 'serverSettings';
const server = servers.find(s => s.id === currentServerId);
const isOwnerUser = server?.ownerId === currentUser?.id;
React.useEffect(() => {
if (server) {
setServerName(server.name);
}
}, [server]);
if (!server || !currentServerId) return null;
const handleSave = async () => {
setError('');
setIsLoading(true);
try {
await updateServer(currentServerId, { name: serverName.trim() });
setIsLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update server');
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
try {
await deleteServer(currentServerId);
closeModal();
navigate('/channels/@me');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete server');
}
};
const handleRoleChange = async (userId: string, role: MemberRole) => {
try {
await api.servers.updateMember(currentServerId, userId, { role });
await loadServerDetail(currentServerId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
const handleKick = async (userId: string) => {
try {
await api.servers.removeMember(currentServerId, userId);
await loadServerDetail(currentServerId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to kick member');
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Server Settings" maxWidth="max-w-xl">
<div className="flex gap-4">
{/* Tabs */}
<div className="w-32 flex-shrink-0 space-y-1">
<button
onClick={() => setTab('overview')}
className={`w-full text-left px-3 py-1.5 rounded text-sm ${
tab === 'overview' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
}`}
>
Overview
</button>
<button
onClick={() => setTab('members')}
className={`w-full text-left px-3 py-1.5 rounded text-sm ${
tab === 'members' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
}`}
>
Members
</button>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{error && (
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">{error}</div>
)}
{tab === 'overview' && (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Server Name
</label>
<input
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
disabled={!isOwnerUser}
/>
</div>
{isOwnerUser && (
<>
<button
onClick={handleSave}
disabled={isLoading}
className="px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
<div className="pt-4 border-t border-discord-bg-tertiary">
<h3 className="text-sm font-bold text-discord-red mb-2">Danger Zone</h3>
<button
onClick={handleDelete}
className="px-4 py-2 bg-discord-red hover:bg-discord-red-hover text-white text-sm font-medium rounded transition-colors"
>
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Server'}
</button>
</div>
</>
)}
</div>
)}
{tab === 'members' && (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{members.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div key={member.userId} className="flex items-center justify-between p-2 rounded hover:bg-discord-bg-hover">
<div className="flex items-center gap-2">
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status={member.user.status}
/>
<div>
<div className="text-sm font-medium">{displayName}</div>
<div className="text-xs text-discord-text-muted capitalize">{member.role}</div>
</div>
</div>
{isOwnerUser && member.userId !== currentUser?.id && (
<div className="flex items-center gap-2">
<select
value={member.role}
onChange={(e) => handleRoleChange(member.userId, e.target.value as MemberRole)}
className="px-2 py-1 bg-discord-bg-tertiary rounded text-xs text-discord-text-secondary outline-none"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button
onClick={() => handleKick(member.userId)}
className="px-2 py-1 text-xs text-discord-red hover:bg-discord-red/10 rounded transition-colors"
>
Kick
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,45 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
export function UserSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const user = useAuthStore((s) => s.user);
const updateProfile = useAuthStore((s) => s.updateProfile);
const logout = useAuthStore((s) => s.logout);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const isOpen = activeModal === 'userSettings';
const handleSave = async () => {
setError('');
setSuccess('');
setIsLoading(true);
try {
await updateProfile({
displayName: displayName.trim() || undefined,
customStatus: customStatus.trim() || undefined,
});
setSuccess('Profile updated!');
setTimeout(() => setSuccess(''), 2000);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile');
}
finally {
setIsLoading(false);
}
};
const handleLogout = () => {
logout();
closeModal();
};
if (!user)
return null;
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "User Settings", maxWidth: "max-w-lg", children: _jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center gap-4 p-4 bg-discord-bg-secondary rounded-lg", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 64, status: user.status }), _jsxs("div", { children: [_jsx("div", { className: "font-bold text-lg", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-discord-text-muted text-sm", children: ["@", user.username] }), user.customStatus && (_jsx("div", { className: "text-discord-text-secondary text-sm mt-1", children: user.customStatus }))] })] }), error && (_jsx("div", { className: "p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), success && (_jsx("div", { className: "p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-green text-sm", children: success })), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Display Name" }), _jsx("input", { type: "text", value: displayName, onChange: (e) => setDisplayName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple" })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Custom Status" }), _jsx("input", { type: "text", value: customStatus, onChange: (e) => setCustomStatus(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "What are you up to?" })] }), _jsxs("div", { className: "flex items-center justify-between pt-2", children: [_jsx("button", { onClick: handleLogout, className: "px-4 py-2 text-sm text-discord-red hover:bg-discord-red/10 rounded transition-colors", children: "Log Out" }), _jsx("button", { onClick: handleSave, disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Saving...' : 'Save Changes' })] })] }) }));
}
@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
export function UserSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const user = useAuthStore((s) => s.user);
const updateProfile = useAuthStore((s) => s.updateProfile);
const logout = useAuthStore((s) => s.logout);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const isOpen = activeModal === 'userSettings';
const handleSave = async () => {
setError('');
setSuccess('');
setIsLoading(true);
try {
await updateProfile({
displayName: displayName.trim() || undefined,
customStatus: customStatus.trim() || undefined,
});
setSuccess('Profile updated!');
setTimeout(() => setSuccess(''), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile');
} finally {
setIsLoading(false);
}
};
const handleLogout = () => {
logout();
closeModal();
};
if (!user) return null;
return (
<Modal isOpen={isOpen} onClose={closeModal} title="User Settings" maxWidth="max-w-lg">
<div className="space-y-6">
{/* Profile preview */}
<div className="flex items-center gap-4 p-4 bg-discord-bg-secondary rounded-lg">
<Avatar
src={user.avatar}
name={user.displayName ?? user.username}
size={64}
status={user.status}
/>
<div>
<div className="font-bold text-lg">{user.displayName ?? user.username}</div>
<div className="text-discord-text-muted text-sm">@{user.username}</div>
{user.customStatus && (
<div className="text-discord-text-secondary text-sm mt-1">{user.customStatus}</div>
)}
</div>
</div>
{error && (
<div className="p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">{error}</div>
)}
{success && (
<div className="p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-green text-sm">{success}</div>
)}
<div>
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
/>
</div>
<div>
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Custom Status
</label>
<input
type="text"
value={customStatus}
onChange={(e) => setCustomStatus(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
placeholder="What are you up to?"
/>
</div>
<div className="flex items-center justify-between pt-2">
<button
onClick={handleLogout}
className="px-4 py-2 text-sm text-discord-red hover:bg-discord-red/10 rounded transition-colors"
>
Log Out
</button>
<button
onClick={handleSave}
disabled={isLoading}
className="px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</Modal>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const statusColors = {
online: 'bg-discord-green',
idle: 'bg-discord-yellow',
dnd: 'bg-discord-red',
offline: 'bg-gray-500',
};
export function Avatar({ src, name, size = 40, status, className = '', onClick }) {
const initials = name.charAt(0).toUpperCase();
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
return (_jsxs("div", { className: `relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`, style: { width: size, height: size }, onClick: onClick, children: [src ? (_jsx("img", { src: src.startsWith('http') ? src : `/api/uploads/${src}`, alt: name, className: "w-full h-full rounded-full object-cover", onError: (e) => {
e.target.style.display = 'none';
const parent = e.target.parentElement;
if (parent) {
const fallback = parent.querySelector('.avatar-fallback');
if (fallback)
fallback.style.display = 'flex';
}
} })) : null, _jsx("div", { className: `avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`, style: src ? { display: 'none' } : undefined, children: initials }), status && (_jsx("div", { className: `absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-gray-500'}`, style: {
width: size * 0.35,
height: size * 0.35,
minWidth: 10,
minHeight: 10,
} }))] }));
}
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
interface AvatarProps {
src?: string | null;
name: string;
size?: number;
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
className?: string;
onClick?: () => void;
}
const statusColors: Record<string, string> = {
online: 'bg-discord-green',
idle: 'bg-discord-yellow',
dnd: 'bg-discord-red',
offline: 'bg-gray-500',
};
export function Avatar({ src, name, size = 40, status, className = '', onClick }: AvatarProps) {
const initials = name.charAt(0).toUpperCase();
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
return (
<div
className={`relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`}
style={{ width: size, height: size }}
onClick={onClick}
>
{src ? (
<img
src={src.startsWith('http') ? src : `/api/uploads/${src}`}
alt={name}
className="w-full h-full rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {
const fallback = parent.querySelector('.avatar-fallback') as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}
}}
/>
) : null}
<div
className={`avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`}
style={src ? { display: 'none' } : undefined}
>
{initials}
</div>
{status && (
<div
className={`absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-gray-500'}`}
style={{
width: size * 0.35,
height: size * 0.35,
minWidth: 10,
minHeight: 10,
}}
/>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useRef, useState } from 'react';
export function ContextMenu({ items, children }) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef(null);
const handleContextMenu = (e) => {
e.preventDefault();
setPosition({ x: e.clientX, y: e.clientY });
setIsOpen(true);
};
useEffect(() => {
const handleClick = () => setIsOpen(false);
const handleScroll = () => setIsOpen(false);
if (isOpen) {
document.addEventListener('click', handleClick);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('scroll', handleScroll, true);
};
}
}, [isOpen]);
// Adjust position to keep menu in viewport
useEffect(() => {
if (isOpen && menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const newPosition = { ...position };
if (rect.right > window.innerWidth) {
newPosition.x = window.innerWidth - rect.width - 8;
}
if (rect.bottom > window.innerHeight) {
newPosition.y = window.innerHeight - rect.height - 8;
}
if (newPosition.x !== position.x || newPosition.y !== position.y) {
setPosition(newPosition);
}
}
}, [isOpen, position]);
return (_jsxs(_Fragment, { children: [_jsx("div", { onContextMenu: handleContextMenu, children: children }), isOpen && (_jsx("div", { ref: menuRef, className: "fixed z-50 min-w-[180px] py-1.5 bg-[#111214] rounded-md shadow-xl border border-gray-800 animate-fade-in", style: { left: position.x, top: position.y }, children: items.map((item, i) => (_jsxs("button", { className: `w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 ${item.danger
? 'text-discord-red hover:bg-discord-red hover:text-white'
: 'text-discord-text-secondary hover:bg-discord-blurple hover:text-white'}`, style: { width: 'calc(100% - 12px)' }, onClick: (e) => {
e.stopPropagation();
item.onClick();
setIsOpen(false);
}, children: [item.icon && _jsx("span", { className: "w-4 h-4", children: item.icon }), item.label] }, i))) }))] }));
}
@@ -0,0 +1,91 @@
import React, { useEffect, useRef, useState } from 'react';
interface ContextMenuItem {
label: string;
onClick: () => void;
danger?: boolean;
icon?: React.ReactNode;
}
interface ContextMenuProps {
items: ContextMenuItem[];
children: React.ReactNode;
}
export function ContextMenu({ items, children }: ContextMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef<HTMLDivElement>(null);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setPosition({ x: e.clientX, y: e.clientY });
setIsOpen(true);
};
useEffect(() => {
const handleClick = () => setIsOpen(false);
const handleScroll = () => setIsOpen(false);
if (isOpen) {
document.addEventListener('click', handleClick);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('scroll', handleScroll, true);
};
}
}, [isOpen]);
// Adjust position to keep menu in viewport
useEffect(() => {
if (isOpen && menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const newPosition = { ...position };
if (rect.right > window.innerWidth) {
newPosition.x = window.innerWidth - rect.width - 8;
}
if (rect.bottom > window.innerHeight) {
newPosition.y = window.innerHeight - rect.height - 8;
}
if (newPosition.x !== position.x || newPosition.y !== position.y) {
setPosition(newPosition);
}
}
}, [isOpen, position]);
return (
<>
<div onContextMenu={handleContextMenu}>{children}</div>
{isOpen && (
<div
ref={menuRef}
className="fixed z-50 min-w-[180px] py-1.5 bg-[#111214] rounded-md shadow-xl border border-gray-800 animate-fade-in"
style={{ left: position.x, top: position.y }}
>
{items.map((item, i) => (
<button
key={i}
className={`w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 ${
item.danger
? 'text-discord-red hover:bg-discord-red hover:text-white'
: 'text-discord-text-secondary hover:bg-discord-blurple hover:text-white'
}`}
style={{ width: 'calc(100% - 12px)' }}
onClick={(e) => {
e.stopPropagation();
item.onClick();
setIsOpen(false);
}}
>
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
{item.label}
</button>
))}
</div>
)}
</>
);
}
@@ -0,0 +1,4 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export function LoadingSpinner({ size = 40, className = '' }) {
return (_jsx("div", { className: `flex items-center justify-center ${className}`, children: _jsxs("svg", { className: "animate-spin", width: size, height: size, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }) }));
}
@@ -0,0 +1,34 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: number;
className?: string;
}
export function LoadingSpinner({ size = 40, className = '' }: LoadingSpinnerProps) {
return (
<div className={`flex items-center justify-center ${className}`}>
<svg
className="animate-spin"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useCallback } from 'react';
export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }) {
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, handleKeyDown]);
if (!isOpen)
return null;
return (_jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center animate-fade-in", children: [_jsx("div", { className: "absolute inset-0 bg-black/70", onClick: onClose }), _jsxs("div", { className: `relative ${maxWidth} w-full mx-4 bg-discord-bg-primary rounded-lg shadow-xl animate-slide-up`, children: [title && (_jsxs("div", { className: "flex items-center justify-between px-4 pt-4", children: [_jsx("h2", { className: "text-xl font-bold text-discord-text-primary", children: title }), _jsx("button", { onClick: onClose, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors p-1", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] })), _jsx("div", { className: "p-4", children: children })] })] }));
}
+53
View File
@@ -0,0 +1,53 @@
import React, { useEffect, useCallback } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
maxWidth?: string;
}
export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }: ModalProps) {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<div
className="absolute inset-0 bg-black/70"
onClick={onClose}
/>
<div className={`relative ${maxWidth} w-full mx-4 bg-discord-bg-primary rounded-lg shadow-xl animate-slide-up`}>
{title && (
<div className="flex items-center justify-between px-4 pt-4">
<h2 className="text-xl font-bold text-discord-text-primary">{title}</h2>
<button
onClick={onClose}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors p-1"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
)}
<div className="p-4">
{children}
</div>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useRef, useEffect } from 'react';
export function Tooltip({ content, children, position = 'right', delay = 200 }) {
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef();
const show = () => {
timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
};
const hide = () => {
if (timeoutRef.current)
clearTimeout(timeoutRef.current);
setIsVisible(false);
};
useEffect(() => {
return () => {
if (timeoutRef.current)
clearTimeout(timeoutRef.current);
};
}, []);
const positionClasses = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
};
return (_jsxs("div", { className: "relative inline-flex", onMouseEnter: show, onMouseLeave: hide, children: [children, isVisible && (_jsx("div", { className: `absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-gray-900 rounded-md shadow-lg whitespace-nowrap pointer-events-none ${positionClasses[position]}`, children: content }))] }));
}
@@ -0,0 +1,48 @@
import React, { useState, useRef, useEffect } from 'react';
interface TooltipProps {
content: string;
children: React.ReactNode;
position?: 'top' | 'right' | 'bottom' | 'left';
delay?: number;
}
export function Tooltip({ content, children, position = 'right', delay = 200 }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const show = () => {
timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
};
const hide = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(false);
};
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
const positionClasses: Record<string, string> = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
};
return (
<div className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
{children}
{isVisible && (
<div
className={`absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-gray-900 rounded-md shadow-lg whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
>
{content}
</div>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useVoiceStore } from '../../stores/voiceStore';
const EMPTY_VOICE_USERS = [];
import { useServerStore } from '../../stores/serverStore';
import { Avatar } from '../ui/Avatar';
export function VoiceChannel({ channelId, channelName, onClick }) {
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId;
return (_jsxs("div", { children: [_jsxs("button", { onClick: onClick, className: `w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group ${isActive
? 'bg-discord-bg-active text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" }) }), _jsx("span", { className: "truncate", children: channelName })] }), voiceUsers.length > 0 && (_jsx("div", { className: "ml-6 mt-0.5 space-y-0.5", children: voiceUsers.map((userId) => {
const member = members.find(m => m.userId === userId);
if (!member)
return null;
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { className: "flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-bg-hover", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 20, status: member.user.status }), _jsx("span", { className: "text-xs text-discord-text-secondary truncate", children: displayName })] }, userId));
}) }))] }));
}
@@ -0,0 +1,59 @@
import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
const EMPTY_VOICE_USERS: string[] = [];
import { useServerStore } from '../../stores/serverStore';
import { Avatar } from '../ui/Avatar';
interface VoiceChannelProps {
channelId: string;
channelName: string;
onClick: () => void;
}
export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelProps) {
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId;
return (
<div>
<button
onClick={onClick}
className={`w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group ${
isActive
? 'bg-discord-bg-active text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
}`}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-60">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" />
</svg>
<span className="truncate">{channelName}</span>
</button>
{/* Connected users */}
{voiceUsers.length > 0 && (
<div className="ml-6 mt-0.5 space-y-0.5">
{voiceUsers.map((userId) => {
const member = members.find(m => m.userId === userId);
if (!member) return null;
const displayName = member.user.displayName ?? member.user.username;
return (
<div key={userId} className="flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-bg-hover">
<Avatar
src={member.user.avatar}
name={displayName}
size={20}
status={member.user.status}
/>
<span className="text-xs text-discord-text-secondary truncate">{displayName}</span>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onToggleScreenShare }) {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMute = useVoiceStore((s) => s.toggleMute);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const channels = useServerStore((s) => s.channels);
if (!currentVoiceChannelId)
return null;
const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? 'Voice Channel';
const handleMic = () => {
toggleMute();
onToggleMic();
};
const handleDeafen = () => {
toggleDeafen();
};
const handleCamera = () => {
toggleCamera();
onToggleCamera();
};
const handleScreenShare = () => {
toggleScreenShare();
onToggleScreenShare();
};
return (_jsxs("div", { className: "bg-discord-bg-secondary border-t border-discord-bg-tertiary p-2", children: [_jsx("div", { className: "flex items-center justify-between px-1 mb-2", children: _jsxs("div", { children: [_jsxs("div", { className: "text-xs font-medium text-discord-green flex items-center gap-1", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" }) }), "Voice Connected"] }), _jsx("div", { className: "text-xs text-discord-text-muted truncate", children: channelName })] }) }), _jsxs("div", { className: "flex items-center justify-center gap-2", children: [_jsx("button", { onClick: handleMic, className: `p-2 rounded-full transition-colors ${isMuted
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isMuted ? 'Unmute' : 'Mute', children: isMuted ? (_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("path", { d: "M2.1 2.1L1.4 2.8L7.6 9L7 12C7 14.8 9.2 17 12 17C12.9 17 13.7 16.7 14.4 16.3L16.2 18.1C15 18.9 13.6 19.4 12 19.5V22H14V24H10V22H12V19.5C8.4 19.1 5.6 16.1 5 12.5H7C7.5 14.8 9.5 16.5 12 16.5C12.5 16.5 13 16.4 13.5 16.2L14.7 17.4C13.9 17.8 13 18 12 18C8.7 18 6 15.3 6 12H4C4 15.7 7 18.8 11 19.4V22H10V24H14V22H13V19.4C14 19.3 14.9 18.9 15.7 18.4L21.9 24.6L22.6 23.9L2.1 2.1Z" })] })) : (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2ZM17 12C17 14.76 14.76 17 12 17S7 14.76 7 12H5C5 15.53 7.61 18.43 11 18.92V22H13V18.92C16.39 18.43 19 15.53 19 12H17Z" }) })) }), _jsx("button", { onClick: handleDeafen, className: `p-2 rounded-full transition-colors ${isDeafened
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isDeafened ? 'Undeafen' : 'Deafen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12V20C2 21.1 2.9 22 4 22H8V12H4.04C4.28 7.57 7.77 4 12 4S19.72 7.57 19.96 12H16V22H20C21.1 22 22 21.1 22 20V12C22 6.48 17.52 2 12 2Z" }) }) }), _jsx("button", { onClick: handleCamera, className: `p-2 rounded-full transition-colors ${isCameraOn
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }) }), _jsx("button", { onClick: handleScreenShare, className: `p-2 rounded-full transition-colors ${isScreenSharing
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }) }) }), _jsx("button", { onClick: onDisconnect, className: "p-2 rounded-full bg-discord-red/20 text-discord-red hover:bg-discord-red/30 transition-colors", title: "Disconnect", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] })] }));
}
@@ -0,0 +1,145 @@
import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
interface VoiceControlsProps {
onDisconnect: () => void;
onToggleMic: () => void;
onToggleCamera: () => void;
onToggleScreenShare: () => void;
}
export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onToggleScreenShare }: VoiceControlsProps) {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMute = useVoiceStore((s) => s.toggleMute);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const channels = useServerStore((s) => s.channels);
if (!currentVoiceChannelId) return null;
const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? 'Voice Channel';
const handleMic = () => {
toggleMute();
onToggleMic();
};
const handleDeafen = () => {
toggleDeafen();
};
const handleCamera = () => {
toggleCamera();
onToggleCamera();
};
const handleScreenShare = () => {
toggleScreenShare();
onToggleScreenShare();
};
return (
<div className="bg-discord-bg-secondary border-t border-discord-bg-tertiary p-2">
<div className="flex items-center justify-between px-1 mb-2">
<div>
<div className="text-xs font-medium text-discord-green flex items-center gap-1">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
</svg>
Voice Connected
</div>
<div className="text-xs text-discord-text-muted truncate">
{channelName}
</div>
</div>
</div>
<div className="flex items-center justify-center gap-2">
{/* Mic */}
<button
onClick={handleMic}
className={`p-2 rounded-full transition-colors ${
isMuted
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
}`}
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" />
<path d="M2.1 2.1L1.4 2.8L7.6 9L7 12C7 14.8 9.2 17 12 17C12.9 17 13.7 16.7 14.4 16.3L16.2 18.1C15 18.9 13.6 19.4 12 19.5V22H14V24H10V22H12V19.5C8.4 19.1 5.6 16.1 5 12.5H7C7.5 14.8 9.5 16.5 12 16.5C12.5 16.5 13 16.4 13.5 16.2L14.7 17.4C13.9 17.8 13 18 12 18C8.7 18 6 15.3 6 12H4C4 15.7 7 18.8 11 19.4V22H10V24H14V22H13V19.4C14 19.3 14.9 18.9 15.7 18.4L21.9 24.6L22.6 23.9L2.1 2.1Z" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2ZM17 12C17 14.76 14.76 17 12 17S7 14.76 7 12H5C5 15.53 7.61 18.43 11 18.92V22H13V18.92C16.39 18.43 19 15.53 19 12H17Z" />
</svg>
)}
</button>
{/* Deafen */}
<button
onClick={handleDeafen}
className={`p-2 rounded-full transition-colors ${
isDeafened
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
}`}
title={isDeafened ? 'Undeafen' : 'Deafen'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12V20C2 21.1 2.9 22 4 22H8V12H4.04C4.28 7.57 7.77 4 12 4S19.72 7.57 19.96 12H16V22H20C21.1 22 22 21.1 22 20V12C22 6.48 17.52 2 12 2Z" />
</svg>
</button>
{/* Camera */}
<button
onClick={handleCamera}
className={`p-2 rounded-full transition-colors ${
isCameraOn
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
}`}
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
</svg>
</button>
{/* Screen Share */}
<button
onClick={handleScreenShare}
className={`p-2 rounded-full transition-colors ${
isScreenSharing
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
}`}
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
</svg>
</button>
{/* Disconnect */}
<button
onClick={onDisconnect}
className="p-2 rounded-full bg-discord-red/20 text-discord-red hover:bg-discord-red/30 transition-colors"
title="Disconnect"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" />
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { VoiceUser } from './VoiceUser';
export function VoiceGrid({ participants }) {
if (participants.length === 0) {
return (_jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsx("p", { children: "No one is in this voice channel" }) }));
}
const gridClass = (() => {
if (participants.length === 1)
return 'grid-cols-1 max-w-2xl mx-auto';
if (participants.length === 2)
return 'grid-cols-2 max-w-4xl mx-auto';
if (participants.length <= 4)
return 'grid-cols-2';
return 'grid-cols-3';
})();
return (_jsx("div", { className: "flex-1 p-4 overflow-auto", children: _jsx("div", { className: `grid ${gridClass} gap-2 h-full`, children: participants.map((p) => (_jsx(VoiceUser, { participant: p }, p.identity))) }) }));
}
@@ -0,0 +1,34 @@
import React from 'react';
import { VoiceUser } from './VoiceUser';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
interface VoiceGridProps {
participants: ParticipantInfo[];
}
export function VoiceGrid({ participants }: VoiceGridProps) {
if (participants.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
<p>No one is in this voice channel</p>
</div>
);
}
const gridClass = (() => {
if (participants.length === 1) return 'grid-cols-1 max-w-2xl mx-auto';
if (participants.length === 2) return 'grid-cols-2 max-w-4xl mx-auto';
if (participants.length <= 4) return 'grid-cols-2';
return 'grid-cols-3';
})();
return (
<div className="flex-1 p-4 overflow-auto">
<div className={`grid ${gridClass} gap-2 h-full`}>
{participants.map((p) => (
<VoiceUser key={p.identity} participant={p} />
))}
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useRef, useEffect } from 'react';
import { Avatar } from '../ui/Avatar';
export function VoiceUser({ participant }) {
const videoRef = useRef(null);
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl)
return;
const track = participant.videoTrack ?? participant.screenTrack;
if (track) {
const stream = new MediaStream([track]);
videoEl.srcObject = stream;
}
else {
videoEl.srcObject = null;
}
}, [participant.videoTrack, participant.screenTrack]);
const hasVideo = participant.isCameraOn || participant.isScreenSharing;
return (_jsxs("div", { className: `relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center ${participant.isSpeaking ? 'ring-2 ring-discord-green' : ''}`, style: { aspectRatio: '16/9', minHeight: '200px' }, children: [hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: participant.userId === 'local', className: "w-full h-full object-cover" })) : (_jsx(Avatar, { src: null, name: participant.username, size: 80 })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm font-medium text-white", children: participant.username }), _jsx("div", { className: "flex items-center gap-1", children: participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })) })] }) }), participant.isSpeaking && (_jsx("div", { className: "absolute inset-0 rounded-xl ring-2 ring-discord-green animate-pulse pointer-events-none" }))] }));
}
@@ -0,0 +1,73 @@
import React, { useRef, useEffect } from 'react';
import { Avatar } from '../ui/Avatar';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
interface VoiceUserProps {
participant: ParticipantInfo;
}
export function VoiceUser({ participant }: VoiceUserProps) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
const track = participant.videoTrack ?? participant.screenTrack;
if (track) {
const stream = new MediaStream([track]);
videoEl.srcObject = stream;
} else {
videoEl.srcObject = null;
}
}, [participant.videoTrack, participant.screenTrack]);
const hasVideo = participant.isCameraOn || participant.isScreenSharing;
return (
<div
className={`relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center ${
participant.isSpeaking ? 'ring-2 ring-discord-green' : ''
}`}
style={{ aspectRatio: '16/9', minHeight: '200px' }}
>
{hasVideo ? (
<video
ref={videoRef}
autoPlay
playsInline
muted={participant.userId === 'local'}
className="w-full h-full object-cover"
/>
) : (
<Avatar
src={null}
name={participant.username}
size={80}
/>
)}
{/* Bottom overlay */}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{participant.username}</span>
<div className="flex items-center gap-1">
{participant.isMuted && (
<div className="w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<path d="M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="white" strokeWidth="2" />
</svg>
</div>
)}
</div>
</div>
</div>
{/* Speaking indicator */}
{participant.isSpeaking && (
<div className="absolute inset-0 rounded-xl ring-2 ring-discord-green animate-pulse pointer-events-none" />
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
export function useAuth() {
const token = useAuthStore((s) => s.token);
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const loadUser = useAuthStore((s) => s.loadUser);
const navigate = useNavigate();
useEffect(() => {
if (!token) {
navigate('/login');
return;
}
if (!user && !isLoading) {
loadUser();
}
}, [token, user, isLoading, loadUser, navigate]);
return { user, isLoading, isAuthenticated: !!token };
}

Some files were not shown because too many files have changed in this diff Show More