chore: Initial commit of Opencord base state
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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/
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../shared" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 }) })] }));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
{' '}• 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
} }))] }));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 })] })] }));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user