feat: add Backspace design prototype and rebrand from Opencord
- Add Backspace-design-prototype.html: finalized "Aether Drift" design language with warm matte surfaces and subtle frosted glass accents - Update CLAUDE.md with DESIGN SYSTEM section and remove Discord clone references - Rename all Opencord references to Backspace across the full codebase - Archive outdated design experiments and Discord-specific assets - Add science-backed accessibility fallback (prefers-reduced-transparency)
This commit is contained in:
+2
-2
@@ -1,4 +1,4 @@
|
||||
# Opencord Configuration
|
||||
# Backspace Configuration
|
||||
# Copy this file to .env and fill in the values
|
||||
|
||||
# Server
|
||||
@@ -15,7 +15,7 @@ LIVEKIT_API_SECRET=
|
||||
|
||||
# Storage
|
||||
UPLOAD_DIR=./data/uploads
|
||||
DB_PATH=./data/opencord.db
|
||||
DB_PATH=./data/backspace.db
|
||||
MAX_UPLOAD_SIZE=104857600
|
||||
|
||||
# Registration — set to false to disable new user registration
|
||||
|
||||
@@ -1,55 +1,76 @@
|
||||
# CLAUDE.md — Opencord Brain File
|
||||
# CLAUDE.md — Backspace 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.
|
||||
You are the sole developer of Backspace, 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.
|
||||
|
||||
## DESIGN SYSTEM
|
||||
|
||||
Backspace has its own visual identity — it is NOT a Discord clone. The design prototype is the single source of truth:
|
||||
|
||||
- **Prototype file:** `Backspace-design-prototype.html` (open in browser to view)
|
||||
- **Design language:** "Aether Drift" — warm matte surfaces with subtle frosted glass accents
|
||||
- **Two-material system:** Solid matte panels for content (75%), frosted glass bubbles for persistent controls (25%)
|
||||
- **Color palette:** Warm dark surfaces (#13131a chat, #1a1a23 sidebars), pastel accents (mint, peach, lavender, sky, amber, rose, coral)
|
||||
- **Glass elements:** Server strip (left column), voice+user bubble (bottom-left, crosses over server strip), input bubble (bottom of chat)
|
||||
- **Glass material:** `backdrop-filter: blur(20px) saturate(120%)`, warm-tinted `rgba(20,20,26,0.52)`, subtle 0.07 opacity borders
|
||||
- **Key principles:** Calm over flashy. Warm over cool. Quiet glass (felt, not seen). No decorative gradients. Minimal shadows.
|
||||
- **Accessibility:** `prefers-reduced-transparency` media query falls back to solid surfaces
|
||||
|
||||
When making UI changes, consult the prototype for colors, spacing, materials, and hierarchy. The frontend should converge toward this design.
|
||||
|
||||
## 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.
|
||||
Maintain and extend Backspace as a complete, production-quality application. The core application is fully built and deployed. Every change must uphold the same standard: no stubs, no TODOs, no shortcuts. A user must always be able to `docker compose up` and have a fully working chat platform.
|
||||
|
||||
## 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 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.
|
||||
- Write production-quality code from the start. Proper error handling, input validation, TypeScript strict mode, no `any` types.
|
||||
- If something fails, FIX IT before moving on. Never leave broken code behind.
|
||||
- Test changes with `pnpm dev` before considering them done. Both server and frontend must start without errors.
|
||||
|
||||
## 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 |
|
||||
| Backend Framework | Fastify | fastify, @fastify/cors, @fastify/multipart, @fastify/static, @fastify/websocket, @fastify/rate-limit |
|
||||
| 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 |
|
||||
| Build Tool | Vite 6 | vite, @vitejs/plugin-react |
|
||||
| State Management | Zustand 5 | zustand |
|
||||
| Voice/Video | LiveKit Client SDK | livekit-client, @livekit/components-react |
|
||||
| LiveKit Token | livekit-server-sdk | livekit-server-sdk |
|
||||
| Desktop | Electron | electron, electron-builder |
|
||||
| Audio Processing | Web Noise Suppressor (RNNoise) | @sapphi-red/web-noise-suppressor |
|
||||
| Markdown | react-markdown + remark-gfm | react-markdown, remark-gfm |
|
||||
| Syntax Highlighting | prism-react-renderer | prism-react-renderer |
|
||||
| HTML Parsing | Cheerio (server-side URL metadata) | cheerio |
|
||||
| Desktop | Electron 33 | electron, electron-builder |
|
||||
| Testing | Vitest + Testing Library | vitest, @testing-library/react, jsdom |
|
||||
| Monorepo | pnpm workspaces | pnpm |
|
||||
| Markdown | react-markdown | react-markdown |
|
||||
|
||||
## PROJECT STRUCTURE
|
||||
|
||||
```
|
||||
Opencord/
|
||||
Backspace/
|
||||
├── CLAUDE.md
|
||||
├── Backspace-design-prototype.html
|
||||
├── package.json
|
||||
├── pnpm-workspace.yaml
|
||||
├── tsconfig.base.json
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── deploy.sh
|
||||
├── Makefile
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
@@ -58,7 +79,8 @@ Opencord/
|
||||
│ │ ├── package.json
|
||||
│ │ ├── tsconfig.json
|
||||
│ │ └── src/
|
||||
│ │ └── types.ts
|
||||
│ │ ├── types.ts
|
||||
│ │ └── permissions.ts
|
||||
│ ├── server/
|
||||
│ │ ├── package.json
|
||||
│ │ ├── tsconfig.json
|
||||
@@ -68,7 +90,8 @@ Opencord/
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── schema.ts
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── seed.ts
|
||||
│ │ │ ├── seed.ts
|
||||
│ │ │ └── migrate.ts
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── auth.ts
|
||||
│ │ │ ├── users.ts
|
||||
@@ -77,7 +100,10 @@ Opencord/
|
||||
│ │ │ ├── messages.ts
|
||||
│ │ │ ├── uploads.ts
|
||||
│ │ │ ├── dm.ts
|
||||
│ │ │ └── livekit.ts
|
||||
│ │ │ ├── livekit.ts
|
||||
│ │ │ ├── social.ts
|
||||
│ │ │ ├── settings.ts
|
||||
│ │ │ └── utils.ts
|
||||
│ │ ├── ws/
|
||||
│ │ │ ├── handler.ts
|
||||
│ │ │ └── events.ts
|
||||
@@ -97,35 +123,63 @@ Opencord/
|
||||
│ │ ├── App.tsx
|
||||
│ │ ├── api/
|
||||
│ │ │ └── client.ts
|
||||
│ │ ├── audio/
|
||||
│ │ │ ├── AudioManager.ts
|
||||
│ │ │ └── SpeakingDetector.ts
|
||||
│ │ ├── stores/
|
||||
│ │ │ ├── authStore.ts
|
||||
│ │ │ ├── serverStore.ts
|
||||
│ │ │ ├── chatStore.ts
|
||||
│ │ │ ├── voiceStore.ts
|
||||
│ │ │ ├── socialStore.ts
|
||||
│ │ │ ├── settingsStore.ts
|
||||
│ │ │ └── uiStore.ts
|
||||
│ │ ├── hooks/
|
||||
│ │ │ ├── useWebSocket.ts
|
||||
│ │ │ ├── useLiveKit.ts
|
||||
│ │ │ └── useAuth.ts
|
||||
│ │ │ ├── useAuth.ts
|
||||
│ │ │ ├── useTrackStats.ts
|
||||
│ │ │ └── useAudioTrackPlayer.ts
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── permissions.ts
|
||||
│ │ │ ├── livekitInternals.ts
|
||||
│ │ │ └── screenShare.ts
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── layout/
|
||||
│ │ │ │ ├── AppLayout.tsx
|
||||
│ │ │ │ ├── ServerSidebar.tsx
|
||||
│ │ │ │ ├── ChannelSidebar.tsx
|
||||
│ │ │ │ ├── MainContent.tsx
|
||||
│ │ │ │ ├── RightPanel.tsx
|
||||
│ │ │ │ ├── MemberSidebar.tsx
|
||||
│ │ │ │ ├── ActivityPanel.tsx
|
||||
│ │ │ │ ├── MemberListToggleButton.tsx
|
||||
│ │ │ │ └── MobileNav.tsx
|
||||
│ │ │ ├── chat/
|
||||
│ │ │ │ ├── MessageList.tsx
|
||||
│ │ │ │ ├── Message.tsx
|
||||
│ │ │ │ ├── MessageInput.tsx
|
||||
│ │ │ │ ├── TypingIndicator.tsx
|
||||
│ │ │ │ └── ImagePreview.tsx
|
||||
│ │ │ │ ├── ImagePreview.tsx
|
||||
│ │ │ │ ├── MarkdownRenderer.tsx
|
||||
│ │ │ │ ├── MentionPopover.tsx
|
||||
│ │ │ │ ├── MentionBadge.tsx
|
||||
│ │ │ │ ├── Embed.tsx
|
||||
│ │ │ │ └── FriendsPage.tsx
|
||||
│ │ │ ├── voice/
|
||||
│ │ │ │ ├── VoiceChannel.tsx
|
||||
│ │ │ │ ├── VoiceChatPanel.tsx
|
||||
│ │ │ │ ├── VoiceControlBar.tsx
|
||||
│ │ │ │ ├── VoiceControls.tsx
|
||||
│ │ │ │ ├── VoiceGrid.tsx
|
||||
│ │ │ │ └── VoiceUser.tsx
|
||||
│ │ │ │ ├── VoiceUser.tsx
|
||||
│ │ │ │ ├── StreamTile.tsx
|
||||
│ │ │ │ ├── PictureInPicture.tsx
|
||||
│ │ │ │ ├── IncomingCallModal.tsx
|
||||
│ │ │ │ ├── GlobalAudioRenderer.tsx
|
||||
│ │ │ │ ├── SoundController.tsx
|
||||
│ │ │ │ ├── ConnectionInfoPopover.tsx
|
||||
│ │ │ │ └── ScreenShareSettingsPopover.tsx
|
||||
│ │ │ ├── auth/
|
||||
│ │ │ │ ├── LoginPage.tsx
|
||||
│ │ │ │ └── RegisterPage.tsx
|
||||
@@ -135,13 +189,19 @@ Opencord/
|
||||
│ │ │ │ ├── CreateChannel.tsx
|
||||
│ │ │ │ ├── JoinServer.tsx
|
||||
│ │ │ │ ├── UserSettings.tsx
|
||||
│ │ │ │ └── ServerSettings.tsx
|
||||
│ │ │ │ ├── ServerSettings.tsx
|
||||
│ │ │ │ ├── ChannelSettingsModal.tsx
|
||||
│ │ │ │ ├── NewDmModal.tsx
|
||||
│ │ │ │ └── AddDmMemberModal.tsx
|
||||
│ │ │ └── ui/
|
||||
│ │ │ ├── Avatar.tsx
|
||||
│ │ │ ├── Modal.tsx
|
||||
│ │ │ ├── Tooltip.tsx
|
||||
│ │ │ ├── ContextMenu.tsx
|
||||
│ │ │ └── LoadingSpinner.tsx
|
||||
│ │ │ ├── LoadingSpinner.tsx
|
||||
│ │ │ └── UserProfilePopout.tsx
|
||||
│ │ ├── test/
|
||||
│ │ │ └── setup.ts
|
||||
│ │ └── styles/
|
||||
│ │ └── globals.css
|
||||
│ └── desktop/
|
||||
@@ -152,7 +212,7 @@ Opencord/
|
||||
│ │ └── preload.ts
|
||||
│ └── electron-builder.yml
|
||||
└── data/
|
||||
├── opencord.db
|
||||
├── backspace.db
|
||||
└── uploads/
|
||||
```
|
||||
|
||||
@@ -168,6 +228,7 @@ CREATE TABLE users (
|
||||
avatar TEXT,
|
||||
status TEXT DEFAULT 'offline',
|
||||
custom_status TEXT,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -183,30 +244,59 @@ CREATE TABLE servers (
|
||||
|
||||
-- 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',
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
nickname TEXT,
|
||||
joined_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, user_id)
|
||||
);
|
||||
|
||||
-- Roles
|
||||
CREATE TABLE roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#b9bbbe',
|
||||
position INTEGER DEFAULT 0,
|
||||
permissions TEXT, -- decimal string of bigint permission bits
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Member Roles (many-to-many)
|
||||
CREATE TABLE member_roles (
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (server_id, user_id, role_id)
|
||||
);
|
||||
|
||||
-- Channels
|
||||
CREATE TABLE channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT REFERENCES servers(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- 'text' | 'voice' | 'video'
|
||||
topic TEXT,
|
||||
position INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Channel Permission Overrides
|
||||
CREATE TABLE channel_overrides (
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
target_type TEXT NOT NULL, -- 'role' | 'member'
|
||||
target_id TEXT NOT NULL, -- role ID or user ID
|
||||
allow TEXT NOT NULL DEFAULT '0', -- bigint decimal string
|
||||
deny TEXT NOT NULL DEFAULT '0', -- bigint decimal string
|
||||
PRIMARY KEY (channel_id, target_type, target_id)
|
||||
);
|
||||
|
||||
-- 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),
|
||||
reply_to_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
content TEXT,
|
||||
edited_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
@@ -216,6 +306,7 @@ CREATE TABLE messages (
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
|
||||
dm_message_id TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
mimetype TEXT NOT NULL,
|
||||
@@ -223,15 +314,26 @@ CREATE TABLE attachments (
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Reactions
|
||||
CREATE TABLE reactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- DM Channels
|
||||
CREATE TABLE dm_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT, -- NULL for 1-on-1, set for group DMs
|
||||
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,
|
||||
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
closed INTEGER DEFAULT 0, -- soft-close flag
|
||||
PRIMARY KEY (dm_channel_id, user_id)
|
||||
);
|
||||
|
||||
@@ -240,119 +342,348 @@ 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),
|
||||
reply_to_id TEXT,
|
||||
content TEXT,
|
||||
edited_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- DM Reactions
|
||||
CREATE TABLE dm_reactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
dm_message_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Friends
|
||||
CREATE TABLE friends (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
friend_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, friend_id)
|
||||
);
|
||||
|
||||
-- Friend Requests
|
||||
CREATE TABLE friend_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
to_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT DEFAULT 'pending', -- 'pending' | 'accepted' | 'declined'
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Read States
|
||||
CREATE TABLE read_states (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel_id TEXT NOT NULL,
|
||||
last_read_message_id TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, channel_id)
|
||||
);
|
||||
|
||||
-- Server Folders
|
||||
CREATE TABLE server_folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
color TEXT,
|
||||
position INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE server_folder_members (
|
||||
folder_id TEXT NOT NULL REFERENCES server_folders(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (folder_id, server_id)
|
||||
);
|
||||
|
||||
-- Instance Settings (singleton row, id=1)
|
||||
CREATE TABLE instance_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000,
|
||||
min_bitrate_kbps INTEGER NOT NULL DEFAULT 500,
|
||||
bitrate_step_kbps INTEGER NOT NULL DEFAULT 500,
|
||||
allowed_resolutions TEXT NOT NULL DEFAULT '540,720,1080',
|
||||
allowed_framerates TEXT NOT NULL DEFAULT '30,45,60',
|
||||
max_resolution INTEGER NOT NULL DEFAULT 1080,
|
||||
max_framerate INTEGER NOT NULL DEFAULT 60,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
## REST API ENDPOINTS
|
||||
|
||||
```
|
||||
POST /api/auth/register { username, password, displayName? } → { token, user }
|
||||
POST /api/auth/login { username, password } → { token, user }
|
||||
# Auth
|
||||
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 }
|
||||
# Users
|
||||
GET /api/users/@me (auth) → { user }
|
||||
PATCH /api/users/@me (auth) { displayName?, avatar?, customStatus?, status? } → { 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 }
|
||||
# Servers
|
||||
POST /api/servers (auth) { name, icon? } → { server }
|
||||
GET /api/servers (auth) → { servers[] }
|
||||
GET /api/servers/:id (auth) → { server, channels[], members[], roles[] }
|
||||
PATCH /api/servers/:id (auth, MANAGE_SERVER) { name?, icon? } → { server }
|
||||
DELETE /api/servers/:id (auth, owner) → { success }
|
||||
POST /api/servers/:id/invite (auth, CREATE_INVITE) → { inviteCode }
|
||||
POST /api/servers/:id/join (auth) { inviteCode } → { server }
|
||||
POST /api/servers/join (auth) { inviteCode } → { server }
|
||||
|
||||
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 }
|
||||
# Members
|
||||
GET /api/servers/:id/members (auth, member) → { members[] }
|
||||
PATCH /api/servers/:id/members/:uid (auth, MANAGE_ROLES) { roles } → { member }
|
||||
DELETE /api/servers/:id/members/:uid (auth, KICK_MEMBERS|self) → { 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 }
|
||||
# Roles
|
||||
POST /api/servers/:id/roles (auth, MANAGE_ROLES) { name, color?, permissions? } → { role }
|
||||
PATCH /api/servers/:id/roles/:rid (auth, MANAGE_ROLES) { name?, color?, permissions?, position? } → { role }
|
||||
DELETE /api/servers/:id/roles/:rid (auth, MANAGE_ROLES) → { success }
|
||||
POST /api/servers/:id/members/:uid/roles (auth, MANAGE_ROLES) { roleId } → { success }
|
||||
DELETE /api/servers/:id/members/:uid/roles/:rid (auth, MANAGE_ROLES) → { 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 }
|
||||
# Channels
|
||||
GET /api/servers/:id/channels (auth, member, VIEW_CHANNEL) → { channels[] }
|
||||
POST /api/servers/:id/channels (auth, MANAGE_CHANNELS) { name, type, topic? } → { channel }
|
||||
PATCH /api/channels/:id (auth, MANAGE_CHANNELS) { name?, topic?, position? } → { channel }
|
||||
DELETE /api/channels/:id (auth, MANAGE_CHANNELS) → { success }
|
||||
|
||||
POST /api/livekit/token (auth) { channelId } → { token }
|
||||
# Channel Permission Overrides
|
||||
GET /api/channels/:id/overrides (auth, MANAGE_CHANNELS) → { overrides[] }
|
||||
PUT /api/channels/:id/overrides (auth, MANAGE_CHANNELS) { targetType, targetId, allow, deny } → { override }
|
||||
DELETE /api/channels/:id/overrides/:targetType/:targetId (auth, MANAGE_CHANNELS) → { success }
|
||||
|
||||
POST /api/uploads (auth) multipart file → { attachment }
|
||||
GET /api/uploads/:filename (public) → file stream
|
||||
# Messages
|
||||
GET /api/channels/:id/messages (auth, member) ?before=&limit=50 → { messages[] }
|
||||
POST /api/channels/:id/messages (auth, SEND_MESSAGES) { content, attachments?, replyToId? } → { message }
|
||||
PATCH /api/messages/:id (auth, author) { content } → { message }
|
||||
DELETE /api/messages/:id (auth, author|MANAGE_MESSAGES) → { success }
|
||||
|
||||
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 }
|
||||
# File Uploads
|
||||
POST /api/uploads (auth) multipart file → { attachment }
|
||||
GET /api/uploads/:filename (public) → file stream
|
||||
|
||||
# Direct Messages
|
||||
GET /api/dm (auth) → { dmChannels[] }
|
||||
POST /api/dm (auth) { userId } → { dmChannel }
|
||||
DELETE /api/dm/:id (auth, member) → { success } (soft-close)
|
||||
GET /api/dm/:id/messages (auth, member) ?before=&limit=50 → { messages[] }
|
||||
POST /api/dm/:id/messages (auth, member) { content } → { message }
|
||||
PATCH /api/dm/messages/:id (auth, author) { content } → { message }
|
||||
DELETE /api/dm/messages/:id (auth, author) → { success }
|
||||
POST /api/dm/:id/members (auth, owner) { userId } → { dmChannel } (group DM, max 10)
|
||||
DELETE /api/dm/:id/members (auth, member) → { success } (leave group DM)
|
||||
|
||||
# Social / Friends
|
||||
GET /api/social/friends (auth) → { friends[] }
|
||||
GET /api/social/requests (auth) → { requests[] }
|
||||
POST /api/social/requests (auth) { username } → { request }
|
||||
PATCH /api/social/requests/:id (auth) { action: 'accept'|'decline' } → { request }
|
||||
DELETE /api/social/requests/:id (auth) → { success } (cancel)
|
||||
DELETE /api/social/friends/:id (auth) → { success }
|
||||
GET /api/social/search (auth) ?q= → { users[] }
|
||||
|
||||
# Voice/Video
|
||||
POST /api/livekit/token (auth) { channelId } → { token }
|
||||
|
||||
# Instance Settings (admin)
|
||||
GET /api/settings/streaming (auth) → { streamingLimits }
|
||||
PATCH /api/settings/streaming (auth, admin) { maxBitrateKbps?, ... } → { streamingLimits }
|
||||
|
||||
# Utilities
|
||||
GET /api/utils/metadata (auth) ?url= → { title?, description?, image?, siteName? }
|
||||
GET /api/health (public) → { status: 'ok', timestamp }
|
||||
```
|
||||
|
||||
## 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 }`.
|
||||
All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ type: 'auth', token: 'jwt...' }` as the first message. Server responds with `{ type: 'ready', ... }` containing all initial state.
|
||||
|
||||
### 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: 'ping' }
|
||||
|
||||
# Server Messages
|
||||
{ type: 'message_create', channelId, content, replyToId?, attachmentIds? }
|
||||
{ type: 'message_edit', messageId, content }
|
||||
{ type: 'message_delete', messageId }
|
||||
{ type: 'typing_start', channelId }
|
||||
{ type: 'reaction_add', messageId, emoji }
|
||||
{ type: 'reaction_remove', messageId, emoji }
|
||||
{ type: 'channel_ack', channelId, messageId }
|
||||
|
||||
# DM Messages
|
||||
{ type: 'dm_message_create', dmChannelId, content, replyToId?, attachmentIds? }
|
||||
{ type: 'dm_message_edit', messageId, content }
|
||||
{ type: 'dm_message_delete', messageId }
|
||||
{ type: 'dm_typing_start', dmChannelId }
|
||||
|
||||
# Presence
|
||||
{ type: 'presence_update', status: 'online' | 'idle' | 'dnd' }
|
||||
{ type: 'voice_join', channelId: string }
|
||||
|
||||
# Voice (Server Channels)
|
||||
{ type: 'voice_join', channelId }
|
||||
{ type: 'voice_leave' }
|
||||
{ type: 'dm_message_create', dmChannelId: string, content: string }
|
||||
{ type: 'voice_status', isMuted?, isDeafened?, isCameraOn?, isScreenSharing? }
|
||||
|
||||
# DM Calls
|
||||
{ type: 'dm_call_start', dmChannelId }
|
||||
{ type: 'dm_call_accept', dmChannelId }
|
||||
{ type: 'dm_call_reject', dmChannelId }
|
||||
{ type: 'dm_call_end', dmChannelId }
|
||||
```
|
||||
|
||||
### Server → Client
|
||||
```
|
||||
{ type: 'ready', user: User, servers: ServerWithChannelsAndMembers[], dmChannels: DmChannel[] }
|
||||
{ type: 'ready', user, servers, dmChannels, folders, voiceStates, readStates, activeCalls }
|
||||
{ type: 'pong' }
|
||||
|
||||
# Server Messages
|
||||
{ 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: 'message_deleted', messageId, channelId }
|
||||
{ type: 'typing', channelId, userId, username }
|
||||
{ type: 'reaction_added', messageId, channelId, reaction }
|
||||
{ type: 'reaction_removed', messageId, channelId, userId, emoji }
|
||||
|
||||
# DM Messages
|
||||
{ type: 'dm_message_created', message: DmMessageWithUser }
|
||||
{ type: 'dm_message_updated', message: DmMessageWithUser }
|
||||
{ type: 'dm_message_deleted', messageId, dmChannelId }
|
||||
{ type: 'dm_channel_created', dmChannel }
|
||||
{ type: 'dm_channel_closed', dmChannelId }
|
||||
{ type: 'dm_member_added', dmChannelId, user }
|
||||
{ type: 'dm_member_removed', dmChannelId, userId }
|
||||
|
||||
# Channel/Server Updates
|
||||
{ type: 'channel_created', channel }
|
||||
{ type: 'channel_updated', channel }
|
||||
{ type: 'channel_deleted', channelId, serverId }
|
||||
{ type: 'server_updated', server }
|
||||
{ type: 'channel_ack', channelId, messageId, userId }
|
||||
|
||||
# Members & Presence
|
||||
{ type: 'member_joined', serverId, member: MemberWithUser }
|
||||
{ type: 'member_left', serverId, userId }
|
||||
{ type: 'presence_update', userId, status }
|
||||
|
||||
# Voice
|
||||
{ type: 'voice_state_update', channelId, userId, action: 'join' | 'leave' }
|
||||
{ type: 'voice_status_update', userId, isMuted, isDeafened, isCameraOn, isScreenSharing }
|
||||
|
||||
# DM Calls
|
||||
{ type: 'dm_call_incoming', dmChannelId, callerId, callerName }
|
||||
{ type: 'dm_call_accepted', dmChannelId }
|
||||
{ type: 'dm_call_rejected', dmChannelId }
|
||||
{ type: 'dm_call_ended', dmChannelId }
|
||||
|
||||
# Social
|
||||
{ type: 'friend_request_received', request }
|
||||
{ type: 'friend_request_accepted', friend }
|
||||
{ type: 'friend_removed', userId }
|
||||
```
|
||||
|
||||
## LIVEKIT CONFIGURATION
|
||||
## PERMISSION SYSTEM
|
||||
|
||||
```
|
||||
LiveKit URL: wss://nova.ddns.net/livekit
|
||||
API Key: REDACTED_LIVEKIT_KEY
|
||||
API Secret: REDACTED_LIVEKIT_SECRET
|
||||
```
|
||||
Bitwise permission engine defined in `packages/shared/src/permissions.ts`. Stored as decimal strings in the database (bigint is not JSON-safe).
|
||||
|
||||
| Bit | Permission | Description |
|
||||
|-----|-----------|-------------|
|
||||
| 0 | ADMINISTRATOR | Full access, bypasses all checks |
|
||||
| 1 | VIEW_CHANNEL | See channel in list and read messages |
|
||||
| 2 | MANAGE_CHANNELS | Create, edit, delete channels |
|
||||
| 3 | MANAGE_ROLES | Create, edit, delete roles |
|
||||
| 4 | MANAGE_SERVER | Edit server name, icon |
|
||||
| 5 | CREATE_INVITE | Generate invite codes |
|
||||
| 6 | KICK_MEMBERS | Remove members from server |
|
||||
| 7 | BAN_MEMBERS | Ban members from server |
|
||||
| 10 | SEND_MESSAGES | Post messages in text channels |
|
||||
| 11 | MANAGE_MESSAGES | Delete other users' messages |
|
||||
| 12 | ATTACH_FILES | Upload files to messages |
|
||||
| 13 | READ_MESSAGE_HISTORY | View message history |
|
||||
| 14 | ADD_REACTIONS | Add emoji reactions |
|
||||
| 20 | CONNECT | Join voice channels |
|
||||
| 21 | SPEAK | Transmit audio in voice |
|
||||
| 22 | MUTE_MEMBERS | Server-mute other members |
|
||||
| 23 | DEAFEN_MEMBERS | Server-deafen other members |
|
||||
| 24 | MOVE_MEMBERS | Move members between voice channels |
|
||||
| 25 | USE_VOICE_ACTIVITY | Use voice activity detection |
|
||||
| 26 | STREAM | Share screen in voice channels |
|
||||
|
||||
**Resolution order:** Owner → @everyone role → Assigned roles (OR'd) → ADMINISTRATOR shortcut → Channel overrides (@everyone → role overrides → member override).
|
||||
|
||||
## 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
|
||||
```env
|
||||
# Server
|
||||
PORT=3000 # HTTP/WS listen port
|
||||
HOST=0.0.0.0 # Bind address
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=<random-64-char-hex> # Required — generate with: openssl rand -hex 32
|
||||
|
||||
# LiveKit (optional — leave empty to disable voice features)
|
||||
LIVEKIT_URL= # WebSocket URL to LiveKit server
|
||||
LIVEKIT_API_KEY= # LiveKit API key
|
||||
LIVEKIT_API_SECRET= # LiveKit API secret
|
||||
|
||||
# Storage
|
||||
UPLOAD_DIR=./data/uploads # File upload directory
|
||||
DB_PATH=./data/backspace.db # SQLite database path
|
||||
MAX_UPLOAD_SIZE=104857600 # Max upload size in bytes (100MB)
|
||||
|
||||
# Registration
|
||||
REGISTRATION_OPEN=true # Set to false to disable new user signup
|
||||
```
|
||||
|
||||
## PROGRESS
|
||||
## DEPLOYMENT
|
||||
|
||||
- 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)
|
||||
**Live instance:** `https://nova.ddns.net` — Raspberry Pi behind OpenResty reverse proxy with HSTS.
|
||||
|
||||
## CURRENT PHASE
|
||||
**Infrastructure:**
|
||||
- Docker container (`backspace`) on port 3000
|
||||
- External Docker volume: `backspace-data` (mounted at `/app/data`)
|
||||
- External Docker network: `backspace-net` (shared with reverse proxy)
|
||||
- LiveKit server at `wss://nova.ddns.net/livekit`
|
||||
|
||||
All 8 phases complete.
|
||||
**Deploy commands:**
|
||||
- `./deploy.sh` — Syncs code via rsync to Pi, triggers `docker compose up -d --build`
|
||||
- `./deploy.sh --local` — Use local IP (192.168.1.10)
|
||||
- `./deploy.sh --remote` — Use remote DNS (nova.ddns.net)
|
||||
- `make deploy` — Same as deploy.sh
|
||||
- `make logs` — Watch Docker logs on Pi
|
||||
- `make shell` — SSH into running container
|
||||
- `make status` — Check container status
|
||||
|
||||
**Development:**
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # Starts server (:3005) + Vite (:5173) with proxy
|
||||
```
|
||||
|
||||
## FEATURE STATUS
|
||||
|
||||
All core features are implemented and live:
|
||||
|
||||
- **Auth:** Registration (first user = admin), login, JWT sessions
|
||||
- **Servers:** Create, join by invite, server settings, delete
|
||||
- **Channels:** Text, voice, video types with position ordering
|
||||
- **Messaging:** Send, edit, delete, replies, attachments, reactions, typing indicators, read states
|
||||
- **Permissions:** Full RBAC with roles, per-channel overrides, computed permissions
|
||||
- **Voice/Video:** LiveKit integration, mute/deafen, camera, screen share with VP9
|
||||
- **Screen Share:** Configurable resolution/FPS/bitrate, gaming vs text mode, instance-level limits
|
||||
- **DMs:** 1-on-1 and group DMs (up to 10), soft-close, message edit/delete
|
||||
- **DM Calls:** Ringing state machine (ring → active → ended), auto-reject timeout
|
||||
- **Friends:** Send/accept/decline requests, friend list, user search
|
||||
- **Audio Processing:** RNNoise noise suppression, echo cancellation, auto gain control, per-user volume
|
||||
- **File Uploads:** Multipart upload, immutable cache headers, directory traversal protection
|
||||
- **Admin Panel:** Instance-level streaming limits (bitrate, resolution, framerate bounds)
|
||||
- **URL Previews:** Server-side metadata extraction with Cheerio
|
||||
- **Desktop:** Electron wrapper with tray, notifications, badge count
|
||||
- **Docker:** Multi-stage build, health checks, persistent volume
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Raspberry Pi Deployment Makefile (Direct SSH)
|
||||
|
||||
PI_IP = 192.168.1.10
|
||||
PI_USER = youruser
|
||||
REMOTE_PATH = ~/opencord
|
||||
|
||||
.PHONY: deploy logs shell env status
|
||||
|
||||
# Full deployment: sync code and rebuild/restart containers ON the Pi
|
||||
deploy:
|
||||
@chmod +x deploy.sh
|
||||
./deploy.sh
|
||||
|
||||
# Watch real-time logs FROM the Pi server
|
||||
logs:
|
||||
ssh $(PI_USER)@$(PI_IP) "cd $(REMOTE_PATH) && docker compose logs -f"
|
||||
|
||||
# Drop into a shell inside the running container ON the Pi
|
||||
shell:
|
||||
ssh -t $(PI_USER)@$(PI_IP) "docker exec -it opencord /bin/bash"
|
||||
|
||||
# Push local .env to the Pi (do this once)
|
||||
env:
|
||||
@echo "📤 Pushing .env to Pi..."
|
||||
ssh $(PI_USER)@$(PI_IP) "mkdir -p $(REMOTE_PATH)"
|
||||
scp .env $(PI_USER)@$(PI_IP):$(REMOTE_PATH)/.env
|
||||
|
||||
# Check container status ON the Pi
|
||||
status:
|
||||
ssh $(PI_USER)@$(PI_IP) "cd $(REMOTE_PATH) && docker compose ps"
|
||||
@@ -1,18 +1,22 @@
|
||||
# Opencord
|
||||
# Backspace
|
||||
|
||||
Open-source, self-hosted Discord alternative built with TypeScript.
|
||||
|
||||
## Features
|
||||
|
||||
- Real-time text messaging with WebSocket
|
||||
- Servers, channels, and member management
|
||||
- Servers, channels, and role-based permission management
|
||||
- Voice and video chat via LiveKit
|
||||
- Direct messages
|
||||
- Screen sharing with configurable quality (VP9)
|
||||
- Direct messages (1-on-1 and group DMs up to 10)
|
||||
- DM voice/video calls with ringing
|
||||
- Friend system with requests
|
||||
- File uploads and image sharing
|
||||
- Markdown message formatting
|
||||
- Typing indicators and presence status
|
||||
- Role-based permissions (owner, admin, member)
|
||||
- Markdown message formatting with syntax highlighting
|
||||
- Message reactions, replies, and editing
|
||||
- Typing indicators, presence status, and read states
|
||||
- Invite system with shareable codes
|
||||
- Instance-level admin panel (streaming limits)
|
||||
- Desktop app (Electron)
|
||||
- Mobile-responsive web UI
|
||||
- Docker deployment
|
||||
@@ -34,8 +38,8 @@ Open-source, self-hosted Discord alternative built with TypeScript.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/your-username/opencord.git
|
||||
cd opencord
|
||||
git clone https://github.com/your-username/backspace.git
|
||||
cd backspace
|
||||
|
||||
# Create environment file
|
||||
cp .env.example .env
|
||||
@@ -43,11 +47,11 @@ cp .env.example .env
|
||||
# Generate a JWT secret
|
||||
echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
|
||||
# Start Opencord
|
||||
# Start Backspace
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` in your browser. A default server "Opencord" is created automatically.
|
||||
Open `http://localhost:3000` in your browser. A default server "Backspace" is created automatically.
|
||||
|
||||
**Default admin account:** `admin` / `admin123` (change this after first login).
|
||||
|
||||
@@ -78,7 +82,7 @@ cp .env.example .env
|
||||
pnpm dev
|
||||
|
||||
# Or start individually
|
||||
pnpm dev:server # API server on :3000
|
||||
pnpm dev:server # API server on :3005
|
||||
pnpm dev:web # Vite dev server on :5173
|
||||
```
|
||||
|
||||
@@ -93,9 +97,9 @@ This builds the shared types, server, and web frontend. The server serves the bu
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Opencord/
|
||||
Backspace/
|
||||
├── packages/
|
||||
│ ├── shared/ # Shared TypeScript types
|
||||
│ ├── shared/ # Shared TypeScript types & permissions
|
||||
│ ├── server/ # Fastify API + WebSocket server
|
||||
│ ├── web/ # React frontend (Vite + Tailwind)
|
||||
│ └── desktop/ # Electron desktop app
|
||||
|
||||
+8
-8
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "opencord",
|
||||
"name": "backspace",
|
||||
"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"
|
||||
"dev:server": "pnpm --filter @backspace/server dev",
|
||||
"dev:web": "pnpm --filter @backspace/web dev",
|
||||
"dev": "pnpm --filter @backspace/server dev & pnpm --filter @backspace/web dev",
|
||||
"build:shared": "pnpm --filter @backspace/shared build",
|
||||
"build:server": "pnpm --filter @backspace/server build",
|
||||
"build:web": "pnpm --filter @backspace/web build",
|
||||
"build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build & pnpm --filter @backspace/web build"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: com.opencord.desktop
|
||||
productName: Opencord
|
||||
appId: com.backspace.desktop
|
||||
productName: Backspace
|
||||
directories:
|
||||
output: dist-electron
|
||||
files:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@opencord/desktop",
|
||||
"name": "@backspace/desktop",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "dist/main.js",
|
||||
|
||||
@@ -14,7 +14,7 @@ let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let isQuitting = false;
|
||||
|
||||
const SERVER_URL = process.env.OPENCORD_URL || 'http://localhost:3000';
|
||||
const SERVER_URL = process.env.BACKSPACE_URL || 'http://localhost:3000';
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
@@ -22,7 +22,7 @@ function createWindow(): void {
|
||||
height: 800,
|
||||
minWidth: 940,
|
||||
minHeight: 500,
|
||||
title: 'Opencord',
|
||||
title: 'Backspace',
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
backgroundColor: '#313338',
|
||||
show: false,
|
||||
@@ -61,7 +61,7 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
function createTray(): void {
|
||||
// Create a 16x16 tray icon (blue circle for Opencord)
|
||||
// Create a 16x16 tray icon (blue circle for Backspace)
|
||||
const size = 16;
|
||||
const canvas = Buffer.alloc(size * size * 4);
|
||||
const cx = size / 2;
|
||||
@@ -89,7 +89,7 @@ function createTray(): void {
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show Opencord',
|
||||
label: 'Show Backspace',
|
||||
click: () => {
|
||||
mainWindow?.show();
|
||||
mainWindow?.focus();
|
||||
@@ -111,7 +111,7 @@ function createTray(): void {
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setToolTip('Opencord');
|
||||
tray.setToolTip('Backspace');
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on('click', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('opencord', {
|
||||
contextBridge.exposeInMainWorld('backspace', {
|
||||
platform: process.platform,
|
||||
showNotification: (title: string, body: string) => {
|
||||
ipcRenderer.send('show-notification', { title, body });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@opencord/server",
|
||||
"name": "@backspace/server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -15,7 +15,7 @@
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@opencord/shared": "workspace:*",
|
||||
"@backspace/shared": "workspace:*",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"cheerio": "^1.0.0",
|
||||
|
||||
@@ -47,7 +47,7 @@ export const config = {
|
||||
},
|
||||
|
||||
uploadDir: env('UPLOAD_DIR', resolve(__dirname, '../../../data/uploads')),
|
||||
dbPath: env('DB_PATH', resolve(__dirname, '../../../data/opencord.db')),
|
||||
dbPath: env('DB_PATH', resolve(__dirname, '../../../data/backspace.db')),
|
||||
maxUploadSize: envInt('MAX_UPLOAD_SIZE', 104857600),
|
||||
registrationOpen: envBool('REGISTRATION_OPEN', true),
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
console.log('Checking for database migrations...');
|
||||
@@ -137,9 +137,12 @@ function migrateEveryoneRoles(db: Database.Database): void {
|
||||
}
|
||||
|
||||
// Migrate existing admin members: ensure an Admin role exists and assign it
|
||||
const adminMembers = db.prepare(
|
||||
"SELECT server_id, user_id FROM server_members WHERE role = 'admin'"
|
||||
).all() as { server_id: string; user_id: string }[];
|
||||
// Only run if the old `role` column still exists on server_members
|
||||
const smColumns = db.pragma('table_info(server_members)') as { name: string }[];
|
||||
const hasRoleColumn = smColumns.some(c => c.name === 'role');
|
||||
const adminMembers = hasRoleColumn
|
||||
? db.prepare("SELECT server_id, user_id FROM server_members WHERE role = 'admin'").all() as { server_id: string; user_id: string }[]
|
||||
: [];
|
||||
|
||||
if (adminMembers.length > 0) {
|
||||
// Group by server
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getDb, schema } from './index.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { hashPassword } from '../utils/auth.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||
|
||||
export async function seedDatabase(): Promise<void> {
|
||||
const db = getDb();
|
||||
@@ -31,9 +31,9 @@ export async function seedDatabase(): Promise<void> {
|
||||
const serverId = generateSnowflake();
|
||||
db.insert(schema.servers).values({
|
||||
id: serverId,
|
||||
name: 'Opencord',
|
||||
name: 'Backspace',
|
||||
ownerId: adminId,
|
||||
inviteCode: 'opencord',
|
||||
inviteCode: 'backspace',
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
@@ -76,6 +76,6 @@ export async function seedDatabase(): Promise<void> {
|
||||
}).run();
|
||||
|
||||
console.log('Database seeded successfully');
|
||||
console.log(` Default server: Opencord (invite code: opencord)`);
|
||||
console.log(` Default server: Backspace (invite code: backspace)`);
|
||||
console.log(` Admin user: admin / admin123`);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ async function main(): Promise<void> {
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.port, host: config.host });
|
||||
console.log(`Opencord server running at http://${config.host}:${config.port}`);
|
||||
console.log(`Backspace server running at http://${config.host}:${config.port}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import type { RegisterRequest, LoginRequest, AuthResponse, User } from '@backspace/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
return {
|
||||
|
||||
@@ -4,13 +4,13 @@ import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { isMember, hasPermission, getChannelServerId, PermissionBits, computePermissions } from '../utils/permissions.js';
|
||||
import { permissionsToString } from '@opencord/shared/src/permissions.js';
|
||||
import { permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import type {
|
||||
CreateChannelRequest,
|
||||
UpdateChannelRequest,
|
||||
Channel,
|
||||
} from '@opencord/shared';
|
||||
} from '@backspace/shared';
|
||||
|
||||
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
||||
return {
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
PaginatedQuery,
|
||||
Attachment,
|
||||
Reaction,
|
||||
} from '@opencord/shared';
|
||||
} from '@backspace/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AccessToken } from 'livekit-server-sdk';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { config } from '../config.js';
|
||||
import { getChannelServerId, hasPermission, isDmMember, PermissionBits } from '../utils/permissions.js';
|
||||
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@opencord/shared';
|
||||
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@backspace/shared';
|
||||
|
||||
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post<{ Body: LiveKitTokenRequest & { dmChannelId?: string } }>('/api/livekit/token', {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
User,
|
||||
MessageWithUser,
|
||||
Reaction,
|
||||
} from '@opencord/shared';
|
||||
} from '@backspace/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { isMember, isServerOwner, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||
import crypto from 'crypto';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import type {
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
MemberWithUser,
|
||||
ServerWithChannelsAndMembers,
|
||||
Role,
|
||||
} from '@opencord/shared';
|
||||
} from '@backspace/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { InstanceStreamingLimits } from '@opencord/shared';
|
||||
import type { InstanceStreamingLimits } from '@backspace/shared';
|
||||
|
||||
const VALID_RESOLUTIONS = [540, 720, 1080];
|
||||
const VALID_FRAMERATES = [30, 45, 60];
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
FriendRequest,
|
||||
SendFriendRequest,
|
||||
UpdateFriendRequest,
|
||||
} from '@opencord/shared';
|
||||
} from '@backspace/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { eq } from 'drizzle-orm';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import type { Attachment } from '@opencord/shared';
|
||||
import type { Attachment } from '@backspace/shared';
|
||||
|
||||
export async function uploadRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Ensure upload directory exists
|
||||
|
||||
@@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import type { User, UpdateUserRequest } from '@opencord/shared';
|
||||
import type { User, UpdateUserRequest } from '@backspace/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function utilRoutes(app: FastifyInstance): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'OpencordBot/1.0',
|
||||
'User-Agent': 'BackspaceBot/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ALL_PERMISSIONS,
|
||||
stringToPermissions,
|
||||
permissionsToString,
|
||||
} from '@opencord/shared/src/permissions.js';
|
||||
} from '@backspace/shared/src/permissions.js';
|
||||
|
||||
// Re-export for convenience
|
||||
export { PermissionBits, ALL_PERMISSIONS, permissionsToString, stringToPermissions };
|
||||
|
||||
@@ -5,7 +5,7 @@ import { connectionManager } from './handler.js';
|
||||
import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js';
|
||||
import { isMember, getChannelServerId, isDmMember, hasPermission, PermissionBits } from '../utils/permissions.js';
|
||||
import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
|
||||
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared';
|
||||
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@backspace/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
ServerFolder,
|
||||
ReadState,
|
||||
ActiveCallInfo,
|
||||
} from '@opencord/shared';
|
||||
} from '@backspace/shared';
|
||||
|
||||
// SQLite's SQLITE_MAX_VARIABLE_NUMBER default is 999.
|
||||
// Chunk inArray() calls to stay safely under this limit.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@opencord/shared",
|
||||
"name": "@backspace/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
<title>Backspace</title>
|
||||
</head>
|
||||
<body class="bg-discord-bg-primary text-discord-text-primary">
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@opencord/web",
|
||||
"name": "@backspace/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "^2.7.4",
|
||||
"@opencord/shared": "workspace:*",
|
||||
"@backspace/shared": "workspace:*",
|
||||
"@sapphi-red/web-noise-suppressor": "^0.3.5",
|
||||
"livekit-client": "^2.9.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
|
||||
@@ -26,12 +26,12 @@ import type {
|
||||
Friend,
|
||||
FriendRequest,
|
||||
InstanceStreamingLimits,
|
||||
} from '@opencord/shared';
|
||||
} from '@backspace/shared';
|
||||
|
||||
const BASE_URL = '/api';
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('opencord_token');
|
||||
return localStorage.getItem('backspace_token');
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
|
||||
@@ -22,7 +22,7 @@ export function Embed({ url }: EmbedProps) {
|
||||
// Simple fetch from our new API
|
||||
fetch(`/api/utils/metadata?url=${encodeURIComponent(url)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('opencord_token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('backspace_token')}`
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import { FriendsPage } from './FriendsPage';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import type { Friend, FriendRequest } from '@opencord/shared';
|
||||
import type { Friend, FriendRequest } from '@backspace/shared';
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('../../api/client', () => ({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Avatar } from '../ui/Avatar';
|
||||
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import { api } from '../../api/client';
|
||||
import type { Friend, FriendRequest } from '@opencord/shared';
|
||||
import type { Friend, FriendRequest } from '@backspace/shared';
|
||||
|
||||
type Tab = 'online' | 'all' | 'pending' | 'add';
|
||||
|
||||
@@ -143,7 +143,7 @@ export function FriendsPage() {
|
||||
return (
|
||||
<div className="flex-1 p-8">
|
||||
<h2 className="text-base font-bold text-discord-text-primary uppercase mb-2">Add Friend</h2>
|
||||
<p className="text-sm text-discord-text-muted mb-4">You can add friends with their Opencord username.</p>
|
||||
<p className="text-sm text-discord-text-muted mb-4">You can add friends with their Backspace username.</p>
|
||||
<form onSubmit={handleAddFriend} className="relative mb-8">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useRef, useEffect } from 'react';
|
||||
import type { MemberWithUser } from '@opencord/shared';
|
||||
import type { MemberWithUser } from '@backspace/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { MessageWithUser } from '@opencord/shared';
|
||||
import type { MessageWithUser } from '@backspace/shared';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { ContextMenu } from '../ui/ContextMenu';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isDmChannel, useServerStore } from '../../stores/serverStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { api } from '../../api/client';
|
||||
import { MentionPopover } from './MentionPopover';
|
||||
import type { MemberWithUser } from '@opencord/shared';
|
||||
import type { MemberWithUser } from '@backspace/shared';
|
||||
|
||||
interface MessageInputProps {
|
||||
channelId: string;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useAuthStore } from '../../stores/authStore';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import type { MessageWithUser } from '@opencord/shared';
|
||||
import type { MessageWithUser } from '@backspace/shared';
|
||||
|
||||
const EMPTY_MESSAGES: MessageWithUser[] = [];
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo } from 'react';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import type { Friend } from '@opencord/shared';
|
||||
import type { Friend } from '@backspace/shared';
|
||||
|
||||
export function ActivityPanel() {
|
||||
const friends = useSocialStore((s) => s.friends);
|
||||
|
||||
@@ -53,12 +53,12 @@ export function AppLayout() {
|
||||
|
||||
// MutationObserver: neutralize rogue LiveKit <audio> elements that bypass our Web Audio pipeline.
|
||||
// LiveKit can re-attach hidden <audio> elements after .detach(), causing full-volume playback
|
||||
// that ignores our volume/mute controls. Any <audio> without data-opencord is immediately killed.
|
||||
// that ignores our volume/mute controls. Any <audio> without data-backspace is immediately killed.
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLAudioElement && !node.dataset.opencord) {
|
||||
if (node instanceof HTMLAudioElement && !node.dataset.backspace) {
|
||||
node.muted = true;
|
||||
node.volume = 0;
|
||||
node.pause();
|
||||
@@ -210,7 +210,7 @@ export function AppLayout() {
|
||||
<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>
|
||||
<p className="text-discord-text-muted">Loading Backspace...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { MemberWithUser } from '@opencord/shared';
|
||||
import type { MemberWithUser } from '@backspace/shared';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Avatar } from '../ui/Avatar';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { api } from '../../api/client';
|
||||
import type { User } from '@opencord/shared';
|
||||
import type { User } from '@backspace/shared';
|
||||
|
||||
export function AddDmMemberModal() {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Avatar } from '../ui/Avatar';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { api } from '../../api/client';
|
||||
import type { User } from '@opencord/shared';
|
||||
import type { User } from '@backspace/shared';
|
||||
|
||||
export function NewDmModal() {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Avatar } from '../ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import type { InstanceStreamingLimits } from '@opencord/shared';
|
||||
import type { InstanceStreamingLimits } from '@backspace/shared';
|
||||
|
||||
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
|
||||
const VALID_FRAMERATES = [30, 45, 60] as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { User } from '@opencord/shared';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
interface AvatarProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { User } from '@opencord/shared';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
@@ -60,7 +60,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
||||
<div className="w-full h-[1px] bg-discord-modifier-accent mb-3" />
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="text-[12px] font-bold text-discord-text-header uppercase mb-1">Opencord Member Since</div>
|
||||
<div className="text-[12px] font-bold text-discord-text-header uppercase mb-1">Backspace Member Since</div>
|
||||
<div className="text-[12px] text-discord-text-normal font-medium">
|
||||
{new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ function AudioTrackElement({
|
||||
// The <audio> element is always muted — it serves only as a Chrome
|
||||
// keep-alive so Chrome continues processing the WebRTC track.
|
||||
// Real audio output goes through the Web Audio pipeline.
|
||||
return <audio ref={audioRef} autoPlay playsInline data-opencord="keepalive" />;
|
||||
return <audio ref={audioRef} autoPlay playsInline data-backspace="keepalive" />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useChatStore } from '../stores/chatStore';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
import { useSocialStore } from '../stores/socialStore';
|
||||
import { useSettingsStore } from '../stores/settingsStore';
|
||||
import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@opencord/shared';
|
||||
import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@backspace/shared';
|
||||
|
||||
let globalWs: WebSocket | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { User } from '@opencord/shared';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { api } from '../api/client';
|
||||
import { useChatStore } from './chatStore';
|
||||
import { useServerStore } from './serverStore';
|
||||
@@ -21,7 +21,7 @@ interface AuthState {
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
token: localStorage.getItem('opencord_token'),
|
||||
token: localStorage.getItem('backspace_token'),
|
||||
user: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
@@ -30,7 +30,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.auth.login({ username, password });
|
||||
localStorage.setItem('opencord_token', response.token);
|
||||
localStorage.setItem('backspace_token', response.token);
|
||||
set({ token: response.token, user: response.user, isLoading: false });
|
||||
} catch (err) {
|
||||
set({ isLoading: false, error: err instanceof Error ? err.message : 'Login failed' });
|
||||
@@ -42,7 +42,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.auth.register({ username, password, displayName });
|
||||
localStorage.setItem('opencord_token', response.token);
|
||||
localStorage.setItem('backspace_token', response.token);
|
||||
set({ token: response.token, user: response.user, isLoading: false });
|
||||
} catch (err) {
|
||||
set({ isLoading: false, error: err instanceof Error ? err.message : 'Registration failed' });
|
||||
@@ -51,7 +51,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('opencord_token');
|
||||
localStorage.removeItem('backspace_token');
|
||||
// Clear all user-scoped state to prevent data leaking between sessions
|
||||
useChatStore.getState().clearAllMessages();
|
||||
useServerStore.getState().populateFromReady([], [], []);
|
||||
@@ -69,7 +69,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
const user = await api.users.me();
|
||||
set({ user, isLoading: false });
|
||||
} catch {
|
||||
localStorage.removeItem('opencord_token');
|
||||
localStorage.removeItem('backspace_token');
|
||||
set({ token: null, user: null, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { MessageWithUser, Reaction, ReadState } from '@opencord/shared';
|
||||
import type { MessageWithUser, Reaction, ReadState } from '@backspace/shared';
|
||||
import { api } from '../api/client';
|
||||
import { wsSend } from '../hooks/useWebSocket';
|
||||
import { isDmChannel, useServerStore } from './serverStore';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User } from '@opencord/shared';
|
||||
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User } from '@backspace/shared';
|
||||
import { api } from '../api/client';
|
||||
|
||||
interface ServerState {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { InstanceStreamingLimits } from '@opencord/shared';
|
||||
import type { InstanceStreamingLimits } from '@backspace/shared';
|
||||
import { api } from '../api/client';
|
||||
|
||||
interface SettingsState {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Friend, FriendRequest, User } from '@opencord/shared';
|
||||
import type { Friend, FriendRequest, User } from '@backspace/shared';
|
||||
import { api } from '../api/client';
|
||||
|
||||
interface SocialState {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { User } from '@opencord/shared';
|
||||
import type { User } from '@backspace/shared';
|
||||
|
||||
type ModalType =
|
||||
| 'createServer'
|
||||
@@ -100,7 +100,7 @@ export const useUIStore = create<UIState>()(
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}),
|
||||
{
|
||||
name: 'opencord-ui-settings',
|
||||
name: 'backspace-ui-settings',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
memberListOpen: state.memberListOpen,
|
||||
|
||||
@@ -323,7 +323,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'opencord-voice-settings',
|
||||
name: 'backspace-voice-settings',
|
||||
version: 6,
|
||||
migrate: (persistedState: any, version: number) => {
|
||||
if (version === 0) {
|
||||
|
||||
@@ -8,4 +8,4 @@ export {
|
||||
hasPermissionBit,
|
||||
permissionsToString,
|
||||
stringToPermissions,
|
||||
} from '@opencord/shared/src/permissions';
|
||||
} from '@backspace/shared/src/permissions';
|
||||
|
||||
Generated
+6
-6
@@ -26,6 +26,9 @@ importers:
|
||||
|
||||
packages/server:
|
||||
dependencies:
|
||||
'@backspace/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
'@fastify/cors':
|
||||
specifier: ^9.0.1
|
||||
version: 9.0.1
|
||||
@@ -41,9 +44,6 @@ importers:
|
||||
'@fastify/websocket':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1
|
||||
'@opencord/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
@@ -102,12 +102,12 @@ importers:
|
||||
|
||||
packages/web:
|
||||
dependencies:
|
||||
'@backspace/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
'@livekit/components-react':
|
||||
specifier: ^2.7.4
|
||||
version: 2.9.19(livekit-client@2.17.1(@types/dom-mediacapture-record@1.0.22))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1)
|
||||
'@opencord/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
'@sapphi-red/web-noise-suppressor':
|
||||
specifier: ^0.3.5
|
||||
version: 0.3.5
|
||||
|
||||
Reference in New Issue
Block a user