Compare commits

5 Commits

135 changed files with 994 additions and 14188 deletions

170
.gitignore vendored
View File

@@ -1,140 +1,38 @@
# Logs
logs
*.log
# Dependencies
node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
# Prisma
generated
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
**/.helix
# Misc
.DS_Store
*.pem

43
README.md Normal file
View File

@@ -0,0 +1,43 @@
# Dependencies to run
- postgres
- bun
- setup biome in ide / editor
## env
```env
DATABASE_URL = "postgresql://concord_user:concord_test@localhost:5432/concord_db"
```
# Running
## API
```bash
cd apps/api
bun run dev
```
## DB
```bash
cd packages/database
bun run db:generate
bun run db:migrate
bun run db:push
bun run db:seed
bun run db:studio # optional
```
## Linting and Fixing
```bash
bunx turbo format-and-lint # view linting and formatting suggestions, without writing
bunx turbo format-and-lint:fix # write fixes
```
## Alternatively
All commands for DB and API can be run from repo root with:
```bash
bunx turbo <script> # db:generate, etc...
```

View File

@@ -1,3 +1,2 @@
# deps
node_modules/
generated/

22
apps/api/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "@concord/api",
"scripts": {
"dev": "bun run --hot src/index.ts",
"build": "bun build src/index.ts",
"start": "bun src/index.ts",
"lint": "biome lint .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@concord/database": "workspace:*",
"@hono/zod-validator": "^0.7.3",
"hono": "^4.9.10",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/bun": "latest",
"@concord/biome-config": "workspace:*",
"@concord/tsconfig": "workspace:*"
}
}

37
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import { prisma } from '@concord/database'; // types from prisma
import { Hono } from 'hono';
import { logger } from 'hono/logger';
const app = new Hono();
app.use('*', logger());
// Health check
app.get('/', (c) => {
return c.json({ ok: true, message: 'Hello from the Concord API' });
});
// route to get all users
const usersRoute = app.get('/users', async (c) => {
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
nickname: true,
createdAt: true,
},
});
return c.json(users);
});
const port = Number.parseInt(process.env.PORT || '3000', 10);
console.log(`API server listening on port ${port}`);
// This exports the type of our app's routes,
// which the client can use for end-to-end type safety.
export type AppType = typeof usersRoute;
export default {
port: 3000,
fetch: app.fetch,
};

10
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "@concord/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

3
biome.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["./packages/biome-config/biome.json"]
}

182
bun.lock Normal file
View File

@@ -0,0 +1,182 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "concord",
"devDependencies": {
"@biomejs/biome": "^2.2.5",
"turbo": "^2.5.8",
"typescript": "5.9.2",
},
},
"apps/api": {
"name": "@concord/api",
"dependencies": {
"@concord/database": "workspace:*",
"@hono/zod-validator": "^0.7.3",
"hono": "^4.9.10",
"zod": "^4.1.12",
},
"devDependencies": {
"@concord/biome-config": "workspace:*",
"@concord/tsconfig": "workspace:*",
"@types/bun": "latest",
},
},
"packages/biome-config": {
"name": "@concord/biome-config",
"version": "0.1.0",
},
"packages/database": {
"name": "@concord/database",
"dependencies": {
"@prisma/client": "^6.17.0",
},
"devDependencies": {
"@concord/biome-config": "workspace:*",
"@concord/tsconfig": "workspace:*",
"@types/bun": "latest",
"bcryptjs": "^3.0.2",
"prisma": "^6.17.0",
},
"peerDependencies": {
"typescript": "^5",
},
},
"packages/tsconfig": {
"name": "@concord/tsconfig",
"version": "0.1.0",
},
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.2.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.5", "@biomejs/cli-darwin-x64": "2.2.5", "@biomejs/cli-linux-arm64": "2.2.5", "@biomejs/cli-linux-arm64-musl": "2.2.5", "@biomejs/cli-linux-x64": "2.2.5", "@biomejs/cli-linux-x64-musl": "2.2.5", "@biomejs/cli-win32-arm64": "2.2.5", "@biomejs/cli-win32-x64": "2.2.5" }, "bin": { "biome": "bin/biome" } }, "sha512-zcIi+163Rc3HtyHbEO7CjeHq8DjQRs40HsGbW6vx2WI0tg8mYQOPouhvHSyEnCBAorfYNnKdR64/IxO7xQ5faw=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MYT+nZ38wEIWVcL5xLyOhYQQ7nlWD0b/4mgATW2c8dvq7R4OQjt/XGXFkXrmtWmQofaIM14L7V8qIz/M+bx5QQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-FLIEl73fv0R7dI10EnEiZLw+IMz3mWLnF95ASDI0kbx6DDLJjWxE5JxxBfmG+udz1hIDd3fr5wsuP7nwuTRdAg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5DjiiDfHqGgR2MS9D+AZ8kOfrzTGqLKywn8hoXpXXlJXIECGQ32t+gt/uiS2XyGBM2XQhR6ztUvbjZWeccFMoQ=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Ov2wgAFwqDvQiESnu7b9ufD1faRa+40uwrohgBopeY84El2TnBDoMNXx6iuQdreoFGjwW8vH6k68G21EpNERw=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-fq9meKm1AEXeAWan3uCg6XSP5ObA6F/Ovm89TwaMiy1DNIwdgxPkNwxlXJX8iM6oRbFysYeGnT0OG8diCWb9ew=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-AVqLCDb/6K7aPNIcxHaTQj01sl1m989CJIQFQEaiQkGr2EQwyOpaATJ473h+nXDUuAcREhccfRpe/tu+0wu0eQ=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-xaOIad4wBambwJa6mdp1FigYSIF9i7PCqRbvBqtIi9y29QtPVQ13sDGtUnsRoe6SjL10auMzQ6YAe+B3RpZXVg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw=="],
"@concord/api": ["@concord/api@workspace:apps/api"],
"@concord/biome-config": ["@concord/biome-config@workspace:packages/biome-config"],
"@concord/database": ["@concord/database@workspace:packages/database"],
"@concord/tsconfig": ["@concord/tsconfig@workspace:packages/tsconfig"],
"@hono/zod-validator": ["@hono/zod-validator@0.7.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uYGdgVib3RlGD698WR5dVM0zB3UuPY5vHKXffGUbUh7r4xY+mFIhF3/v4AcQVLrU5CQdBso8BJr4wuVoCrjTuQ=="],
"@prisma/client": ["@prisma/client@6.17.0", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-b42mTLOdLEZ6e/igu8CLdccAUX9AwHknQQ1+pHOftnzDP2QoyZyFvcANqSLs5ockimFKJnV7Ljf+qrhNYf6oAg=="],
"@prisma/config": ["@prisma/config@6.17.0", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.16.12", "empathic": "2.0.0" } }, "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ=="],
"@prisma/debug": ["@prisma/debug@6.17.0", "", {}, "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g=="],
"@prisma/engines": ["@prisma/engines@6.17.0", "", { "dependencies": { "@prisma/debug": "6.17.0", "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", "@prisma/fetch-engine": "6.17.0", "@prisma/get-platform": "6.17.0" } }, "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw=="],
"@prisma/engines-version": ["@prisma/engines-version@6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", "", {}, "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.17.0", "", { "dependencies": { "@prisma/debug": "6.17.0", "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", "@prisma/get-platform": "6.17.0" } }, "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg=="],
"@prisma/get-platform": ["@prisma/get-platform@6.17.0", "", { "dependencies": { "@prisma/debug": "6.17.0" } }, "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
"@types/node": ["@types/node@24.7.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="],
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"hono": ["hono@4.9.10", "", {}, "sha512-AlI15ijFyKTXR7eHo7QK7OR4RoKIedZvBuRjO8iy4zrxvlY5oFCdiRG/V/lFJHCNXJ0k72ATgnyzx8Yqa5arug=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"prisma": ["prisma@6.17.0", "", { "dependencies": { "@prisma/config": "6.17.0", "@prisma/engines": "6.17.0" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
"turbo": ["turbo@2.5.8", "", { "optionalDependencies": { "turbo-darwin-64": "2.5.8", "turbo-darwin-arm64": "2.5.8", "turbo-linux-64": "2.5.8", "turbo-linux-arm64": "2.5.8", "turbo-windows-64": "2.5.8", "turbo-windows-arm64": "2.5.8" }, "bin": { "turbo": "bin/turbo" } }, "sha512-5c9Fdsr9qfpT3hA0EyYSFRZj1dVVsb6KIWubA9JBYZ/9ZEAijgUEae0BBR/Xl/wekt4w65/lYLTFaP3JmwSO8w=="],
"turbo-darwin-64": ["turbo-darwin-64@2.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh5bCACiHO8rUXZLpKw+m3FiHtAp2CkanSyJre+SInEvEr5kIxjGvCK/8MFX8SFRjQuhjtvpIvYYZJB4AGCxNQ=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f1H/tQC9px7+hmXn6Kx/w8Jd/FneIUnvLlcI/7RGHunxfOkKJKvsoiNzySkoHQ8uq1pJnhJ0xNGTlYM48ZaJOQ=="],
"turbo-linux-64": ["turbo-linux-64@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-hMyvc7w7yadBlZBGl/bnR6O+dJTx3XkTeyTTH4zEjERO6ChEs0SrN8jTFj1lueNXKIHh1SnALmy6VctKMGnWfw=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-LQELGa7bAqV2f+3rTMRPnj5G/OHAe2U+0N9BwsZvfMvHSUbsQ3bBMWdSQaYNicok7wOZcHjz2TkESn1hYK6xIQ=="],
"turbo-windows-64": ["turbo-windows-64@2.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-3YdcaW34TrN1AWwqgYL9gUqmZsMT4T7g8Y5Azz+uwwEJW+4sgcJkIi9pYFyU4ZBSjBvkfuPZkGgfStir5BBDJQ=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-eFC5XzLmgXJfnAK3UMTmVECCwuBcORrWdewoiXBnUm934DY6QN8YowC/srhNnROMpaKaqNeRpoB5FxCww3eteQ=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
}
}

View File

@@ -1,18 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View File

@@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
dist-electron
*.local
release/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,30 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -1,43 +0,0 @@
// @see - https://www.electron.build/configuration/configuration
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "YourAppID",
"asar": true,
"productName": "YourAppName",
"directories": {
"output": "release/${version}"
},
"files": [
"dist",
"dist-electron"
],
"mac": {
"target": [
"dmg"
],
"artifactName": "${productName}-Mac-${version}-Installer.${ext}"
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-Windows-${version}-Setup.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false
},
"linux": {
"target": [
"AppImage"
],
"artifactName": "${productName}-Linux-${version}.${ext}"
}
}

View File

@@ -1,27 +0,0 @@
/// <reference types="vite-plugin-electron/electron-env" />
declare namespace NodeJS {
interface ProcessEnv {
/**
* The built directory structure
*
* ```tree
* ├─┬─┬ dist
* │ │ └── index.html
* │ │
* │ ├─┬ dist-electron
* │ │ ├── main.js
* │ │ └── preload.js
* │
* ```
*/
APP_ROOT: string
/** /dist/ or /public/ */
VITE_PUBLIC: string
}
}
// Used in Renderer process, expose in `preload.ts`
interface Window {
ipcRenderer: import('electron').IpcRenderer
}

View File

@@ -1,70 +0,0 @@
import { app, BrowserWindow } from "electron";
// import { createRequire } from 'node:module'
import { fileURLToPath } from "node:url";
import path from "node:path";
// const require = createRequire(import.meta.url)
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// The built directory structure
//
// ├─┬─┬ dist
// │ │ └── index.html
// │ │
// │ ├─┬ dist-electron
// │ │ ├── main.js
// │ │ └── preload.mjs
// │
process.env.APP_ROOT = path.join(__dirname, "..");
// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST;
let win: BrowserWindow | null;
function createWindow() {
win = new BrowserWindow({
icon: path.join(process.env.VITE_PUBLIC, "electron-vite.svg"),
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
},
});
// Test active push message to Renderer-process.
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
});
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL);
} else {
// win.loadFile('dist/index.html')
win.loadFile(path.join(RENDERER_DIST, "index.html"));
}
}
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
win = null;
}
});
app.on("activate", () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
app.whenReady().then(createWindow);

View File

@@ -1,24 +0,0 @@
import { ipcRenderer, contextBridge } from 'electron'
// --------- Expose some API to the Renderer process ---------
contextBridge.exposeInMainWorld('ipcRenderer', {
on(...args: Parameters<typeof ipcRenderer.on>) {
const [channel, listener] = args
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args))
},
off(...args: Parameters<typeof ipcRenderer.off>) {
const [channel, ...omit] = args
return ipcRenderer.off(channel, ...omit)
},
send(...args: Parameters<typeof ipcRenderer.send>) {
const [channel, ...omit] = args
return ipcRenderer.send(channel, ...omit)
},
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
const [channel, ...omit] = args
return ipcRenderer.invoke(channel, ...omit)
},
// You can expose other APTs you need here.
// ...
})

View File

@@ -1,13 +0,0 @@
<!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>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,66 +0,0 @@
{
"name": "concord-client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev:web": "VITE_APP_MODE=web bunx --bun vite",
"dev:electron": "bunx --bun vite --open",
"build": "tsc && bunx --bun vite build && electron-builder",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "bunx --bun vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router": "^7.9.3",
"react-syntax-highlighter": "^15.6.6",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14",
"zustand": "^5.0.8"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/bun": "^1.2.23",
"@types/react": "^18.3.26",
"@types/react-dom": "^18.3.7",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/socket.io-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.7.0",
"electron": "^30.5.1",
"electron-builder": "^24.13.3",
"eslint": "^8.57.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.23",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^5.4.20",
"vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6"
},
"main": "dist-electron/main.js"
}

View File

@@ -1,34 +0,0 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
<g id="not-lightning" clip-path="url(#clip0_103_2)">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 64 64"
to="360 64 64"
dur="20s"
repeatCount="indefinite"/>
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
</g>
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
<defs>
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
<clipPath id="clip0_103_2">
<rect width="128" height="128" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -1,26 +0,0 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_103_2)">
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
</g>
<defs>
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
<clipPath id="clip0_103_2">
<rect width="128" height="128" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,114 +0,0 @@
import React, { useEffect } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/theme-provider";
import AppLayout from "@/components/layout/AppLayout";
import LoginPage from "@/pages/LoginPage";
import ChatPage from "@/pages/ChatPage";
import SettingsPage from "@/pages/SettingsPage";
import NotFoundPage from "@/pages/NotFoundPage";
import { useVoiceStore } from "@/stores/voiceStore";
import { queryClient } from "@/lib/api-client";
import { useAuthStore } from "@/stores/authStore";
import ErrorBoundary from "@/components/common/ErrorBoundary";
import { Home } from "lucide-react";
import { Socket } from "socket.io-client";
// Protected Route wrapper
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const { isAuthenticated } = useAuthStore();
// Enable this when you want to enforce authentication
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
// Home page component - shows server selection
const HomePage: React.FC = () => {
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary max-w-md">
<div className="w-16 h-16 mx-auto mb-4 bg-concord-secondary rounded-full flex items-center justify-center">
<Home />
</div>
<h2 className="text-xl font-semibold mb-2 text-concord-primary">
Welcome to Concord
</h2>
<p className="text-sm mb-4">
Select a server from the sidebar to start chatting, or create a new
server
</p>
</div>
</div>
);
};
function App(props: { socket: Socket }) {
const initVoiceStore = useVoiceStore((state) => state.init);
useEffect(() => {
initVoiceStore(props.socket);
return () => {
useVoiceStore.getState().cleanup();
};
}, [props.socket, initVoiceStore]);
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="system" storageKey="concord-theme">
<Router>
<div className="h-screen w-screen overflow-hidden bg-background text-foreground">
<Routes>
{/* Auth routes */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes with layout */}
<Route
path="/"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
{/* Default redirect to home */}
<Route index element={<HomePage />} />
{/* Server and channel routes */}
<Route path="channels/:instanceId" element={<ChatPage />} />
<Route
path="channels/:instanceId/:channelId"
element={<ChatPage />}
/>
{/* Settings */}
<Route path="settings" element={<SettingsPage />} />
<Route path="settings/:section" element={<SettingsPage />} />
</Route>
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
</Router>
{import.meta.env.DEV === true && <ReactQueryDevtools />}
{/* Toast notifications */}
<Toaster />
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
);
}
export default App;

View File

@@ -1,13 +0,0 @@
# TODO
- Messages
- Channels should include messages
- sample data
- message components
- User
- Set up fake user with auth to:
- Confirm userpanel is ok
- test login flow
- Add server ui
- Add channel ui
- Role based for above ^

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,103 +0,0 @@
import React from "react";
import { Hash, Volume2 } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { Channel } from "@/lib/api-client";
import { useVoiceStore } from "@/stores/voiceStore";
import { useInstanceMembers } from "@/hooks/useServers";
import { useAuthStore } from "@/stores/authStore";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
interface ChannelItemProps {
channel: Channel;
}
const ChannelItem: React.FC<ChannelItemProps> = ({ channel }) => {
const { instanceId, channelId: activeChannelId } = useParams();
const navigate = useNavigate();
// Voice store hooks
const {
joinChannel,
leaveChannel,
activeVoiceChannelId,
remoteStreams,
localStream,
} = useVoiceStore();
// Data hooks
const { data: members } = useInstanceMembers(instanceId);
const { user: currentUser, token } = useAuthStore(); // Get token from auth store
const isConnectedToThisChannel = activeVoiceChannelId === channel.id;
const isActive = activeChannelId === channel.id;
const handleChannelClick = () => {
if (channel.type === "text") {
navigate(`/channels/${instanceId}/${channel.id}`);
} else if (channel.type === "voice") {
if (isConnectedToThisChannel) {
leaveChannel();
} else if (currentUser && token) {
joinChannel(channel.id, currentUser.id, token);
}
}
};
const Icon = channel.type === "voice" ? Volume2 : Hash;
const connectedUserIds = Array.from(remoteStreams.keys());
return (
<div className={`${isActive ?? "visible"}`}>
<button
onClick={handleChannelClick}
className={`w-full flex items-center p-1.5 rounded-md text-left transition-colors ${
isActive || isConnectedToThisChannel
? "bg-concord-secondary text-concord-primary"
: "text-concord-secondary hover:bg-concord-secondary/50 hover:text-concord-primary"
}`}
>
<Icon className="w-5 h-5 mr-2 flex-shrink-0" />
<span className="truncate flex-1">{channel.name}</span>
</button>
{/* Render connected users for this voice channel */}
{isConnectedToThisChannel && (
<div className="pl-4 mt-1 space-y-1">
{/* Current User */}
{localStream && currentUser && (
<div className="flex items-center p-1">
<Avatar className="h-6 w-6">
<AvatarImage src={currentUser.picture || ""} />
<AvatarFallback>
{currentUser.username.slice(0, 2)}
</AvatarFallback>
</Avatar>
<span className="ml-2 text-sm text-concord-primary">
{currentUser.nickname || currentUser.username}
</span>
</div>
)}
{/* Remote Users */}
{connectedUserIds.map((userId) => {
const member = members?.find((m) => m.id === userId);
if (!member) return null;
return (
<div key={userId} className="flex items-center p-1">
<Avatar className="h-6 w-6">
<AvatarImage src={member.picture || ""} />
<AvatarFallback>{member.username.slice(0, 2)}</AvatarFallback>
</Avatar>
<span className="ml-2 text-sm text-concord-primary">
{member.nickname || member.username}
</span>
</div>
);
})}
</div>
)}
</div>
);
};
export default ChannelItem;

View File

@@ -1,102 +0,0 @@
import React, { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CategoryWithChannels } from "@/types/api";
import ChannelItem from "@/components/channel/ChannelItem";
interface CategoryHeaderProps {
category: CategoryWithChannels;
isExpanded: boolean;
onToggle: () => void;
}
const CategoryHeader: React.FC<CategoryHeaderProps> = ({
category,
isExpanded,
onToggle,
}) => {
return (
<Button
variant="ghost"
className="w-full justify-between p-4 h-6 text-md text-concord-primary font-semibold interactive-hover uppercase tracking-wide group"
onClick={() => {
onToggle();
}}
>
<div className="flex items-center">
{isExpanded ? (
<ChevronDown size={12} className="mr-1" />
) : (
<ChevronRight size={12} className="mr-1" />
)}
<span className="truncate">{category.name}</span>
</div>
</Button>
);
};
interface ChannelListProps {
categories: CategoryWithChannels[];
}
const ChannelList: React.FC<ChannelListProps> = ({ categories }) => {
// Track expanded categories
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(categories.map((cat) => cat.id)), // Start with all expanded
);
const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
} else {
newSet.add(categoryId);
}
return newSet;
});
};
if (!categories || categories.length === 0) {
return (
<div className="text-sm text-gray-400 px-2 py-4 text-center">
No channels available
</div>
);
}
return (
<div className="space-y-1">
{categories
.sort((a, b) => a.position - b.position)
.map((category) => {
const isExpanded = expandedCategories.has(category.id);
return (
<div key={category.id} className="space-y-0.5">
{/* Category Header */}
<CategoryHeader
category={category}
isExpanded={isExpanded}
onToggle={() => toggleCategory(category.id)}
/>
<div
className={`ml-2 space-y-0.5 transition-all duration-300 ease-in-out overflow-hidden ${
isExpanded ? "max-h-screen opacity-100" : "max-h-0 opacity-0"
}`}
>
{category.channels
.sort((a, b) => a.position - b.position)
.map((channel) => (
<ChannelItem key={channel.id} channel={channel} />
))}
</div>
</div>
);
})}
</div>
);
};
export default ChannelList;

View File

@@ -1,137 +0,0 @@
import React from "react";
import {
Avatar as ShadcnAvatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { User } from "@/types/database";
import { cn } from "@/lib/utils";
interface AvatarProps {
user: Pick<User, "id" | "username" | "nickname" | "picture" | "status">;
size?: "xs" | "sm" | "md" | "lg" | "xl";
showStatus?: boolean;
className?: string;
onClick?: () => void;
}
const sizeClasses = {
xs: "h-6 w-6",
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-12 w-12",
xl: "h-16 w-16",
};
const statusSizes = {
xs: "w-2 h-2",
sm: "w-3 h-3",
md: "w-3 h-3",
lg: "w-4 h-4",
xl: "w-5 h-5",
};
const statusPositions = {
xs: "-bottom-0.5 -right-0.5",
sm: "-bottom-0.5 -right-0.5",
md: "-bottom-0.5 -right-0.5",
lg: "-bottom-1 -right-1",
xl: "-bottom-1 -right-1",
};
const Avatar: React.FC<AvatarProps> = ({
user,
size = "md",
showStatus = false,
className,
onClick,
}) => {
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-status-online";
case "away":
return "bg-status-away";
case "busy":
return "bg-status-busy";
case "offline":
default:
return "bg-status-offline";
}
};
const getUserInitials = (username: string, nickname: string | null) => {
const name = nickname || username;
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const getFallbackColor = (userId: string) => {
// Generate a consistent color based on user ID using theme colors
const colors = [
"bg-red-500",
"bg-blue-500",
"bg-green-500",
"bg-yellow-500",
"bg-purple-500",
"bg-pink-500",
"bg-indigo-500",
"bg-teal-500",
];
const hash = userId.split("").reduce((a, b) => {
a = (a << 5) - a + b.charCodeAt(0);
return a & a;
}, 0);
return colors[Math.abs(hash) % colors.length];
};
return (
<div className="relative inline-block">
<ShadcnAvatar
className={cn(
sizeClasses[size],
onClick && "cursor-pointer hover:opacity-80 transition-opacity",
className,
)}
onClick={onClick}
>
<AvatarImage
src={user.picture || undefined}
alt={user.nickname || user.username}
/>
<AvatarFallback
className={cn(
"text-white font-medium",
getFallbackColor(user.id),
size === "xs" && "text-xs",
size === "sm" && "text-xs",
size === "md" && "text-sm",
size === "lg" && "text-base",
size === "xl" && "text-lg",
)}
>
{getUserInitials(user.username, user.nickname ? user.nickname : null)}
</AvatarFallback>
</ShadcnAvatar>
{showStatus && (
<div
className={cn(
"absolute rounded-full border-2 border-sidebar",
statusSizes[size],
statusPositions[size],
getStatusColor(user.status),
)}
/>
)}
</div>
);
};
export default Avatar;

View File

@@ -1,120 +0,0 @@
import { Component, ErrorInfo, ReactNode } from "react";
import { AlertTriangle, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Card } from "../ui/card";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
this.setState({
error,
errorInfo,
});
}
private handleReload = () => {
window.location.reload();
};
private handleReset = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
};
public render() {
if (this.state.hasError) {
// Custom fallback UI
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen bg-concord-primary flex items-center justify-center p-4">
<Card className="max-w-md bg-concord-secondary border-concord w-full space-y-4">
<Alert className="border-red-500 bg-red-950/50">
<AlertTriangle className="h-4 w-4 text-red-500" />
<AlertTitle className="text-red-400">
Something went wrong
</AlertTitle>
<AlertDescription className="text-gray-300">
The application encountered an unexpected error. This might be a
temporary issue.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Button
onClick={this.handleReset}
className="w-full"
variant="outline"
>
<RotateCcw size={16} className="mr-2" />
Try Again
</Button>
<Button
onClick={this.handleReload}
variant="secondary"
className="w-full"
>
Reload Application
</Button>
</div>
{/* Error details in development */}
{process.env.NODE_ENV === "development" && this.state.error && (
<details className="mt-4 p-3 bg-concord-secondary rounded-lg text-sm">
<summary className="cursor-pointer text-red-400 font-medium mb-2">
Error Details (Development)
</summary>
<div className="space-y-2 text-gray-300">
<div>
<strong>Error:</strong> {this.state.error.message}
</div>
<div>
<strong>Stack:</strong>
<pre className="mt-1 text-xs overflow-auto text-gray-400">
{this.state.error.stack}
</pre>
</div>
{this.state.errorInfo && (
<div>
<strong>Component Stack:</strong>
<pre className="mt-1 text-xs overflow-auto text-gray-400">
{this.state.errorInfo.componentStack}
</pre>
</div>
)}
</div>
</details>
)}
</Card>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -1,41 +0,0 @@
import React from "react";
import { cn } from "@/lib/utils";
interface LoadingSpinnerProps {
size?: "xs" | "sm" | "md" | "lg" | "xl";
className?: string;
color?: "white" | "blue" | "gray";
}
const sizeClasses = {
xs: "h-3 w-3",
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-8 w-8",
xl: "h-12 w-12",
};
const colorClasses = {
white: "border-white",
blue: "border-blue-500",
gray: "border-gray-400",
};
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = "md",
className,
color = "white",
}) => {
return (
<div
className={cn(
"animate-spin rounded-full border-2 border-transparent border-t-current",
sizeClasses[size],
colorClasses[color],
className,
)}
/>
);
};
export default LoadingSpinner;

View File

@@ -1,79 +0,0 @@
import React, { useEffect } from "react";
import { Outlet, useLocation } from "react-router";
import { useAuthStore } from "@/stores/authStore";
import { useUiStore } from "@/stores/uiStore";
import ServerSidebar from "@/components/layout/ServerSidebar";
import ChannelSidebar from "@/components/layout/ChannelSidebar";
import UserPanel from "@/components/layout/UserPanel";
import MemberList from "@/components/layout/MemberList";
import LoadingSpinner from "@/components/common/LoadingSpinner";
import VoiceConnectionManager from "@/components/voice/VoiceConnectionManager";
const AppLayout: React.FC = () => {
const { isLoading } = useAuthStore();
const {
showMemberList,
sidebarCollapsed,
shouldShowChannelSidebar,
updateSidebarVisibility,
} = useUiStore();
const location = useLocation();
// Update sidebar visibility when route changes
useEffect(() => {
updateSidebarVisibility(location.pathname);
}, [location.pathname, updateSidebarVisibility]);
if (isLoading) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-concord-primary">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="flex h-screen overflow-y-auto bg-concord-primary text-concord-primary">
{/* This component handles playing audio from remote users */}
<VoiceConnectionManager />
{/* Server List Sidebar - Always visible on desktop, overlay on mobile */}
<div className="relative min-w-1/16 sidebar-primary flex-shrink-0">
<ServerSidebar />
</div>
{/* Channel Sidebar - Only shown when in a server context and not collapsed */}
{shouldShowChannelSidebar && (
<div
className={`${
sidebarCollapsed
? "w-0" // Collapse by setting width to 0
: "w-60" // Default width
}
flex-col flex-shrink-0 sidebar-secondary transition-all duration-200 ease-in-out overflow-hidden`}
>
<div className="flex flex-col h-full">
<ChannelSidebar />
<UserPanel />
</div>
</div>
)}
{/* Main Content Area */}
<div
className={`flex-1 flex flex-col min-w-0 ${
!sidebarCollapsed ? "" : ""
} transition-all duration-200 ease-in-out bg-concord-secondary`}
>
<Outlet />
</div>
{/* Member List - Only shown when in a channel and member list is enabled */}
{showMemberList && shouldShowChannelSidebar && (
<div className="flex-0 min-w-1/7 sidebar-secondary order-l border-sidebar">
<MemberList />
</div>
)}
</div>
);
};
export default AppLayout;

View File

@@ -1,99 +0,0 @@
import React from "react";
import { useParams } from "react-router";
import { ChevronDown, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useInstanceDetails } from "@/hooks/useServers";
import { useUiStore } from "@/stores/uiStore";
import ChannelList from "@/components/channel/ChannelList";
import { CreateChannelModal } from "@/components/modals/CreateChannelModal";
const ChannelSidebar: React.FC = () => {
const { instanceId } = useParams();
const { data: instance, isLoading: instanceLoading } =
useInstanceDetails(instanceId);
const categories = instance?.categories;
const {
showCreateChannel,
closeCreateChannel,
openCreateChannel,
openServerSettings,
} = useUiStore();
// Only show for valid instance IDs
if (!instanceId) {
return null;
}
if (instanceLoading) {
return (
<div className="sidebar-secondary flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
if (!instance) {
return (
<div className="sidebar-secondary flex items-center justify-center h-full">
<div className="text-concord-secondary">Server not found</div>
</div>
);
}
return (
<div className="sidebar-secondary flex-1">
<ScrollArea className="">
{/* Server Header */}
<div className="flex items-center justify-between border-b border-concord-primary shadow-sm px-6 py-4">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<Button
variant="ghost"
className="flex items-center justify-between w-full h-8 font-semibold text-concord-primary text-xl hover:bg-concord-tertiary"
onClick={openServerSettings}
>
<span className="truncate">{instance.name}</span>
<ChevronDown size={20} className="flex-shrink-0 ml-1" />
</Button>
</div>
</div>
{/* Channel Categories and Channels */}
<ScrollArea className="flex-1">
<div className="p-2">
{categories && categories.length > 0 ? (
<ChannelList categories={categories} />
) : (
<div className="text-sm text-concord-secondary text-center px-2 py-4">
No channels yet
</div>
)}
</div>
</ScrollArea>
{/* Bottom Actions */}
<div className="border-t border-sidebar px-2 py-2">
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="justify-start interactive-hover flex-grow-1"
onClick={openCreateChannel}
>
<Plus size={16} className="mr-1" />
Add Channel
</Button>
</div>
</div>
</ScrollArea>
<CreateChannelModal
isOpen={showCreateChannel}
onClose={closeCreateChannel}
categories={categories}
instanceId={instance.id}
/>
</div>
);
};
export default ChannelSidebar;

View File

@@ -1,265 +0,0 @@
import React from "react";
import { useParams } from "react-router";
import { Crown, Shield, UserIcon } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Role, User } from "@/types/database";
import { useInstanceMembers } from "@/hooks/useServers";
import { useAuthStore } from "@/stores/authStore";
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-status-online";
case "away":
return "bg-status-away";
case "busy":
return "bg-status-busy";
default:
return "bg-status-offline";
}
};
interface MemberItemProps {
member: User;
instanceId: string;
isOwner?: boolean;
currentUserRolePriority: number;
}
const getUserRoleForInstance = (roles: Role[], instanceId: string): string => {
if (!instanceId) return "member";
const roleEntry = roles.find((r) => r.instanceId === instanceId);
return roleEntry?.role || "member";
};
const getRoleInfo = (role: string) => {
const lowerRole = role.toLowerCase();
switch (lowerRole) {
case "admin":
return { color: "#ff6b6b", priority: 3, name: "Admin" };
case "mod":
return { color: "#4ecdc4", priority: 2, name: "Moderator" };
case "member":
return { color: null, priority: 1, name: "Member" };
default:
return {
color: null,
priority: 0,
name: role.charAt(0).toUpperCase() + role.slice(1),
};
}
};
const MemberItem: React.FC<MemberItemProps> = ({
member,
instanceId,
isOwner = false,
currentUserRolePriority,
}) => {
// Determine the role for this specific instance
const userRole = getUserRoleForInstance(member.roles, instanceId);
const roleInfo = getRoleInfo(userRole);
const memberRolePriority = roleInfo.priority;
// Consider if this member is a global admin as well
const isGlobalAdmin = member.admin || false;
let effectiveRoleInfo = roleInfo;
let effectiveMemberRolePriority = memberRolePriority;
if (isGlobalAdmin && roleInfo.priority < 3) {
effectiveRoleInfo = getRoleInfo("admin");
effectiveMemberRolePriority = 3;
}
return (
<Button
variant="ghost"
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
// disable if the current member is an admin
disabled={currentUserRolePriority < 3 || effectiveMemberRolePriority >= 3}
>
<div className="flex items-center gap-3 w-full">
<div className="relative">
<Avatar className="h-8 w-8">
<AvatarImage
src={member.picture || undefined}
alt={member.username}
/>
<AvatarFallback className="text-xs bg-primary text-primary-foreground">
{member.username?.slice(0, 2).toUpperCase() || "???"}
</AvatarFallback>
</Avatar>
{/* Status indicator */}
<div
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-sidebar ${getStatusColor(member.status)}`}
/>
</div>
<div className="flex-1 min-w-0 text-left">
<div className="flex items-center gap-1">
{isOwner && (
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
)}
{/* Display Shield for Admins and Mods, not for Members */}
{!isOwner && effectiveMemberRolePriority > 1 && (
<Shield
size={12}
className="flex-shrink-0"
style={{
color: effectiveRoleInfo.color || "var(--background)",
}}
/>
)}
<span
className="text-sm font-medium truncate"
style={{
color: effectiveRoleInfo.color || "var(--color-text-primary)",
}}
>
{member.nickname || member.username}
</span>
</div>
{member.bio && (
<div className="text-xs text-concord-secondary truncate">
{member.bio}
</div>
)}
</div>
</div>
</Button>
);
};
const MemberList: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const { data: members, isLoading } = useInstanceMembers(instanceId);
const { user: currentUser } = useAuthStore();
const currentUserRoleInfo = React.useMemo(() => {
if (!currentUser || !instanceId) {
return { role: "member", priority: 1, name: "Member", color: null };
}
// If the current user is a global admin, they are effectively an admin of any instance.
if (currentUser.admin) {
return { role: "admin", priority: 3, name: "Admin", color: "#ff6b6b" };
}
const role = getUserRoleForInstance(currentUser.roles, instanceId);
return { ...getRoleInfo(role), role: role };
}, [currentUser, instanceId]);
if (!instanceId) {
return null;
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
);
}
if (!members || members.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-concord-secondary text-sm">No members found</div>
</div>
);
}
// Group members by their role for the current instance.
const groupedMembers = members.reduce(
(acc, member) => {
// Determine the effective role for this instance.
let effectiveRoleName = getUserRoleForInstance(
member.roles as Role[],
instanceId,
);
// Global admin is instance admin
if (member.admin && effectiveRoleName !== "admin") {
effectiveRoleName = "admin";
}
const roleInfo = getRoleInfo(effectiveRoleName);
if (!acc[roleInfo.name]) {
acc[roleInfo.name] = [];
}
acc[roleInfo.name].push(member as User);
return acc;
},
{} as Record<string, User[]>,
);
// Get all unique role names present and sort them by priority.
const sortedRoleNames = Object.keys(groupedMembers).sort(
(roleNameA, roleNameB) => {
const priorityA = getRoleInfo(roleNameA).priority;
const priorityB = getRoleInfo(roleNameB).priority;
return priorityB - priorityA;
},
);
return (
<div className="flex flex-col flex-grow-1 w-full border-l border-concord h-full sidebar-secondary">
{/* Header */}
<div className="px-6 py-4 pb-5 border-b border-concord flex items-center justify-between">
<div className="flex items-center space-x-2">
<UserIcon size={20} className="h-5 w-5 text-concord-secondary" />
<p className="font-semibold text-xl text-concord-primary tracking-wide">
Members
</p>
</div>
<p className="font-medium text-concord-secondary tracking-wide">
{members.length}
</p>
</div>
{/* Member List */}
<ScrollArea className="flex-1">
<div className="py-2">
{sortedRoleNames.map((roleName) => {
const roleMembers = groupedMembers[roleName];
// Sort members within each role group alphabetically by username.
const sortedMembers = roleMembers.sort((a, b) =>
(a.nickname || a.username).localeCompare(
b.nickname || b.username,
),
);
return (
<div key={roleName} className="mb-4">
{/* Role Header */}
<div className="px-4 py-1">
<h4 className="text-xs font-semibold text-concord-secondary uppercase tracking-wide">
{roleName} {roleMembers.length}
</h4>
</div>
{/* Role Members */}
<div className="space-y-1">
{sortedMembers.map((member) => (
<MemberItem
key={member.id}
member={member}
instanceId={instanceId}
currentUserRolePriority={currentUserRoleInfo.priority}
isOwner={false}
/>
))}
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
);
};
export default MemberList;

View File

@@ -1,141 +0,0 @@
import React from "react";
import { useNavigate, useParams } from "react-router";
import { Plus, Home } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useServers } from "@/hooks/useServers";
import { useUiStore } from "@/stores/uiStore";
import { useAuthStore } from "@/stores/authStore";
import ServerIcon from "@/components/server/ServerIcon";
import { getAccessibleInstances, isGlobalAdmin } from "@/utils/permissions";
const ServerSidebar: React.FC = () => {
const navigate = useNavigate();
const { instanceId } = useParams();
const { data: allServers = [], isLoading } = useServers();
const { openCreateServer, setActiveInstance, getSelectedChannelForInstance } =
useUiStore();
const { user: currentUser } = useAuthStore();
// Filter servers based on user permissions
const accessibleServers = getAccessibleInstances(currentUser, allServers);
const canCreateServer = isGlobalAdmin(currentUser);
const handleServerClick = (serverId: string) => {
setActiveInstance(serverId);
const lastChannelId = getSelectedChannelForInstance(serverId);
if (lastChannelId) {
navigate(`/channels/${serverId}/${lastChannelId}`);
} else {
// Fallback: navigate to the server, let the page component handle finding a channel
navigate(`/channels/${serverId}`);
}
};
const handleHomeClick = () => {
setActiveInstance(null);
navigate("/");
};
const handleCreateServer = () => {
if (canCreateServer) {
openCreateServer();
}
};
return (
<TooltipProvider>
<div className="sidebar-primary flex flex-col items-center h-full space-y-2 w-full">
{/* Home/DM Button */}
<Tooltip key={"home-server"}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`w-12 h-12 mt-2 transition-all duration-200 ${
!instanceId || instanceId === "@me"
? "rounded-xl border-primary bg-primary/10 border-2"
: "rounded-2xl hover:rounded-xl border hover:border-primary/50"
}`}
onClick={handleHomeClick}
>
<Home size={4} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{isGlobalAdmin(currentUser) ? "Admin Dashboard" : "Home"}</p>
</TooltipContent>
</Tooltip>
{/* Separator */}
<div className="w-full h-0.5 bg-border rounded-full" />{" "}
{/* Server List */}
<div className="flex-1 flex flex-col overflow-y-auto scrollbar-thin scrollbar-thumb-border space-y-2">
{isLoading ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
) : accessibleServers.length > 0 ? (
accessibleServers.map((server) => (
<Tooltip key={server.id}>
<TooltipTrigger asChild>
<div>
<ServerIcon
server={server}
isActive={instanceId === server.id}
onClick={() => handleServerClick(server.id)}
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>{server.name}</p>
</TooltipContent>
</Tooltip>
))
) : currentUser ? (
<div className="text-center py-4 px-2">
<div className="text-xs text-concord-secondary mb-2">
No servers available
</div>
{canCreateServer && (
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={handleCreateServer}
>
Create One
</Button>
)}
</div>
) : null}
{/* Add Server Button - Only show if user can create servers */}
{canCreateServer && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-12 mb-4 h-12 rounded-2xl hover:rounded-xl bg-concord-secondary hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200"
onClick={handleCreateServer}
>
<Plus size={24} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Add a Server</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</TooltipProvider>
);
};
export default ServerSidebar;

View File

@@ -1,179 +0,0 @@
import React from "react";
import { Settings, Mic, MicOff, Headphones } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAuthStore } from "@/stores/authStore";
import { useVoiceStore } from "@/stores/voiceStore";
import { useNavigate } from "react-router";
// Status color utility
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-status-online";
case "away":
return "bg-status-away";
case "busy":
return "bg-status-busy";
default:
return "bg-status-offline";
}
};
// Voice Controls Component
interface VoiceControlsProps {
isMuted: boolean;
isDeafened: boolean;
onMuteToggle: () => void;
onDeafenToggle: () => void;
}
const VoiceControls: React.FC<VoiceControlsProps> = ({
isMuted,
isDeafened,
onMuteToggle,
onDeafenToggle,
}) => {
return (
<div className="flex items-center space-x-1">
<TooltipProvider>
{/* Mute/Unmute */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 ${isMuted ? "text-destructive hover:text-destructive/80" : "interactive-hover"}`}
onClick={onMuteToggle}
>
{isMuted ? <MicOff size={18} /> : <Mic size={18} />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isMuted ? "Unmute" : "Mute"}</p>
</TooltipContent>
</Tooltip>
{/* Deafen/Undeafen */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 ${isDeafened ? "text-destructive hover:text-destructive/80" : "interactive-hover"}`}
onClick={onDeafenToggle}
>
<Headphones size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isDeafened ? "Undeafen" : "Deafen"}</p>
</TooltipContent>
</Tooltip>
{/* Settings */}
</TooltipProvider>
</div>
);
};
// User Avatar Component
interface UserAvatarProps {
user: any;
size?: "sm" | "md" | "lg";
showStatus?: boolean;
}
const UserAvatar: React.FC<UserAvatarProps> = ({
user,
size = "md",
showStatus = true,
}) => {
const sizeClasses = {
sm: "h-6 w-6",
md: "h-8 w-8",
lg: "h-10 w-10",
};
const statusSizeClasses = {
sm: "w-2 h-2",
md: "w-3 h-3",
lg: "w-4 h-4",
};
return (
<div className="relative">
<Avatar className={sizeClasses[size]}>
<AvatarImage src={user.picture || undefined} alt={user.username} />
<AvatarFallback className="text-xs text-primary-foreground bg-primary">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{showStatus && (
<div
className={`absolute -bottom-0.5 -right-0.5 ${statusSizeClasses[size]} rounded-full border-2 border-sidebar ${getStatusColor(user.status)}`}
/>
)}
</div>
);
};
const UserPanel: React.FC = () => {
const { user } = useAuthStore();
const navigate = useNavigate();
const { isConnected, isMuted, isDeafened, toggleMute, toggleDeafen } =
useVoiceStore();
return (
<div className="user-panel flex items-center p-3 bg-concord-tertiary border-t border-sidebar min-h-16 rounded-xl m-2">
{/* User Info */}
<UserAvatar user={user} size="md" />
<div className="ml-2 flex-1 min-w-0 text-left">
<div className="text-sm font-medium text-concord-primary truncate">
{user?.nickname || user?.username}
</div>
<div className="text-xs text-concord-secondary truncate capitalize">
{user?.status}
</div>
</div>
{/* Voice Controls */}
{isConnected && (
<VoiceControls
isMuted={isMuted}
isDeafened={isDeafened}
onMuteToggle={toggleMute}
onDeafenToggle={toggleDeafen}
/>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 interactive-hover"
onClick={() => navigate("/settings")}
>
<Settings size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>User Settings</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
export default UserPanel;

View File

@@ -1,55 +0,0 @@
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Copy, Reply, MoreHorizontal } from "lucide-react";
import { Message } from "@/lib/api-client";
interface MessageActionsModalProps {
message: Message;
// isOwnMessage?: boolean;
onReply?: (messageId: string) => void;
}
export const MessageActionsDropdown: React.FC<MessageActionsModalProps> = ({
message,
onReply,
// isOwnMessage,
}) => {
const handleReply = () => {
onReply?.(message.id);
};
const handleCopyText = () => {
navigator.clipboard.writeText(message.text);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
<DropdownMenuItem onSelect={handleReply}>
<Reply className="h-4 w-4 mr-2" />
<span>Reply</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleCopyText}>
<Copy className="h-4 w-4 mr-2" />
<span>Copy Text</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,244 +0,0 @@
import { Copy, Reply, Pin } from "lucide-react";
import { Button } from "@/components/ui/button";
import ReactMarkdown from "react-markdown";
import { formatDistanceToNow, isValid, parseISO } from "date-fns";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import SyntaxHighlighter from "react-syntax-highlighter";
import {
dark,
solarizedLight,
} from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useTheme } from "@/components/theme-provider";
import { Message } from "@/lib/api-client";
import { useState } from "react";
// User type for message component
interface MessageUser {
id: string;
username?: string;
nickname?: string | null;
picture?: string | null;
}
// Message Props interface
interface MessageProps {
message: Message;
user: MessageUser;
replyTo?: Message;
replyToUser?: MessageUser;
onReply?: (messageId: string) => void;
}
export const MessageComponent: React.FC<MessageProps> = ({
message,
user,
replyTo,
replyToUser,
onReply,
}) => {
const [isHovered, setIsHovered] = useState(false);
const formatTimestamp = (timestamp: string) => {
try {
// First try parsing as ISO string
let date = parseISO(timestamp);
// If that fails, try regular Date constructor
if (!isValid(date)) {
date = new Date(timestamp);
}
// Final check if date is valid
if (!isValid(date)) {
console.error("Invalid timestamp:", timestamp);
return "Invalid date";
}
return formatDistanceToNow(date, { addSuffix: true });
} catch (error) {
console.error("Error formatting timestamp:", timestamp, error);
return "Invalid date";
}
};
// const isOwnMessage = currentUser?.id === message.userId;
const { mode } = useTheme();
// Get username with fallback
const username = user.username || user.username || "Unknown User";
const displayName = user.nickname || user.nickname || username;
const isDeleted = message.deleted;
if (isDeleted) {
return (
<div className="px-4 py-2 opacity-50">
<div className="flex gap-3">
<div className="w-10 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-concord-secondary italic border border-border rounded px-3 py-2 bg-concord-tertiary/50">
This message has been deleted
</div>
</div>
</div>
</div>
);
}
return (
<>
<div
className="group relative px-4 py-2 hover:bg-concord-secondary/50 transition-colors"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex gap-3">
{/* Avatar - always show */}
<div className="w-10 flex-shrink-0">
<Avatar className="h-10 w-10">
<AvatarImage src={user.picture || undefined} alt={username} />
<AvatarFallback className="text-sm bg-primary text-primary-foreground">
{username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
{/* Message content */}
<div className="flex-1 min-w-0">
{/* Reply line and reference */}
{replyTo && replyToUser && (
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary">
{replyToUser.nickname || replyToUser.username}
</span>
<span className="truncate max-w-xs opacity-75">
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
</span>
</div>
)}
{/* Header - always show */}
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-concord-primary">
{displayName}
</span>
<span className="text-xs text-concord-secondary">
{formatTimestamp(message.createdAt)}
</span>
{message.edited && (
<span className="text-xs text-concord-secondary opacity-60">
(edited)
</span>
)}
{(message as any).pinned && (
<Pin className="h-3 w-3 text-concord-primary" />
)}
</div>
{/* Message content with markdown */}
<div className="text-concord-primary leading-relaxed prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown
components={{
code: ({ className, children }) => {
const match = /language-(\w+)/.exec(className || "");
return match ? (
<div className="flex flex-row flex-1 max-w-2/3 flex-wrap !bg-transparent">
<SyntaxHighlighter
PreTag="div"
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={mode === "light" ? solarizedLight : dark}
className="!bg-concord-secondary p-2 border-2 concord-border rounded-xl"
/>
</div>
) : (
<code className={className}>{children}</code>
);
},
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary pl-4 my-2 italic text-concord-secondary bg-concord-secondary/30 py-2 rounded-r">
{children}
</blockquote>
),
p: ({ children }) => (
<p className="my-1 text-concord-primary">{children}</p>
),
strong: ({ children }) => (
<strong className="font-semibold text-concord-primary">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-concord-primary">{children}</em>
),
ul: ({ children }) => (
<ul className="list-disc list-inside my-2 text-concord-primary">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside my-2 text-concord-primary">
{children}
</ol>
),
h1: ({ children }) => (
<h1 className="text-xl font-bold my-2 text-concord-primary">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-bold my-2 text-concord-primary">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-bold my-2 text-concord-primary">
{children}
</h3>
),
a: ({ children, href }) => (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
}}
>
{message.text}
</ReactMarkdown>
</div>
</div>
{/* Message actions */}
{isHovered && (
<div className="absolute top-0 right-4 bg-concord-secondary border border-border rounded-md shadow-md flex">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 interactive-hover"
onClick={() => onReply?.(message.id)}
>
<Reply className="h-4 w-4" />
</Button>
<Button
variant="ghost"
className="h-8 w-8 p-0 interactive-hover"
onClick={() => navigator.clipboard.writeText(message.text)}
>
<Copy className="h-4 w-4" />
</Button>
{/*<MessageActionsDropdown
message={message}
onReply={() => onReply?.(message.id)}
/>*/}
</div>
)}
</div>
</div>
</>
);
};

View File

@@ -1,108 +0,0 @@
import { Message } from "@/lib/api-client";
import { useState, useRef, useEffect } from "react";
import { useSendMessage } from "@/hooks/useMessages";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface MessageUser {
id: string;
username?: string;
nickname?: string | null;
picture?: string | null;
}
interface MessageInputProps {
channelId: string;
channelName?: string;
replyingTo?: Message | null;
onCancelReply?: () => void;
replyingToUser: MessageUser | null;
}
export const MessageInput: React.FC<MessageInputProps> = ({
channelId,
channelName,
replyingTo,
onCancelReply,
replyingToUser,
}) => {
const [content, setContent] = useState("");
const formRef = useRef<HTMLFormElement>(null);
// Use the API hook for sending messages
const sendMessageMutation = useSendMessage();
// Auto-resize textarea (using direct DOM access as a fallback, no ref needed)
useEffect(() => {
const textarea = document.getElementById(
"message-input-textarea",
) as HTMLTextAreaElement | null;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}
}, [content]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (content.trim() && !sendMessageMutation.isPending) {
try {
await sendMessageMutation.mutateAsync({
channelId,
content: content.trim(),
repliedMessageId: replyingTo?.id || null,
});
setContent("");
onCancelReply?.();
} catch (error) {
console.error("Failed to send message:", error);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
formRef.current?.requestSubmit();
}
};
return (
<div className="px-2 pb-2">
{replyingTo && replyingToUser && (
<div className="mb-2 p-3 bg-concord-secondary rounded-lg border border-b-0 border-border">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-6 h-4 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary">
{replyingToUser.nickname || replyingToUser.username}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-auto p-1 text-concord-secondary hover:text-concord-primary"
onClick={onCancelReply}
>
×
</Button>
</div>
<div className="text-sm text-concord-primary truncate pl-2">
{replyingTo.text.replace(/```[\s\S]*?```/g, "[code]")}
</div>
</div>
)}
<form ref={formRef} onSubmit={handleSubmit}>
<Textarea
id="message-input-textarea" // Unique ID for DOM targeting
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message #${channelName || "channel"}`}
disabled={sendMessageMutation.isPending}
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50 min-h-8 max-h-56"
/>
</form>
</div>
);
};

View File

@@ -1,199 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Hash, Volume2, Loader2 } from "lucide-react";
import { useCreateChannel } from "@/hooks/useServers";
import { CategoryWithChannels } from "@/types";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
interface CreateChannelModalProps {
isOpen: boolean;
onClose: () => void;
instanceId: string;
categories: CategoryWithChannels[] | undefined;
}
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
isOpen,
onClose,
categories,
}) => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [type, setType] = useState<"text" | "voice">("text");
const [categoryId, setCategoryId] = useState("");
const createChannelMutation = useCreateChannel();
// Reset form when modal opens or closes
useEffect(() => {
if (!isOpen) {
setName("");
setDescription("");
setType("text");
setCategoryId("");
} else {
setCategoryId("");
}
}, [isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Basic validation: ensure name is not empty and a category is selected
if (!name.trim() || !categoryId || categoryId === "no-categories") {
console.warn("Channel name and a valid category are required.");
toast("Error", {
description: "Channel name and a valid category are required.",
});
return;
}
try {
await createChannelMutation.mutateAsync({
name: name.trim(),
description: description.trim(),
type,
categoryId,
});
// Reset form after successful creation
setName("");
setDescription("");
setType("text");
setCategoryId(""); // Reset to default or empty
onClose();
} catch (error) {
console.error("Failed to create channel:", error);
toast("Error", { description: <p>{`${error}`}</p> });
}
};
// Helper to determine if the form is in a valid state for submission
const isFormInvalid =
!name.trim() || // Name is required and cannot be just whitespace
!categoryId || // Category must be selected
categoryId === "no-categories" || // Handle the "no categories available" placeholder
createChannelMutation.isPending; // Disable while mutation is in progress
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Create Channel</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Channel Type Selection */}
<div className="space-y-2">
<Label>Channel Type</Label>
<div className="flex gap-2">
<Button
type="button"
variant={type === "text" ? "secondary" : "ghost"}
onClick={() => setType("text")}
className="flex-1"
>
<Hash className="h-4 w-4 mr-2" />
Text
</Button>
<Button
type="button"
variant={type === "voice" ? "secondary" : "ghost"}
onClick={() => setType("voice")}
className="flex-1"
>
<Volume2 className="h-4 w-4 mr-2" />
Voice
</Button>
</div>
</div>
{/* Channel Name Input */}
<div className="space-y-2">
<Label htmlFor="channel-name">Channel Name</Label>
<Input
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="awesome-channel"
required
/>
</div>
{/* Category Selection */}
<div className="space-y-2">
<Label htmlFor="channel-category">Category</Label>
<Select
value={categoryId}
onValueChange={(value) => setCategoryId(value)}
required
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{categories && categories.length > 0 ? (
categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))
) : (
// Display this option if there are no categories
<SelectItem value="no-categories" disabled>
No categories available
</SelectItem>
)}
</SelectContent>
</Select>
</div>
{/* Channel Description Textarea */}
<div className="space-y-2">
<Label htmlFor="channel-description">Description</Label>
<Textarea
id="channel-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's this channel about?"
rows={3}
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isFormInvalid}>
{createChannelMutation.isPending ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Creating...
</div>
) : (
"Create Channel"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,92 +0,0 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCreateInstance } from "@/hooks/useServers";
interface CreateServerModalProps {
isOpen: boolean;
onClose: () => void;
}
export const CreateServerModal: React.FC<CreateServerModalProps> = ({
isOpen,
onClose,
}) => {
const [name, setName] = useState("");
const [icon, setIcon] = useState("");
const createInstanceMutation = useCreateInstance();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await createInstanceMutation.mutateAsync({
name: name.trim(),
icon: icon.trim() || undefined,
});
// Reset form
setName("");
setIcon("");
onClose();
} catch (error) {
console.error("Failed to create server:", error);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Create Server</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="server-name">Server Name</Label>
<Input
id="server-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Awesome Server"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-icon">Server Icon URL (optional)</Label>
<Input
id="server-icon"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="https://example.com/icon.png"
type="url"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
disabled={!name.trim() || createInstanceMutation.isPending}
>
{createInstanceMutation.isPending
? "Creating..."
: "Create Server"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,59 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Instance } from "@/types/database";
interface ServerIconProps {
server: Instance;
isActive: boolean;
onClick: () => void;
}
const ServerIcon: React.FC<ServerIconProps> = ({
server,
isActive,
onClick,
}) => {
const getServerInitials = (name: string) => {
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
return (
<div className="relative group w-12">
{/* Active indicator - Positioned outside to the left */}
<div
className={`absolute -left-2 top-1/2 transform -translate-y-1/2 w-1 bg-accent-foreground rounded transition-all duration-200 ${
isActive ? "h-10 rounded-xl" : "rounded-r h-2 group-hover:h-5"
}`}
/>
<Button
variant="ghost"
size="icon"
className={`w-12 h-12 transition-all duration-200 ${
isActive
? "rounded-xl border-primary bg-primary/10 border-2"
: "rounded-2xl hover:rounded-xl border hover:border-primary/50"
}`}
onClick={onClick}
>
{server.icon ? (
<img
src={server.icon}
alt={server.name}
className={`w-full h-full object-cover ${isActive ? "rounded-xl" : "rounded-2xl"}`}
/>
) : (
<span className="font-semibold text-sm">
{getServerInitials(server.name)}
</span>
)}
</Button>
</div>
);
};
export default ServerIcon;

View File

@@ -1,500 +0,0 @@
import { createContext, useContext, useEffect, useState } from "react";
export interface ThemeColors {
background: string;
foreground: string;
card: string;
cardForeground: string;
popover: string;
popoverForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
border: string;
input: string;
ring: string;
// Chart colors
chart1: string;
chart2: string;
chart3: string;
chart4: string;
chart5: string;
// Sidebar colors
sidebar: string;
sidebarForeground: string;
sidebarPrimary: string;
sidebarPrimaryForeground: string;
sidebarAccent: string;
sidebarAccentForeground: string;
sidebarBorder: string;
sidebarRing: string;
}
export interface ThemeDefinition {
id: string;
name: string;
description?: string;
mode: "light" | "dark";
colors: ThemeColors;
isCustom?: boolean;
}
// Fixed themes using proper OKLCH format
const DEFAULT_THEMES: ThemeDefinition[] = [
{
id: "default-light",
name: "Default Light",
mode: "light",
colors: {
background: "oklch(1 0 0)",
foreground: "oklch(0.145 0 0)",
card: "oklch(1 0 0)",
cardForeground: "oklch(0.145 0 0)",
popover: "oklch(1 0 0)",
popoverForeground: "oklch(0.145 0 0)",
primary: "oklch(0.205 0 0)",
primaryForeground: "oklch(0.985 0 0)",
secondary: "oklch(0.97 0 0)",
secondaryForeground: "oklch(0.205 0 0)",
muted: "oklch(0.97 0 0)",
mutedForeground: "oklch(0.556 0 0)",
accent: "oklch(0.97 0 0)",
accentForeground: "oklch(0.205 0 0)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.922 0 0)",
input: "oklch(0.922 0 0)",
ring: "oklch(0.708 0 0)",
chart1: "oklch(0.646 0.222 41.116)",
chart2: "oklch(0.6 0.118 184.704)",
chart3: "oklch(0.398 0.07 227.392)",
chart4: "oklch(0.828 0.189 84.429)",
chart5: "oklch(0.769 0.188 70.08)",
sidebar: "oklch(0.985 0 0)",
sidebarForeground: "oklch(0.145 0 0)",
sidebarPrimary: "oklch(0.205 0 0)",
sidebarPrimaryForeground: "oklch(0.985 0 0)",
sidebarAccent: "oklch(0.97 0 0)",
sidebarAccentForeground: "oklch(0.205 0 0)",
sidebarBorder: "oklch(0.922 0 0)",
sidebarRing: "oklch(0.708 0 0)",
},
},
{
id: "default-dark",
name: "Default Dark",
mode: "dark",
colors: {
background: "oklch(0.145 0 0)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.205 0 0)",
cardForeground: "oklch(0.985 0 0)",
popover: "oklch(0.205 0 0)",
popoverForeground: "oklch(0.985 0 0)",
primary: "oklch(0.922 0 0)",
primaryForeground: "oklch(0.205 0 0)",
secondary: "oklch(0.269 0 0)",
secondaryForeground: "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
mutedForeground: "oklch(0.708 0 0)",
accent: "oklch(0.269 0 0)",
accentForeground: "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.556 0 0)",
chart1: "oklch(0.488 0.243 264.376)",
chart2: "oklch(0.696 0.17 162.48)",
chart3: "oklch(0.769 0.188 70.08)",
chart4: "oklch(0.627 0.265 303.9)",
chart5: "oklch(0.645 0.246 16.439)",
sidebar: "oklch(0.205 0 0)",
sidebarForeground: "oklch(0.985 0 0)",
sidebarPrimary: "oklch(0.488 0.243 264.376)",
sidebarPrimaryForeground: "oklch(0.985 0 0)",
sidebarAccent: "oklch(0.269 0 0)",
sidebarAccentForeground: "oklch(0.985 0 0)",
sidebarBorder: "oklch(1 0 0 / 10%)",
sidebarRing: "oklch(0.556 0 0)",
},
},
{
id: "paper-light",
name: "Paper",
description: "Clean paper-like theme",
mode: "light",
colors: {
background: "oklch(0.99 0.01 85)",
foreground: "oklch(0.15 0.02 65)",
card: "oklch(0.99 0.01 85)",
cardForeground: "oklch(0.15 0.02 65)",
popover: "oklch(1 0 0)",
popoverForeground: "oklch(0.15 0.02 65)",
primary: "oklch(0.25 0.03 45)",
primaryForeground: "oklch(0.98 0.01 85)",
secondary: "oklch(0.96 0.01 75)",
secondaryForeground: "oklch(0.25 0.03 45)",
muted: "oklch(0.96 0.01 75)",
mutedForeground: "oklch(0.45 0.02 55)",
accent: "oklch(0.96 0.01 75)",
accentForeground: "oklch(0.25 0.03 45)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.90 0.01 65)",
input: "oklch(0.90 0.01 65)",
ring: "oklch(0.25 0.03 45)",
chart1: "oklch(0.646 0.222 41.116)",
chart2: "oklch(0.6 0.118 184.704)",
chart3: "oklch(0.398 0.07 227.392)",
chart4: "oklch(0.828 0.189 84.429)",
chart5: "oklch(0.769 0.188 70.08)",
sidebar: "oklch(0.97 0.01 80)",
sidebarForeground: "oklch(0.15 0.02 65)",
sidebarPrimary: "oklch(0.25 0.03 45)",
sidebarPrimaryForeground: "oklch(0.98 0.01 85)",
sidebarAccent: "oklch(0.94 0.01 75)",
sidebarAccentForeground: "oklch(0.25 0.03 45)",
sidebarBorder: "oklch(0.88 0.01 65)",
sidebarRing: "oklch(0.25 0.03 45)",
},
},
{
id: "comfy-brown-dark",
name: "Comfy Brown",
description: "Warm brown theme for dark mode",
mode: "dark",
colors: {
background: "oklch(0.15 0.03 65)",
foreground: "oklch(0.95 0.01 85)",
card: "oklch(0.20 0.03 55)",
cardForeground: "oklch(0.95 0.01 85)",
popover: "oklch(0.20 0.03 55)",
popoverForeground: "oklch(0.95 0.01 85)",
primary: "oklch(0.65 0.15 45)",
primaryForeground: "oklch(0.95 0.01 85)",
secondary: "oklch(0.25 0.04 50)",
secondaryForeground: "oklch(0.95 0.01 85)",
muted: "oklch(0.25 0.04 50)",
mutedForeground: "oklch(0.70 0.02 65)",
accent: "oklch(0.25 0.04 50)",
accentForeground: "oklch(0.95 0.01 85)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(0.30 0.04 55)",
input: "oklch(0.30 0.04 55)",
ring: "oklch(0.65 0.15 45)",
chart1: "oklch(0.65 0.15 45)",
chart2: "oklch(0.55 0.12 85)",
chart3: "oklch(0.75 0.18 25)",
chart4: "oklch(0.60 0.14 105)",
chart5: "oklch(0.70 0.16 65)",
sidebar: "oklch(0.18 0.03 60)",
sidebarForeground: "oklch(0.95 0.01 85)",
sidebarPrimary: "oklch(0.65 0.15 45)",
sidebarPrimaryForeground: "oklch(0.95 0.01 85)",
sidebarAccent: "oklch(0.22 0.04 50)",
sidebarAccentForeground: "oklch(0.95 0.01 85)",
sidebarBorder: "oklch(0.28 0.04 55)",
sidebarRing: "oklch(0.65 0.15 45)",
},
},
{
id: "midnight-dark",
name: "Midnight",
description: "Deep blue midnight theme",
mode: "dark",
colors: {
background: "oklch(0.12 0.08 250)",
foreground: "oklch(0.95 0.01 230)",
card: "oklch(0.18 0.06 240)",
cardForeground: "oklch(0.95 0.01 230)",
popover: "oklch(0.18 0.06 240)",
popoverForeground: "oklch(0.95 0.01 230)",
primary: "oklch(0.60 0.20 240)",
primaryForeground: "oklch(0.95 0.01 230)",
secondary: "oklch(0.22 0.05 235)",
secondaryForeground: "oklch(0.95 0.01 230)",
muted: "oklch(0.22 0.05 235)",
mutedForeground: "oklch(0.70 0.02 230)",
accent: "oklch(0.22 0.05 235)",
accentForeground: "oklch(0.95 0.01 230)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(0.25 0.05 235)",
input: "oklch(0.25 0.05 235)",
ring: "oklch(0.60 0.20 240)",
chart1: "oklch(0.60 0.20 240)",
chart2: "oklch(0.50 0.15 200)",
chart3: "oklch(0.65 0.18 280)",
chart4: "oklch(0.55 0.16 160)",
chart5: "oklch(0.70 0.22 300)",
sidebar: "oklch(0.15 0.07 245)",
sidebarForeground: "oklch(0.95 0.01 230)",
sidebarPrimary: "oklch(0.60 0.20 240)",
sidebarPrimaryForeground: "oklch(0.95 0.01 230)",
sidebarAccent: "oklch(0.20 0.05 235)",
sidebarAccentForeground: "oklch(0.95 0.01 230)",
sidebarBorder: "oklch(0.22 0.05 235)",
sidebarRing: "oklch(0.60 0.20 240)",
},
},
];
type ThemeMode = "light" | "dark" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: ThemeMode;
storageKey?: string;
};
type ThemeProviderState = {
mode: ThemeMode;
currentTheme: ThemeDefinition;
currentLightTheme: ThemeDefinition;
currentDarkTheme: ThemeDefinition;
themes: ThemeDefinition[];
setMode: (mode: ThemeMode) => void;
setTheme: (themeId: string) => void;
addCustomTheme: (theme: Omit<ThemeDefinition, "id" | "isCustom">) => void;
removeCustomTheme: (themeId: string) => void;
getThemesForMode: (mode: "light" | "dark") => ThemeDefinition[];
};
const initialState: ThemeProviderState = {
mode: "system",
currentTheme: DEFAULT_THEMES[1], // Default to dark theme
currentLightTheme: DEFAULT_THEMES[0], // Default light
currentDarkTheme: DEFAULT_THEMES[1], // Default dark
themes: DEFAULT_THEMES,
setMode: () => null,
setTheme: () => null,
addCustomTheme: () => null,
removeCustomTheme: () => null,
getThemesForMode: () => [],
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "concord-theme",
...props
}: ThemeProviderProps) {
const [mode, setMode] = useState<ThemeMode>(
() =>
(localStorage.getItem(storageKey + "-mode") as ThemeMode) || defaultTheme,
);
const [themes, setThemes] = useState<ThemeDefinition[]>(() => {
const saved = localStorage.getItem(storageKey + "-themes");
const customThemes = saved ? JSON.parse(saved) : [];
return [...DEFAULT_THEMES, ...customThemes];
});
const [currentLightThemeId, setCurrentLightThemeId] = useState<string>(() => {
const saved = localStorage.getItem(storageKey + "-light");
return saved || "default-light";
});
const [currentDarkThemeId, setCurrentDarkThemeId] = useState<string>(() => {
const saved = localStorage.getItem(storageKey + "-dark");
return saved || "default-dark";
});
const currentLightTheme =
themes.find((t) => t.id === currentLightThemeId) || DEFAULT_THEMES[0];
const currentDarkTheme =
themes.find((t) => t.id === currentDarkThemeId) || DEFAULT_THEMES[2];
// Determine the current theme based on mode and system preference
const getCurrentTheme = (): ThemeDefinition => {
switch (mode) {
case "light":
return currentLightTheme;
case "dark":
return currentDarkTheme;
case "system":
const systemPrefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
return systemPrefersDark ? currentDarkTheme : currentLightTheme;
default:
return currentDarkTheme;
}
};
const currentTheme = getCurrentTheme();
const applyTheme = (theme: ThemeDefinition) => {
const root = window.document.documentElement;
// Remove existing theme classes
root.classList.remove("light", "dark");
// Apply mode class
root.classList.add(theme.mode);
// Apply CSS custom properties with proper mapping
Object.entries(theme.colors).forEach(([key, value]) => {
// Convert camelCase to kebab-case and map to CSS variables
const cssVarMap: Record<string, string> = {
background: "--background",
foreground: "--foreground",
card: "--card",
cardForeground: "--card-foreground",
popover: "--popover",
popoverForeground: "--popover-foreground",
primary: "--primary",
primaryForeground: "--primary-foreground",
secondary: "--secondary",
secondaryForeground: "--secondary-foreground",
muted: "--muted",
mutedForeground: "--muted-foreground",
accent: "--accent",
accentForeground: "--accent-foreground",
destructive: "--destructive",
border: "--border",
input: "--input",
ring: "--ring",
chart1: "--chart-1",
chart2: "--chart-2",
chart3: "--chart-3",
chart4: "--chart-4",
chart5: "--chart-5",
sidebar: "--sidebar",
sidebarForeground: "--sidebar-foreground",
sidebarPrimary: "--sidebar-primary",
sidebarPrimaryForeground: "--sidebar-primary-foreground",
sidebarAccent: "--sidebar-accent",
sidebarAccentForeground: "--sidebar-accent-foreground",
sidebarBorder: "--sidebar-border",
sidebarRing: "--sidebar-ring",
};
const cssVar = cssVarMap[key];
if (cssVar) {
root.style.setProperty(cssVar, value);
}
});
};
useEffect(() => {
applyTheme(currentTheme);
}, [currentTheme]);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (mode === "system") {
// Theme will be recalculated due to getCurrentTheme dependency
const newTheme = getCurrentTheme();
applyTheme(newTheme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [mode, currentLightTheme, currentDarkTheme]);
const setTheme = (themeId: string) => {
const theme = themes.find((t) => t.id === themeId);
if (!theme) return;
// Update the appropriate theme based on the theme's mode
if (theme.mode === "light") {
setCurrentLightThemeId(themeId);
localStorage.setItem(storageKey + "-light", themeId);
} else {
setCurrentDarkThemeId(themeId);
localStorage.setItem(storageKey + "-dark", themeId);
}
};
const handleSetMode = (newMode: ThemeMode) => {
setMode(newMode);
localStorage.setItem(storageKey + "-mode", newMode);
};
const addCustomTheme = (
themeData: Omit<ThemeDefinition, "id" | "isCustom">,
) => {
const newTheme: ThemeDefinition = {
...themeData,
id: `custom-${Date.now()}`,
isCustom: true,
};
const updatedThemes = [...themes, newTheme];
setThemes(updatedThemes);
// Save only custom themes to localStorage
const customThemes = updatedThemes.filter((t) => t.isCustom);
localStorage.setItem(storageKey + "-themes", JSON.stringify(customThemes));
};
const removeCustomTheme = (themeId: string) => {
const updatedThemes = themes.filter((t) => t.id !== themeId);
setThemes(updatedThemes);
// If removing current theme, switch to default
if (currentLightThemeId === themeId) {
const defaultLight = updatedThemes.find(
(t) => t.mode === "light" && !t.isCustom,
);
if (defaultLight) {
setCurrentLightThemeId(defaultLight.id);
localStorage.setItem(storageKey + "-light", defaultLight.id);
}
}
if (currentDarkThemeId === themeId) {
const defaultDark = updatedThemes.find(
(t) => t.mode === "dark" && !t.isCustom,
);
if (defaultDark) {
setCurrentDarkThemeId(defaultDark.id);
localStorage.setItem(storageKey + "-dark", defaultDark.id);
}
}
// Save only custom themes to localStorage
const customThemes = updatedThemes.filter((t) => t.isCustom);
localStorage.setItem(storageKey + "-themes", JSON.stringify(customThemes));
};
const getThemesForMode = (targetMode: "light" | "dark") => {
return themes.filter((t) => t.mode === targetMode);
};
const value = {
mode,
currentTheme,
currentLightTheme,
currentDarkTheme,
themes,
setMode: handleSetMode,
setTheme,
addCustomTheme,
removeCustomTheme,
getThemesForMode,
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@@ -1,66 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,51 +0,0 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -1,46 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -1,58 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -1,141 +0,0 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -1,255 +0,0 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -1,22 +0,0 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -1,56 +0,0 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -1,183 +0,0 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -1,28 +0,0 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -1,61 +0,0 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@@ -1,23 +0,0 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -1,29 +0,0 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -1,59 +0,0 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,39 +0,0 @@
import React, { useEffect, useRef } from "react";
import { useVoiceStore } from "@/stores/voiceStore";
interface AudioPlayerProps {
stream: MediaStream;
isDeafened: boolean;
}
const AudioPlayer: React.FC<AudioPlayerProps> = ({ stream, isDeafened }) => {
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (audioRef.current) {
audioRef.current.srcObject = stream;
audioRef.current.volume = isDeafened ? 0 : 1;
}
}, [stream, isDeafened]);
return <audio ref={audioRef} autoPlay playsInline />;
};
const VoiceConnectionManager: React.FC = () => {
const remoteStreams = useVoiceStore((state) => state.remoteStreams);
const isDeafened = useVoiceStore((state) => state.isDeafened);
if (remoteStreams.size === 0) {
return null;
}
return (
<div style={{ display: "none" }}>
{Array.from(remoteStreams.entries()).map(([userId, stream]) => (
<AudioPlayer key={userId} stream={stream} isDeafened={isDeafened} />
))}
</div>
);
};
export default VoiceConnectionManager;

View File

@@ -1,212 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/authStore";
import {
authClient,
LoginCredentials,
RegisterData,
AuthResponse,
} from "@/lib/auth-client";
import { BackendUser } from "@/lib/api-client";
import { Role, UserStatus } from "@/types/database";
// Frontend User type
interface FrontendUser {
id: string;
username: string;
nickname?: string | null;
bio?: string | null;
picture?: string | null;
banner?: string | null;
hashPassword: string;
admin: boolean;
status: UserStatus;
createdAt: string;
updatedAt: string;
roles: Role[];
}
// Transform backend user to frontend user format
function transformBackendUser(backendUser: BackendUser): FrontendUser {
return {
id: backendUser.id,
username: backendUser.userName,
nickname: backendUser.nickName,
bio: backendUser.bio,
picture: backendUser.picture,
banner: backendUser.banner,
hashPassword: "", // Don't store password
admin: backendUser.admin,
status: transformStatusToFrontend(backendUser.status),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: backendUser.role.map((r) => ({
instanceId: r.instanceId || "",
role: r.role || "member",
})) as Role[],
};
}
// Transform status from backend to frontend format
function transformStatusToFrontend(
backendStatus: "online" | "offline" | "dnd" | "idle" | "invis",
): UserStatus {
switch (backendStatus) {
case "dnd":
return "busy";
case "idle":
return "away";
case "invis":
return "offline";
default:
return "online";
}
}
// Transform status from frontend to backend format
export function transformStatusToBackend(
frontendStatus: UserStatus,
): "online" | "offline" | "dnd" | "idle" | "invis" {
switch (frontendStatus) {
case "busy":
return "dnd";
case "away":
return "idle";
case "offline":
return "invis";
default:
return "online";
}
}
// Hook for login
export const useLogin = () => {
const { setAuth, setLoading } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
credentials: LoginCredentials,
): Promise<AuthResponse> => {
setLoading(true);
return authClient.login(credentials);
},
onSuccess: (data: AuthResponse) => {
const frontendUser = transformBackendUser(data.user);
setAuth(frontendUser, data.token, data.token); // Use token as refresh token for now
queryClient.clear();
},
onError: (error: Error) => {
console.error("Login failed:", error);
setLoading(false);
},
onSettled: () => {
setLoading(false);
},
});
};
// Hook for registration (requires admin)
export const useRegister = () => {
const { setAuth, setLoading, user, token } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userData: RegisterData): Promise<AuthResponse> => {
setLoading(true);
if (!user || !token || !user.admin) {
throw new Error("Admin privileges required for user creation");
}
return authClient.register(userData, { id: user.id, token });
},
onSuccess: (data: AuthResponse) => {
const frontendUser = transformBackendUser(data.user);
setAuth(frontendUser, data.token, data.token);
queryClient.clear();
},
onError: (error: Error) => {
console.error("Registration failed:", error);
setLoading(false);
},
onSettled: () => {
setLoading(false);
},
});
};
// Hook for logout
export const useLogout = () => {
const { logout, user } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (): Promise<void> => {
if (user) {
try {
await authClient.logout(user.id);
} catch (error) {
console.warn("Logout endpoint failed:", error);
}
}
},
onSuccess: () => {
logout();
queryClient.clear();
},
onError: (error: Error) => {
console.error("Logout failed:", error);
// Still logout locally even if server request fails
logout();
queryClient.clear();
},
});
};
// Hook for token validation
export const useValidateToken = () => {
const { token, user, setAuth, logout } = useAuthStore();
return useMutation({
mutationFn: async (): Promise<{ valid: boolean; user?: BackendUser }> => {
if (!token || !user) {
throw new Error("No token to validate");
}
return authClient.validateToken(token, user.id);
},
onSuccess: (data) => {
if (!data.valid) {
logout();
} else if (data.user) {
const frontendUser = transformBackendUser(data.user);
setAuth(frontendUser, token!, useAuthStore.getState().refreshToken!);
}
},
onError: (error: Error) => {
console.error("Token validation failed:", error);
logout();
},
});
};
// Hook for token refresh
export const useRefreshToken = () => {
const { refreshToken, user, setAuth, logout } = useAuthStore();
return useMutation({
mutationFn: async (): Promise<AuthResponse> => {
if (!refreshToken || !user) {
throw new Error("No refresh token available");
}
return authClient.refreshToken(refreshToken, user.id);
},
onSuccess: (data: AuthResponse) => {
const frontendUser = transformBackendUser(data.user);
setAuth(frontendUser, data.token, data.token);
},
onError: (error: Error) => {
console.error("Token refresh failed:", error);
logout();
},
});
};

View File

@@ -1,303 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient, Message } from "@/lib/api-client";
import { useAuthStore } from "@/stores/authStore";
// Hook for getting messages in a channel with pagination
export const useChannelMessages = (channelId?: string, limit = 50) => {
return useQuery({
queryKey: ["messages", channelId, limit],
queryFn: async (): Promise<Message[]> => {
if (!channelId) return [];
try {
const date = new Date();
const messages = await apiClient.getMessages({
date: date.toISOString(),
channelId: channelId,
});
return messages || [];
} catch (error) {
console.error("Failed to fetch messages:", error);
return [];
}
},
enabled: !!channelId,
staleTime: 500 * 1,
refetchInterval: 500 * 1,
});
};
// Hook for getting older messages (pagination)
export const useChannelMessagesPaginated = (
channelId?: string,
beforeDate?: Date,
limit = 50,
) => {
return useQuery({
queryKey: [
"messages",
channelId,
"paginated",
beforeDate?.toISOString(),
limit,
],
queryFn: async (): Promise<Message[]> => {
if (!channelId || !beforeDate) return [];
try {
const messages = await apiClient.getMessages({
date: beforeDate.toISOString(),
channelId: channelId,
});
return messages || [];
} catch (error) {
console.error("Failed to fetch paginated messages:", error);
return [];
}
},
enabled: !!channelId && !!beforeDate,
staleTime: 500 * 1,
});
};
// Hook for sending messages
export const useSendMessage = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: {
channelId: string;
content: string;
repliedMessageId?: string | null;
}) => {
if (!user || !token) {
throw new Error("Authentication required");
}
const requestData = {
channelId: data.channelId,
userId: user.id,
content: data.content,
token: token,
repliedMessageId: data.repliedMessageId,
};
try {
const message = await apiClient.sendMessage(requestData);
return message;
} catch (error) {
console.error("Failed to send message:", error);
throw new Error("Failed to send message");
}
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["messages", variables.channelId],
});
},
onError: (error) => {
console.error("Send message failed:", error);
},
});
};
// Hook for deleting messages
export const useDeleteMessage = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: { messageId: string; channelId: string }) => {
if (!user || !token) {
throw new Error("Authentication required");
}
// TODO: Replace with actual API call when available
return { success: true, messageId: data.messageId };
},
onSuccess: (result, variables) => {
// Update the cache to mark message as deleted
queryClient.setQueryData(
["messages", variables.channelId],
(oldData: Message[] | undefined) => {
if (!oldData) return oldData;
return oldData.map((msg) =>
msg.id === result.messageId
? { ...msg, content: "[Message deleted]", deleted: true }
: msg,
);
},
);
},
onError: (error) => {
console.error("Delete message failed:", error);
},
});
};
// Hook for editing messages
export const useEditMessage = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: {
messageId: string;
content: string;
channelId: string;
}) => {
if (!user || !token) {
throw new Error("Authentication required");
}
// TODO: Replace with actual API call when available
return {
success: true,
messageId: data.messageId,
content: data.content,
edited: true,
};
},
onSuccess: (result, variables) => {
// Update the cache with edited message
queryClient.setQueryData(
["messages", variables.channelId],
(oldData: Message[] | undefined) => {
if (!oldData) return oldData;
return oldData.map((msg) =>
msg.id === result.messageId
? { ...msg, content: result.content, edited: result.edited }
: msg,
);
},
);
},
onError: (error) => {
console.error("Edit message failed:", error);
},
});
};
// Hook for pinning/unpinning messages
export const usePinMessage = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: {
messageId: string;
channelId: string;
pinned: boolean;
}) => {
if (!user || !token) {
throw new Error("Authentication required");
}
// TODO: Replace with actual API call when available
return {
success: true,
messageId: data.messageId,
pinned: data.pinned,
};
},
onSuccess: (result, variables) => {
// Update the cache with pinned status
queryClient.setQueryData(
["messages", variables.channelId],
(oldData: Message[] | undefined) => {
if (!oldData) return oldData;
return oldData.map((msg) =>
msg.id === result.messageId
? { ...msg, pinned: result.pinned }
: msg,
);
},
);
// Also invalidate pinned messages query if it exists
queryClient.invalidateQueries({
queryKey: ["pinned-messages", variables.channelId],
});
},
onError: (error) => {
console.error("Pin message failed:", error);
},
});
};
// Hook for getting pinned messages
export const usePinnedMessages = (channelId?: string) => {
return useQuery({
queryKey: ["pinned-messages", channelId],
queryFn: async (): Promise<Message[]> => {
if (!channelId) return [];
try {
// TODO: Replace with actual API call when available
// For now, return empty array
return [];
} catch (error) {
console.error("Failed to fetch pinned messages:", error);
return [];
}
},
enabled: !!channelId,
staleTime: 500 * 1,
});
};
// Hook for loading more messages (infinite scroll)
export const useLoadMoreMessages = (channelId?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { beforeDate: Date }) => {
if (!channelId) return [];
try {
const messages = await apiClient.getMessages({
date: data.beforeDate.toISOString(),
channelId: channelId,
});
return messages || [];
} catch (error) {
console.error("Failed to load more messages:", error);
return [];
}
},
onSuccess: (newMessages) => {
if (newMessages.length > 0) {
// Prepend new messages to existing messages
queryClient.setQueryData(
["messages", channelId],
(oldData: Message[] | undefined) => {
if (!oldData) return newMessages;
// Remove duplicates and sort by creation date
const combined = [...newMessages, ...oldData];
const unique = combined.filter(
(msg, index, arr) =>
arr.findIndex((m) => m.id === msg.id) === index,
);
return unique.sort(
(a, b) =>
new Date(a.createdAt).getTime() -
new Date(b.createdAt).getTime(),
);
},
);
}
},
});
};

View File

@@ -1,108 +0,0 @@
import { useAuthStore } from "@/stores/authStore";
import { Role } from "@/types/database";
import { useMemo } from "react";
import { useParams } from "react-router";
type PermissionsRole = "admin" | "member" | "mod";
const getUserRoleForInstance = (
roles: Role[],
instanceId: string,
): PermissionsRole => {
if (!instanceId) return "member";
const roleEntry = roles.find((r) => r.instanceId === instanceId);
return roleEntry?.role || "member";
};
const getRoleInfo = (role: PermissionsRole) => {
const lowerRole = role.toLowerCase();
switch (lowerRole) {
case "admin":
return { color: "#ff6b6b", priority: 3, name: "Admin" };
case "mod":
return { color: "#4ecdc4", priority: 2, name: "Moderator" };
case "member":
return { color: null, priority: 1, name: "Member" };
default:
return {
color: null,
priority: 0,
name: role.charAt(0).toUpperCase() + role.slice(1),
};
}
};
interface InstancePermissions {
currentUserRole: PermissionsRole;
currentUserRolePriority: number;
canManageMembers: boolean; // Can kick/ban/promote/demote members
canViewAdminPanel: boolean;
}
export const useInstancePermissions = (): InstancePermissions => {
const { instanceId } = useParams<{ instanceId: string }>();
const { user: currentUser } = useAuthStore();
const permissions = useMemo(() => {
let currentUserRole: PermissionsRole = "member";
let currentUserRolePriority = 1;
let canManageMembers = false;
let canViewAdminPanel = false;
if (!currentUser || !instanceId) {
// If no user or instance, user has no permissions within an instance
return {
currentUserRole,
currentUserRolePriority,
canManageMembers,
canViewAdminPanel,
};
}
// If they are a global admin
if (currentUser.admin) {
currentUserRole = "admin";
currentUserRolePriority = 3;
canManageMembers = true;
canViewAdminPanel = true;
return {
currentUserRole: "admin",
currentUserRolePriority: 3,
canManageMembers: true,
canManageRoles: true,
canViewAdminPanel: true,
};
}
// Instance-Specific Role Check
const instanceRole = getUserRoleForInstance(currentUser.roles, instanceId);
const roleInfo = getRoleInfo(instanceRole as PermissionsRole);
currentUserRole = instanceRole;
currentUserRolePriority = roleInfo.priority;
// Define permissions based on role priority
if (roleInfo.priority >= 3) {
// Admin
canManageMembers = true;
canViewAdminPanel = true;
} else if (roleInfo.priority === 2) {
// Moderator
canManageMembers = true;
canViewAdminPanel = false;
} else {
// Member (priority 1 or 0)
canManageMembers = false;
canViewAdminPanel = false;
}
return {
currentUserRole,
currentUserRolePriority,
canManageMembers,
canViewAdminPanel,
};
}, [currentUser, instanceId]);
return permissions as InstancePermissions;
};

View File

@@ -1,269 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
apiClient,
Instance,
Category,
Channel,
BackendUser,
} from "@/lib/api-client";
import { useAuthStore } from "@/stores/authStore";
// Extended types with relations for frontend use
export interface CategoryWithChannels extends Category {
channels: Channel[];
}
export interface InstanceWithDetails extends Instance {
categories: CategoryWithChannels[];
}
// Transform backend user to frontend user format for compatibility
function transformBackendUserToFrontend(backendUser: BackendUser) {
return {
id: backendUser.id,
username: backendUser.userName,
nickname: backendUser.nickName,
bio: backendUser.bio,
picture: backendUser.picture,
banner: backendUser.banner,
hashPassword: "",
admin: backendUser.admin,
status:
backendUser.status === "dnd"
? "busy"
: backendUser.status === "idle"
? "away"
: backendUser.status === "invis"
? "offline"
: backendUser.status,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: backendUser.role.map((r) => ({
instanceId: r.instanceId || "",
role: r.role || "member",
})),
};
}
// Hook for getting all servers/instances
export const useServers = () => {
return useQuery({
queryKey: ["servers"],
queryFn: async (): Promise<Instance[]> => {
try {
const instances = await apiClient.getInstances();
return instances;
} catch (error) {
console.error("Failed to fetch servers:", error);
throw new Error("Failed to fetch servers");
}
},
staleTime: 500 * 1,
});
};
// Hook for getting detailed instance info with categories and channels
export const useInstanceDetails = (instanceId?: string) => {
return useQuery({
queryKey: ["instance", instanceId],
queryFn: async (): Promise<InstanceWithDetails | null> => {
if (!instanceId) return null;
try {
// Get instance basic info
const instances = await apiClient.getInstances();
const instance = instances.find((s) => s.id === instanceId);
if (!instance) return null;
// Get categories for this instance
const categories = await apiClient.getCategoriesByInstance(instanceId);
// For each category, get its channels
const categoriesWithChannels: CategoryWithChannels[] =
await Promise.all(
categories.map(async (category): Promise<CategoryWithChannels> => {
try {
const channels = await apiClient.getChannelsByCategory(
category.id,
);
return {
...category,
channels: channels || [],
};
} catch (error) {
console.warn(
`Failed to fetch channels for category ${category.id}:`,
error,
);
return {
...category,
channels: [],
};
}
}),
);
return {
...instance,
categories: categoriesWithChannels,
};
} catch (error) {
console.error("Failed to fetch instance details:", error);
throw new Error("Failed to fetch instance details");
}
},
enabled: !!instanceId,
staleTime: 500 * 1,
});
};
// Hook for getting all users from an instance with their roles
export const useInstanceMembers = (instanceId?: string) => {
return useQuery({
queryKey: ["instance", instanceId, "members"],
queryFn: async () => {
if (!instanceId) return [];
try {
const backendUsers = await apiClient.getUsersByInstance(instanceId);
// Transform backend users to frontend format for compatibility
return backendUsers.map(transformBackendUserToFrontend);
} catch (error) {
console.error("Failed to fetch instance members:", error);
throw new Error("Failed to fetch instance members");
}
},
enabled: !!instanceId,
staleTime: 500 * 1,
});
};
// Hook for creating a new server/instance
export const useCreateInstance = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: { name: string; icon?: string }) => {
if (!user || !token) {
throw new Error("Authentication required");
}
const requestData = {
...data,
requestingUserId: user.id,
requestingUserToken: token,
};
try {
const instance = await apiClient.createInstance(requestData);
return instance;
} catch (error) {
console.error("Failed to create instance:", error);
throw new Error("Failed to create instance");
}
},
onSuccess: () => {
// Invalidate servers list to refetch
queryClient.invalidateQueries({ queryKey: ["servers"] });
},
});
};
// Hook for creating a new category
export const useCreateCategory = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: {
name: string;
instanceId?: string;
position: number;
}) => {
if (!user || !token) {
throw new Error("Authentication required");
}
const requestData = {
...data,
admin: user.admin,
requestingUserId: user.id,
requestingUserToken: token,
};
try {
const category = await apiClient.createCategory(requestData);
return category;
} catch (error) {
console.error("Failed to create category:", error);
throw new Error("Failed to create category");
}
},
onSuccess: (_, variables) => {
// Invalidate instance details to refetch categories
if (variables.instanceId) {
queryClient.invalidateQueries({
queryKey: ["instance", variables.instanceId],
});
}
},
});
};
// Hook for creating a new channel
export const useCreateChannel = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: {
type: "text" | "voice";
name: string;
description: string;
categoryId?: string;
}) => {
if (!user || !token) {
throw new Error("Authentication required");
}
const requestData = {
...data,
admin: user.admin,
requestingUserId: user.id,
requestingUserToken: token,
};
try {
const channel = await apiClient.createChannel(requestData);
return channel;
} catch (error) {
console.error("Failed to create channel:", error);
throw new Error("Failed to create channel");
}
},
onSuccess: (_, variables) => {
// Invalidate related queries
if (variables.categoryId) {
// Find the instance this category belongs to and invalidate it
queryClient.invalidateQueries({
queryKey: ["instance"],
});
}
},
});
};
// Placeholder hook for channels by instance (for backward compatibility)
export const useChannels = (instanceId?: string) => {
const { data: instance } = useInstanceDetails(instanceId);
return useQuery({
queryKey: ["channels", instanceId],
queryFn: async (): Promise<CategoryWithChannels[]> => {
return instance?.categories || [];
},
enabled: !!instanceId && !!instance,
staleTime: 500 * 1,
});
};

View File

@@ -1,307 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* concord-specific custom properties */
--color-text-primary: var(--foreground);
--color-text-secondary: var(--muted-foreground);
--color-text-muted: var(--muted-foreground);
--color-interactive-normal: var(--muted-foreground);
--color-interactive-hover: var(--foreground);
--color-interactive-active: var(--primary);
--color-background-primary: var(--background);
--color-background-secondary: var(--card);
--color-background-tertiary: var(--muted);
/* Status colors - these remain consistent across themes */
--color-status-online: oklch(0.6 0.15 140);
--color-status-away: oklch(0.75 0.15 85);
--color-status-busy: oklch(0.65 0.2 25);
--color-status-offline: var(--muted-foreground);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
border-color: var(--border);
outline-color: var(--ring, oklch(0.708 0 0)) / 50%;
}
body {
background-color: var(--background);
color: var(--foreground);
}
}
/* Background utilities */
@utility bg-concord-primary {
background-color: var(--color-background-primary);
}
@utility bg-concord-secondary {
background-color: var(--color-background-secondary);
}
@utility bg-concord-tertiary {
background-color: var(--color-background-tertiary);
}
/* Text utilities */
@utility text-concord-primary {
color: var(--color-text-primary);
}
@utility text-concord-secondary {
color: var(--color-text-secondary);
}
@utility text-concord-muted {
color: var(--color-text-muted);
}
/* Interactive utilities */
@utility text-interactive-normal {
color: var(--color-interactive-normal);
}
@utility text-interactive-hover {
color: var(--color-interactive-hover);
}
@utility text-interactive-active {
color: var(--color-interactive-active);
}
/* Status utilities */
@utility text-status-online {
color: var(--color-status-online);
}
@utility text-status-away {
color: var(--color-status-away);
}
@utility text-status-busy {
color: var(--color-status-busy);
}
@utility text-status-offline {
color: var(--color-status-offline);
}
@utility bg-status-online {
background-color: var(--color-status-online);
}
@utility bg-status-away {
background-color: var(--color-status-away);
}
@utility bg-status-busy {
background-color: var(--color-status-busy);
}
@utility bg-status-offline {
background-color: var(--color-status-offline);
}
/* Border utilities */
@utility border-concord {
border-color: var(--color-border);
}
@utility border-sidebar {
border-color: var(--color-sidebar-border);
}
@utility interactive-hover {
transition-property: color;
transition-duration: 200ms;
transition-timing-function: ease-in-out;
color: var(--color-interactive-normal);
&:hover {
color: var(--color-interactive-hover);
}
}
@utility spacing-xs {
padding: var(--tw-spacing-1, 0.25rem);
gap: var(--tw-spacing-1, 0.25rem);
}
@utility spacing-sm {
padding: var(--tw-spacing-2, 0.5rem);
gap: var(--tw-spacing-2, 0.5rem);
}
@utility spacing-md {
padding: var(--tw-spacing-4, 1rem);
gap: var(--tw-spacing-4, 1rem);
}
@utility spacing-lg {
padding: var(--tw-spacing-6, 1.5rem);
gap: var(--tw-spacing-6, 1.5rem);
}
@utility spacing-xl {
padding: var(--tw-spacing-8, 2rem);
gap: var(--tw-spacing-8, 2rem);
}
@utility spacing-x-xs {
padding-left: var(--tw-spacing-1, 0.25rem);
padding-right: var(--tw-spacing-1, 0.25rem);
}
@utility spacing-x-sm {
padding-left: var(--tw-spacing-2, 0.5rem);
padding-right: var(--tw-spacing-2, 0.5rem);
}
@utility spacing-x-md {
padding-left: var(--tw-spacing-4, 1rem);
padding-right: var(--tw-spacing-4, 1rem);
}
@utility spacing-x-lg {
padding-left: var(--tw-spacing-6, 1.5rem);
padding-right: var(--tw-spacing-6, 1.5rem);
}
@utility spacing-x-xl {
padding-left: var(--tw-spacing-8, 2rem);
padding-right: var(--tw-spacing-8, 2rem);
}
@utility spacing-y-xs {
padding-top: var(--tw-spacing-1, 0.25rem);
padding-bottom: var(--tw-spacing-1, 0.25rem);
}
@utility spacing-y-sm {
padding-top: var(--tw-spacing-2, 0.5rem);
padding-bottom: var(--tw-spacing-2, 0.5rem);
}
@utility spacing-y-md {
padding-top: var(--tw-spacing-4, 1rem);
padding-bottom: var(--tw-spacing-4, 1rem);
}
@utility spacing-y-lg {
padding-top: var(--tw-spacing-6, 1.5rem);
padding-bottom: var(--tw-spacing-6, 1.5rem);
}
@utility spacing-y-xl {
padding-top: var(--tw-spacing-8, 2rem);
padding-bottom: var(--tw-spacing-8, 2rem);
}
@layer components {
/* Sidebar styling */
.sidebar-primary {
background-color: var(--color-sidebar);
border-right-width: 1px;
border-color: var(--color-sidebar-border);
}
.sidebar-secondary {
background-color: var(--color-background-secondary);
border-right-width: 1px;
border-color: var(--color-border);
}
.panel-button {
display: flex;
width: full;
flex-grow: 1;
border-radius: 12px;
margin: 4px 8px;
padding: 8px;
&:hover {
background-color: var(--color-background-tertiary);
}
}
}

View File

@@ -1,359 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
// Base API configuration
export const API_BASE_URL =
import.meta.env.VITE_API_URL || "http://localhost:3000";
// Enhanced QueryClient with error handling
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 500 * 1, // 1 minute
refetchOnWindowFocus: true,
retry: (failureCount, error: any) => {
// Don't retry on auth errors
if (error?.status === 401 || error?.status === 403) return false;
return failureCount < 3;
},
},
mutations: {
retry: (failureCount, error: any) => {
if (error?.status === 401 || error?.status === 403) return false;
return failureCount < 2;
},
},
},
});
// API Response types based on your backend
export interface ApiResponse<T> {
success?: boolean;
data?: T;
error?: string;
}
// Specific response types for your backend
export interface Instance {
id: string;
name: string;
icon?: string | null;
description?: string;
createdAt: string;
updatedAt: string;
}
export interface Category {
id: string;
name: string;
instanceId: string;
position: number;
createdAt: string;
updatedAt: string;
}
export interface Channel {
id: string;
name: string;
type: "text" | "voice";
categoryId: string;
instanceId: string;
position: number;
description?: string;
createdAt: string;
updatedAt: string;
}
export interface BackendUser {
id: string;
userName: string;
nickName: string | null;
bio: string | null;
picture: string | null;
banner: string | null;
admin: boolean;
status: "online" | "offline" | "dnd" | "idle" | "invis";
role: Array<{
userId: string;
instanceId: string;
role?: string;
}>;
}
export interface Message {
id: string;
text: string;
channelId: string;
userId: string;
edited: boolean;
createdAt: string;
deleted: boolean;
updatedAt: string;
replies: MessageReply;
user?: BackendUser;
}
export interface MessageReply {
id: string;
repliesToId: string;
repliesToText: string;
}
// Enhanced fetch wrapper with auth and error handling
export class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl;
}
private async getAuthHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
return headers;
}
private async request<T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers = await this.getAuthHeaders();
const config: RequestInit = {
...options,
headers: {
...headers,
...options.headers,
},
};
try {
const response = await fetch(url, config);
if (!response.ok) {
if (response.status === 401) {
// Handle auth error - logout user
const authStore = await import("@/stores/authStore");
authStore.useAuthStore.getState().logout();
throw new Error("Authentication required");
}
const errorData = await response
.json()
.catch(() => ({ error: "Unknown error" }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
const data = await response.json();
return data as T;
} catch (error) {
console.error(`API request failed: ${endpoint}`, error);
throw error;
}
}
// Instance/Server methods
async getInstances(): Promise<Instance[]> {
const response = await this.request<
{ success: boolean; data: Instance[] } | Instance[]
>("/api/instance");
// Handle both wrapped and direct responses
if (Array.isArray(response)) {
return response;
}
if ("data" in response && Array.isArray(response.data)) {
return response.data;
}
return [];
}
async createInstance(data: {
name: string;
icon?: string;
requestingUserId: string;
requestingUserToken: string;
}): Promise<Instance> {
return this.request<Instance>("/api/instance", {
method: "POST",
body: JSON.stringify(data),
});
}
// Categories methods
async getCategoriesByInstance(instanceId: string): Promise<Category[]> {
try {
const response = await this.request<Category[] | { data: Category[] }>(
`/api/category/instance/${instanceId}`,
);
if (Array.isArray(response)) {
return response;
}
if ("data" in response && Array.isArray(response.data)) {
return response.data;
}
return [];
} catch (error) {
console.warn(
`Categories endpoint not available for instance ${instanceId}`,
error,
);
return [];
}
}
async createCategory(data: {
name: string;
position: number;
instanceId?: string;
admin: boolean;
requestingUserId: string;
requestingUserToken: string;
}): Promise<Category> {
return this.request<Category>("/api/category", {
method: "POST",
body: JSON.stringify(data),
});
}
// Channel methods
async getChannelsByCategory(categoryId: string): Promise<Channel[]> {
try {
const response = await this.request<Channel[] | { data: Channel[] }>(
`/api/channel/category/${categoryId}`,
);
if (Array.isArray(response)) {
return response;
}
if ("data" in response && Array.isArray(response.data)) {
return response.data;
}
return [];
} catch (error) {
console.warn(
`Channels endpoint not available for category ${categoryId}`,
error,
);
return [];
}
}
async createChannel(data: {
type: "text" | "voice";
name: string;
description: string;
categoryId?: string;
admin: boolean;
requestingUserId: string;
requestingUserToken: string;
}): Promise<Channel> {
console.log(data);
return this.request<Channel>("/api/channel", {
method: "POST",
body: JSON.stringify(data),
});
}
// Message methods
async getMessages(params: {
date: string;
channelId: string;
}): Promise<Message[]> {
const query = new URLSearchParams(params);
const response = await this.request<Message[] | { data: Message[] }>(
`/api/message?${query}`,
);
if (Array.isArray(response)) {
return response;
}
if ("data" in response && Array.isArray(response.data)) {
return response.data;
}
return [];
}
async sendMessage(data: {
channelId: string;
userId: string;
content: string;
token: string;
repliedMessageId?: string | null;
}): Promise<Message> {
return this.request<Message>("/api/message", {
method: "POST",
body: JSON.stringify(data),
});
}
// User methods
async getUsersByInstance(instanceId: string): Promise<BackendUser[]> {
const query = new URLSearchParams({ instanceId });
const response = await this.request<
BackendUser[] | { data: BackendUser[] }
>(`/api/user?${query}`);
if (Array.isArray(response)) {
return response;
}
if ("data" in response && Array.isArray(response.data)) {
return response.data;
}
return [];
}
async getUser(id: string): Promise<BackendUser> {
const response = await this.request<BackendUser | { data: BackendUser }>(
`/api/user/${id}`,
);
if ("data" in response) {
return response.data;
}
return response as BackendUser;
}
async createUser(data: {
username: string;
nickname?: string;
bio?: string;
picture?: string;
banner?: string;
status?: "online" | "offline" | "dnd" | "idle" | "invis";
admin?: boolean;
requestingUserId: string;
requestingUserToken: string;
passwordhash: string;
}): Promise<{ success: boolean; data?: BackendUser; error?: string }> {
try {
const response = await this.request<BackendUser>("/api/user", {
method: "POST",
body: JSON.stringify(data),
});
return { success: true, data: response };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}
// Create singleton instance
export const apiClient = new ApiClient();

View File

@@ -1,175 +0,0 @@
import { apiClient, BackendUser } from "./api-client";
export interface LoginCredentials {
username: string;
password: string;
}
export interface RegisterData {
username: string;
password: string;
nickname?: string;
bio?: string;
picture?: string;
banner?: string;
}
export interface AuthResponse {
user: BackendUser;
token: string;
}
class AuthClient {
private baseUrl: string;
constructor(
baseUrl: string = import.meta.env.VITE_API_URL || "http://localhost:3000",
) {
this.baseUrl = baseUrl;
}
async login(credentials: LoginCredentials): Promise<AuthResponse> {
try {
const response = await fetch(`${this.baseUrl}/api/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: credentials.username,
password: credentials.password,
}),
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: "Login failed" }));
throw new Error(errorData.error || "Login failed");
}
const data: AuthResponse = await response.json();
return data;
} catch (error) {
console.error("Login failed:", error);
throw error instanceof Error ? error : new Error("Login failed");
}
}
async register(
data: RegisterData,
adminUser: { id: string; token: string },
): Promise<AuthResponse> {
try {
const createUserData = {
username: data.username,
nickname: data.nickname,
bio: data.bio,
picture: data.picture,
banner: data.banner,
status: "online" as const,
admin: false,
requestingUserId: adminUser.id,
requestingUserToken: adminUser.token,
passwordhash: data.password,
};
const response = await apiClient.createUser(createUserData);
if (!response.success || !response.data) {
throw new Error(response.error || "Registration failed");
}
try {
const loginResponse = await this.login({
username: data.username,
password: data.password,
});
return loginResponse;
} catch (loginError) {
throw new Error(
"Registration successful, but auto-login failed. Please login manually.",
);
}
} catch (error) {
console.error("Registration failed:", error);
throw error instanceof Error ? error : new Error("Registration failed");
}
}
async validateToken(
token: string,
userId: string,
): Promise<{ valid: boolean; user?: BackendUser }> {
try {
const response = await fetch(`${this.baseUrl}/api/auth/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token, userId }),
});
if (!response.ok) {
return { valid: false };
}
const data: { valid: boolean; user?: BackendUser } =
await response.json();
return data;
} catch (error) {
console.error("Token validation failed:", error);
return { valid: false };
}
}
async refreshToken(oldToken: string, userId: string): Promise<AuthResponse> {
try {
const response = await fetch(`${this.baseUrl}/api/auth/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ userId, oldToken }),
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: "Token refresh failed" }));
throw new Error(errorData.error || "Token refresh failed");
}
const data: AuthResponse = await response.json();
return data;
} catch (error) {
console.error("Token refresh failed:", error);
throw error instanceof Error ? error : new Error("Token refresh failed");
}
}
async logout(userId: string): Promise<{ success: boolean }> {
try {
const response = await fetch(`${this.baseUrl}/api/auth/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ userId }),
});
if (!response.ok) {
console.warn(
"Logout endpoint failed, but continuing with local logout",
);
}
const data = await response.json().catch(() => ({ success: true }));
return data;
} catch (error) {
console.warn("Logout request failed:", error);
return { success: true }; // Always succeed locally
}
}
}
export const authClient = new AuthClient();

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,13 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { io } from "socket.io-client";
import "./index.css";
const socket = io("http://localhost:3000");
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App socket={socket} />
</React.StrictMode>,
);

View File

@@ -1,372 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router";
import { Hash, Volume2, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageComponent } from "@/components/message/MessageComponent";
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
import { useChannelMessages, useLoadMoreMessages } from "@/hooks/useMessages";
import { useUiStore } from "@/stores/uiStore";
import { useAuthStore } from "@/stores/authStore";
import { Message } from "@/lib/api-client";
import { MessageInput } from "@/components/message/MessageInput";
const ChatPage: React.FC = () => {
const { instanceId, channelId } = useParams();
const navigate = useNavigate();
const {
data: instance,
isLoading: instanceLoading,
error: instanceError,
} = useInstanceDetails(instanceId);
const {
data: channelMessages,
isLoading: messagesLoading,
error: messagesError,
} = useChannelMessages(channelId);
const { data: users, isLoading: usersLoading } =
useInstanceMembers(instanceId);
// UI state hooks - called unconditionally
const { toggleMemberList, showMemberList } = useUiStore();
const { user: currentUser } = useAuthStore();
// Local state hooks - called unconditionally
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesStartRef = useRef<HTMLDivElement>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null); // Ref for the ScrollArea content wrapper
// API mutation hooks - called unconditionally
const loadMoreMessagesMutation = useLoadMoreMessages(channelId);
// Memoized values - called unconditionally
const categories = instance?.categories;
const currentChannel = React.useMemo(() => {
return categories
?.flatMap((cat) => cat.channels)
?.find((ch) => ch.id === channelId);
}, [categories, channelId]);
const userHasAccess = React.useMemo(() => {
if (!currentUser || !instanceId) return false;
if (currentUser.admin) return true;
return currentUser.roles.some((role) => role.instanceId === instanceId);
}, [currentUser, instanceId]);
const sortedMessages = React.useMemo(() => {
if (!channelMessages) return [];
// Sort messages by createdAt timestamp (oldest first, newest last)
return [...channelMessages].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateA - dateB; // ascending order (oldest to newest)
});
}, [channelMessages]);
// Effects - called unconditionally
useEffect(() => {
// Scroll to bottom when messages load or update
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [channelMessages]);
// Auto-focus on channel change or initial load (using DOM query)
useEffect(() => {
if (!currentUser) return; // Skip if input isn't rendered
let retryCount = 0;
const maxRetries = 10;
const retryInterval = 100; // ms
const focusInput = () => {
retryCount++;
const textarea = document.getElementById(
"message-input-textarea",
) as HTMLTextAreaElement | null;
if (textarea) {
textarea.focus();
} else if (retryCount < maxRetries) {
setTimeout(focusInput, retryInterval);
}
};
focusInput();
}, [channelId, currentUser]);
useEffect(() => {
if (!replyingTo) return; // Skip if no reply
const focusInput = () => {
const textarea = document.getElementById(
"message-input-textarea",
) as HTMLTextAreaElement | null;
if (textarea) {
textarea.focus();
}
};
focusInput();
}, [replyingTo]);
// Event handlers
const handleLoadMore = React.useCallback(async () => {
if (!channelMessages || channelMessages.length === 0 || isLoadingMore)
return;
setIsLoadingMore(true);
try {
const oldestMessage = channelMessages[0];
await loadMoreMessagesMutation.mutateAsync({
beforeDate: new Date(oldestMessage.createdAt),
});
} catch (error) {
console.error("Failed to load more messages:", error);
} finally {
setIsLoadingMore(false);
}
}, [channelMessages, isLoadingMore, loadMoreMessagesMutation]);
const handleReply = React.useCallback(
(messageId: string) => {
const message = channelMessages?.find((m) => m.id === messageId);
if (message) {
setReplyingTo(message);
}
},
[channelMessages],
);
// Effect for scroll to top and load more
useEffect(() => {
const scrollAreaElement = scrollAreaRef.current;
const handleScroll = () => {
if (
scrollAreaElement &&
scrollAreaElement.scrollTop === 0 &&
channelMessages &&
channelMessages.length > 0 &&
!isLoadingMore
) {
handleLoadMore();
}
};
if (scrollAreaElement) {
scrollAreaElement.addEventListener("scroll", handleScroll);
}
return () => {
if (scrollAreaElement) {
scrollAreaElement.removeEventListener("scroll", handleScroll);
}
};
}, [channelMessages, isLoadingMore, handleLoadMore]);
// Handle loading states
if (instanceLoading || messagesLoading || usersLoading) {
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p>Loading chat...</p>
</div>
</div>
);
}
// Handle errors and permissions
if (!userHasAccess) {
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary">
<h2 className="text-xl font-semibold mb-2 text-destructive">
Access Denied
</h2>
<p className="mb-4">You don't have permission to view this server.</p>
<Button onClick={() => navigate("/")}>Go Home</Button>
</div>
</div>
);
}
if (instanceError || messagesError) {
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary">
<h2 className="text-xl font-semibold mb-2 text-destructive">
Error Loading Chat
</h2>
<p className="mb-4">
{instanceError?.message ||
messagesError?.message ||
"Something went wrong"}
</p>
<Button onClick={() => window.location.reload()}>Retry</Button>
</div>
</div>
);
}
// Require both instanceId and channelId for chat
if (!instanceId) {
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary">
<h2 className="text-xl font-semibold mb-2 text-concord-primary">
No Server Selected
</h2>
<p>Select a server from the sidebar to start chatting.</p>
</div>
</div>
);
} else if (!channelId || !currentChannel) {
const existingChannelId = categories?.flatMap((cat) => cat.channels)?.[0]
?.id; // Get the first channel from the flattened list
if (existingChannelId) {
navigate(`/channels/${instanceId}/${existingChannelId}`);
return null;
} else {
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary">
<h2 className="text-xl font-semibold mb-2 text-concord-primary">
No channels exist yet!
</h2>
<p>Ask an admin to create a channel</p>
</div>
</div>
);
}
}
const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash;
return (
<div className="flex flex-col flex-shrink h-full bg-concord-primary">
{/* Channel Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-concord bg-concord-secondary">
<div className="flex items-center space-x-2">
<ChannelIcon size={20} className="text-concord-secondary" />
<span className="font-semibold text-xl text-concord-primary">
{currentChannel?.name}
</span>
{currentChannel?.description && (
<>
<div className="w-px h-4 bg-border" />
<span className="text-sm text-concord-secondary truncate max-w-xs">
{currentChannel.description}
</span>
</>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 ${showMemberList ? "text-interactive-active bg-concord-tertiary" : "interactive-hover"}`}
onClick={toggleMemberList}
>
<Users size={16} />
</Button>
<div className="w-40">
<Input
placeholder="Search"
className="h-8 bg-concord-tertiary border-none text-sm"
/>
</div>
</div>
</div>
{/* Chat Content */}
<div className="flex-1 flex flex-col overflow-y-auto">
{/* Messages Area */}
<ScrollArea className="flex-1 min-h-0">
{/* Attach ref to the actual scrollable content */}
<div ref={scrollAreaRef} className="h-full overflow-y-auto">
<div ref={messagesStartRef} />
{/* Welcome Message */}
<div className="px-4 py-6 border-b border-concord/50 flex-shrink-0">
<div className="flex items-center space-x-3 mb-3">
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center">
<ChannelIcon size={24} className="text-primary-foreground" />
</div>
<div>
<h3 className="text-2xl font-bold text-concord-primary">
Welcome to #{currentChannel?.name}!
</h3>
</div>
</div>
</div>
<div className="pb-4">
{/* Messages */}
{sortedMessages && sortedMessages.length > 0 ? (
<div>
{sortedMessages.map((message) => {
const user = users?.find((u) => u.id === message.userId);
const replyToMessage = channelMessages?.find(
(m) => m.id === message.replies?.repliesToId,
);
const replyToUser = replyToMessage
? users?.find((u) => u.id === replyToMessage.userId)
: undefined;
if (!user) return null;
return (
<MessageComponent
key={message.id}
message={message}
user={user}
replyTo={replyToMessage}
onReply={handleReply}
replyToUser={replyToUser}
/>
);
})}
</div>
) : (
<div className="flex items-center justify-center h-64">
<div className="text-center text-concord-secondary">
<p>No messages yet. Start the conversation!</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
</ScrollArea>
{/* Message Input */}
{currentUser && (
<div className="flex-shrink-0">
<MessageInput
channelId={channelId}
channelName={currentChannel?.name}
replyingTo={replyingTo}
onCancelReply={() => setReplyingTo(null)}
replyingToUser={
replyingTo
? users?.find((u) => u.id === replyingTo.userId) || null
: null
}
/>
</div>
)}
</div>
</div>
);
};
export default ChatPage;

View File

@@ -1,109 +0,0 @@
import React, { useState } from "react";
import { Navigate } from "react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useAuthStore } from "@/stores/authStore";
import { useLogin } from "@/hooks/useAuth";
const LoginPage: React.FC = () => {
const { isAuthenticated } = useAuthStore();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
// Use the real login hook
const { mutate: login, isPending, error } = useLogin();
// Redirect if already authenticated
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password.trim()) {
return;
}
login({ username: username.trim(), password });
};
return (
<div className="min-h-screen bg-concord-primary flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-concord-secondary border-concord">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold text-concord-primary">
Welcome back!
</CardTitle>
<CardDescription className="text-concord-secondary">
We're so excited to see you again!
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>
{error instanceof Error
? error.message
: "Login failed. Please try again."}
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="username" className="text-concord-primary">
Username
</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-concord-tertiary border-concord text-concord-primary"
placeholder="Enter your username"
disabled={isPending}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-concord-primary">
Password
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-concord-tertiary border-concord text-concord-primary"
placeholder="Enter your password"
disabled={isPending}
required
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isPending || !username.trim() || !password.trim()}
>
{isPending ? "Logging in..." : "Log In"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
};
export default LoginPage;

View File

@@ -1,17 +0,0 @@
import { Button } from "@/components/ui/button";
const NotFoundPage: React.FC = () => {
return (
<div className="min-h-screen bg-concord-primary flex items-center justify-center">
<div className="text-center text-concord-secondary">
<h1 className="text-4xl font-bold mb-4 text-concord-primary">404</h1>
<p className="text-xl mb-6">Page not found</p>
<Button asChild>
<a href="/">Go Home</a>
</Button>
</div>
</div>
);
};
export default NotFoundPage;

View File

@@ -1,596 +0,0 @@
import React, { useState } from "react";
import { useNavigate, useParams } from "react-router";
import {
Palette,
User,
Mic,
Settings,
ChevronRight,
Moon,
Sun,
Monitor,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useTheme } from "@/components/theme-provider";
import { useAuthStore } from "@/stores/authStore";
import { Slider } from "@/components/ui/slider";
type SettingsSection = {
id: string;
title: string;
icon: React.ComponentType<{ className?: string }>;
description?: string;
};
const SETTINGS_SECTIONS: SettingsSection[] = [
{
id: "account",
title: "My Account",
icon: User,
description: "Profile, privacy, and account settings",
},
{
id: "appearance",
title: "Appearance",
icon: Palette,
description: "Themes, display, and accessibility",
},
{
id: "voice",
title: "Voice & Video",
icon: Mic,
description: "Audio and video settings",
},
];
const AccountSettings: React.FC = () => {
const { user } = useAuthStore();
const [username, setUsername] = useState(user?.username || "");
const [nickname, setNickname] = useState(user?.nickname || "");
const [bio, setBio] = useState(user?.bio || "");
const [isChanged, setIsChanged] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [saveSuccess, setSaveSuccess] = useState("");
const handleSave = async () => {
setSaveError("");
setSaveSuccess("");
setIsSaving(true);
try {
// TODO: Implement actual profile update API call
// Update local state
// updateUser({
// username: username.trim(),
// nickname: nickname.trim() || null,
// bio: bio.trim() || null,
// });
setSaveSuccess("Profile updated successfully");
setIsChanged(false);
} catch (error) {
setSaveError("Failed to update profile. Please try again.");
} finally {
setIsSaving(false);
}
};
const handleChange = () => {
setIsChanged(true);
setSaveError("");
setSaveSuccess("");
};
return (
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-full">
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Profile
</CardTitle>
<CardDescription>
Update your profile information and display settings.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{saveError && (
<Alert variant="destructive">
<AlertDescription>{saveError}</AlertDescription>
</Alert>
)}
{saveSuccess && (
<Alert>
<AlertDescription>{saveSuccess}</AlertDescription>
</Alert>
)}
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={username}
onChange={(e) => {
setUsername(e.target.value);
handleChange();
}}
className="max-w-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="nickname">Display Name</Label>
<Input
id="nickname"
value={nickname}
onChange={(e) => {
setNickname(e.target.value);
handleChange();
}}
className="max-w-sm"
placeholder="How others see your name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="bio">Bio</Label>
<Input
id="bio"
value={bio}
onChange={(e) => {
setBio(e.target.value);
handleChange();
}}
className="max-w-sm"
placeholder="Tell others about yourself"
/>
</div>
</div>
<div className="mt-6 flex gap-2">
<Button onClick={handleSave} disabled={!isChanged || isSaving}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
{isChanged && (
<Button
variant="outline"
onClick={() => {
setUsername(user?.username || "");
setNickname(user?.nickname || "");
setBio(user?.bio || "");
setIsChanged(false);
setSaveError("");
setSaveSuccess("");
}}
>
Reset
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
};
const AppearanceSettings: React.FC = () => {
const {
currentLightTheme,
currentDarkTheme,
getThemesForMode,
mode,
setMode,
setTheme,
} = useTheme();
const lightThemes = getThemesForMode("light");
const darkThemes = getThemesForMode("dark");
const getModeIcon = (themeMode: "light" | "dark" | "system") => {
switch (themeMode) {
case "light":
return <Sun className="h-4 w-4" />;
case "dark":
return <Moon className="h-4 w-4" />;
default:
return <Monitor className="h-4 w-4" />;
}
};
return (
<div className="space-y-6 flex flex-col justify-center self-center items-center w-full">
{/* Theme Mode Selection */}
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{getModeIcon(mode)}
Theme Mode
</CardTitle>
<CardDescription>
Choose between light, dark, or system preference.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => setMode("light")}
className={`p-3 rounded-lg border-2 transition-all text-left ${
mode === "light"
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-center mb-2">
<Sun className="h-6 w-6 text-yellow-500" />
</div>
<div className="text-center">
<div className="font-medium text-sm">Light</div>
<div className="text-xs text-muted-foreground">
Always use light theme
</div>
</div>
</button>
<button
onClick={() => setMode("dark")}
className={`p-3 rounded-lg border-2 transition-all text-left ${
mode === "dark"
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-center mb-2">
<Moon className="h-6 w-6 text-blue-400" />
</div>
<div className="text-center">
<div className="font-medium text-sm">Dark</div>
<div className="text-xs text-muted-foreground">
Always use dark theme
</div>
</div>
</button>
<button
onClick={() => setMode("system")}
className={`p-3 rounded-lg border-2 transition-all text-left ${
mode === "system"
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-center mb-2">
<Monitor className="h-6 w-6 text-gray-500" />
</div>
<div className="text-center">
<div className="font-medium text-sm">System</div>
<div className="text-xs text-muted-foreground">
Match system preference
</div>
</div>
</button>
</div>
</CardContent>
</Card>
{/* Theme Selection */}
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Theme Selection
</CardTitle>
<CardDescription>
Choose themes for light and dark mode. You can also create custom
themes.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Current Theme Display */}
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 mb-2">
<Sun className="h-4 w-4 text-yellow-500" />
<Label className="text-sm font-medium">Light Theme</Label>
</div>
<div className="font-medium">{currentLightTheme.name}</div>
{currentLightTheme.description && (
<p className="text-xs text-muted-foreground mt-1">
{currentLightTheme.description}
</p>
)}
<div className="flex gap-1 mt-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: currentLightTheme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: currentLightTheme.colors.secondary,
}}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: currentLightTheme.colors.accent }}
/>
</div>
</div>
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 mb-2">
<Moon className="h-4 w-4 text-blue-400" />
<Label className="text-sm font-medium">Dark Theme</Label>
</div>
<div className="font-medium">{currentDarkTheme.name}</div>
{currentDarkTheme.description && (
<p className="text-xs text-muted-foreground mt-1">
{currentDarkTheme.description}
</p>
)}
<div className="flex gap-1 mt-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: currentDarkTheme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: currentDarkTheme.colors.secondary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: currentDarkTheme.colors.accent }}
/>
</div>
</div>
</div>
{/* Theme Grid */}
<div className="mt-6">
<Label className="text-sm font-medium">Available Themes</Label>
<div className="mt-3 grid grid-cols-2 gap-3">
{/* Light Themes */}
{lightThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={`p-3 rounded-lg border-2 transition-all text-left ${
currentLightTheme.id === theme.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
<Sun className="h-4 w-4 text-yellow-500" />
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mb-2">
{theme.description}
</p>
)}
<div className="flex gap-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.secondary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.accent }}
/>
</div>
</button>
))}
{/* Dark Themes */}
{darkThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={`p-3 rounded-lg border-2 transition-all text-left ${
currentDarkTheme.id === theme.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
<Moon className="h-4 w-4 text-blue-400" />
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mb-2">
{theme.description}
</p>
)}
<div className="flex gap-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.secondary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.accent }}
/>
</div>
</button>
))}
</div>
</div>
{/* Theme Stats */}
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="text-center p-3 bg-muted/50 rounded-lg">
<div className="text-lg font-semibold">{lightThemes.length}</div>
<div className="text-sm text-muted-foreground">Light Themes</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-lg">
<div className="text-lg font-semibold">{darkThemes.length}</div>
<div className="text-sm text-muted-foreground">Dark Themes</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
const VoiceSettings: React.FC = () => {
const [inputVolume, setInputVolume] = useState(75);
const [outputVolume, setOutputVolume] = useState(100);
return (
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-full">
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mic className="h-5 w-5" />
Voice Settings
</CardTitle>
<CardDescription>
Configure your microphone and audio settings.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Input Volume: {inputVolume}%</Label>
<Slider
defaultValue={[inputVolume]}
value={[inputVolume]}
max={100}
onValueChange={(v) => {
setInputVolume(v[0]);
}}
/>
</div>
<div className="space-y-2">
<Label>Output Volume: {outputVolume}%</Label>
<Slider
defaultValue={[outputVolume]}
value={[outputVolume]}
max={100}
onValueChange={(v) => {
setOutputVolume(v[0]);
}}
/>
</div>
<Separator />
</CardContent>
</Card>
</div>
);
};
const SettingsPage: React.FC = () => {
const { section } = useParams();
const currentSection = section || "account";
const navigate = useNavigate();
const renderSettingsContent = () => {
switch (currentSection) {
case "account":
return <AccountSettings />;
case "appearance":
return <AppearanceSettings />;
case "voice":
return <VoiceSettings />;
default:
return <AccountSettings />;
}
};
return (
<div className="flex h-full">
{/* Sidebar */}
<div className="w-1/4 flex flex-col bg-concord-secondary border-r border-concord">
<div className="px-4 py-4 border-b border-concord">
<h1 className="text-2xl font-semibold text-concord-primary">
Settings
</h1>
</div>
<ScrollArea className="flex-1">
<div className="p-2">
{SETTINGS_SECTIONS.map((settingsSection) => {
const Icon = settingsSection.icon;
const isActive = currentSection === settingsSection.id;
return (
<Button
key={settingsSection.id}
variant={isActive ? "secondary" : "ghost"}
onClick={() => navigate(`/settings/${settingsSection.id}`)}
className="w-full justify-start mb-1 h-auto p-2"
asChild
>
<div>
<Icon className="mr-2 h-4 w-4" />
<div className="flex-1 text-left">
<div className="font-medium">{settingsSection.title}</div>
{settingsSection.description && (
<div className="text-xs text-muted-foreground">
{settingsSection.description}
</div>
)}
</div>
<ChevronRight className="h-4 w-4" />
</div>
</Button>
);
})}
</div>
</ScrollArea>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="px-6 py-4 border-b border-concord">
<div className="flex items-center gap-2">
{(() => {
const section = SETTINGS_SECTIONS.find(
(s) => s.id === currentSection,
);
const Icon = section?.icon || Settings;
return <Icon className="h-5 w-5" />;
})()}
<h1 className="text-2xl font-bold text-concord-primary">
{SETTINGS_SECTIONS.find((s) => s.id === currentSection)?.title ||
"Settings"}
</h1>
</div>
</div>
{/* Content */}
<ScrollArea className="min-h-0 w-full bg-concord-primary h-full">
<div className="p-6 flex w-full">{renderSettingsContent()}</div>
</ScrollArea>
</div>
</div>
);
};
export default SettingsPage;

View File

@@ -1,69 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { User } from "@/types/database";
interface AuthState {
user: User | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
// Actions
setAuth: (user: User, token: string, refreshToken: string) => void;
updateUser: (user: Partial<User>) => void;
logout: () => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
setAuth: (user, token, refreshToken) =>
set({
user,
token,
refreshToken,
isAuthenticated: true,
isLoading: false,
}),
updateUser: (userData) =>
set((state) => ({
user: state.user ? { ...state.user, ...userData } : null,
})),
logout: () =>
set({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
}),
setLoading: (isLoading) => set({ isLoading }),
}),
{
name: "concord-auth-store",
// Persist auth data
partialize: (state) => ({
user: state.user,
token: state.token,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
},
),
);
export const useCurrentUser = () => useAuthStore((state) => state.user);
export const useIsAuthenticated = () =>
useAuthStore((state) => state.isAuthenticated);
export const useAuthToken = () => useAuthStore((state) => state.token);

View File

@@ -1,141 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface UiState {
// Sidebar states
showMemberList: boolean;
sidebarCollapsed: boolean;
// Modal states
showUserSettings: boolean;
showServerSettings: boolean;
showCreateChannel: boolean;
showCreateServer: boolean;
showInviteModal: boolean;
// Navigation
activeChannelId: string | null;
activeInstanceId: string | null;
// Computed: Should show channel sidebar
shouldShowChannelSidebar: boolean;
// Actions
toggleMemberList: () => void;
toggleSidebar: () => void;
// Modal actions
openUserSettings: () => void;
closeUserSettings: () => void;
openServerSettings: () => void;
closeServerSettings: () => void;
openCreateChannel: () => void;
closeCreateChannel: () => void;
openCreateServer: () => void;
closeCreateServer: () => void;
openInviteModal: () => void;
closeInviteModal: () => void;
// Navigation actions
setActiveChannel: (channelId: string | null) => void;
setActiveInstance: (instanceId: string | null) => void;
selectedChannelsByInstance: Record<string, string>;
setSelectedChannelForInstance: (
instanceId: string,
channelId: string,
) => void;
getSelectedChannelForInstance: (instanceId: string) => string | null;
updateSidebarVisibility: (pathname: string) => void;
}
// Helper function to determine if channel sidebar should be shown
const shouldShowChannelSidebar = (pathname: string): boolean => {
// Show channel sidebar for server pages (not settings, home, etc.)
const pathParts = pathname.split("/");
const isChannelsRoute = pathParts[1] === "channels";
const isSettingsRoute = pathname.includes("/settings");
return isChannelsRoute && !isSettingsRoute;
};
export const useUiStore = create<UiState>()(
persist(
(set, get) => ({
// Initial state
showMemberList: true,
sidebarCollapsed: false,
screenWidth: typeof window !== "undefined" ? window.innerWidth : 1024,
showUserSettings: false,
showServerSettings: false,
showCreateChannel: false,
showCreateServer: false,
showInviteModal: false,
activeChannelId: null,
activeInstanceId: null,
shouldShowChannelSidebar: false,
selectedChannelsByInstance: {},
// Sidebar actions
toggleMemberList: () =>
set((state) => ({ showMemberList: !state.showMemberList })),
toggleSidebar: () =>
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
// Modal actions
openUserSettings: () => set({ showUserSettings: true }),
closeUserSettings: () => set({ showUserSettings: false }),
openServerSettings: () => set({ showServerSettings: true }),
closeServerSettings: () => set({ showServerSettings: false }),
openCreateChannel: () => set({ showCreateChannel: true }),
closeCreateChannel: () => set({ showCreateChannel: false }),
openCreateServer: () => set({ showCreateServer: true }),
closeCreateServer: () => set({ showCreateServer: false }),
openInviteModal: () => set({ showInviteModal: true }),
closeInviteModal: () => set({ showInviteModal: false }),
// Navigation actions
setActiveChannel: (channelId) => set({ activeChannelId: channelId }),
setActiveInstance: (instanceId) => set({ activeInstanceId: instanceId }),
setSelectedChannelForInstance: (instanceId, channelId) =>
set((state) => ({
selectedChannelsByInstance: {
...state.selectedChannelsByInstance,
[instanceId]: channelId,
},
})),
getSelectedChannelForInstance: (instanceId) => {
const state = get();
return state.selectedChannelsByInstance[instanceId] || null;
},
updateSidebarVisibility: (pathname) => {
const showChannelSidebar = shouldShowChannelSidebar(pathname);
const pathParts = pathname.split("/");
const instanceId = pathParts[2] || null;
const channelId = pathParts[3] || null;
set({
shouldShowChannelSidebar: showChannelSidebar,
activeInstanceId: instanceId,
activeChannelId: channelId,
});
// Store the selected channel for this instance if we have both
if (instanceId && channelId) {
get().setSelectedChannelForInstance(instanceId, channelId);
}
},
}),
{
name: "concord-ui-store",
// Only persist UI preferences, not temporary states
partialize: (state) => ({
showMemberList: state.showMemberList,
sidebarCollapsed: state.sidebarCollapsed,
selectedChannelsByInstance: state.selectedChannelsByInstance,
}),
},
),
);

View File

@@ -1,328 +0,0 @@
import { create } from "zustand";
import { Socket } from "socket.io-client";
// --- TYPE DEFINITIONS ---
interface IceServerConfig {
urls: string | string[];
username?: string;
credential?: string;
}
// The state managed by the store
interface VoiceState {
socket: Socket | null;
localStream: MediaStream | null;
remoteStreams: Map<string, MediaStream>;
peerConnections: Map<string, RTCPeerConnection>;
iceServers: IceServerConfig[];
isConnected: boolean;
isConnecting: boolean;
activeVoiceChannelId: string | null;
isDeafened: boolean;
isMuted: boolean;
}
// Actions that can be performed on the store
interface VoiceActions {
init: (socket: Socket) => void;
joinChannel: (
channelId: string,
userId: string,
token: string,
) => Promise<void>;
leaveChannel: () => void;
cleanup: () => void;
toggleMute: () => void;
toggleDeafen: () => void;
}
// --- ZUSTAND STORE IMPLEMENTATION ---
export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
// --- INTERNAL HELPERS (not exposed in the store's public interface) ---
/**
* Safely closes and removes a single peer connection.
* @param userId The ID of the user whose connection to clean up.
*/
const cleanupPeerConnection = (userId: string) => {
const { peerConnections } = get();
const peerConnection = peerConnections.get(userId);
if (peerConnection) {
peerConnection.close();
peerConnections.delete(userId);
}
set((state) => {
const newStreams = new Map(state.remoteStreams);
newStreams.delete(userId);
return {
remoteStreams: newStreams,
peerConnections: new Map(peerConnections),
};
});
};
/**
* Creates a new RTCPeerConnection for a target user and configures it.
* @param targetUserId The user to connect to.
* @returns The configured RTCPeerConnection instance.
*/
const createPeerConnection = (targetUserId: string): RTCPeerConnection => {
const { iceServers, localStream, socket, peerConnections } = get();
const peerConnection = new RTCPeerConnection({ iceServers });
// Add local stream tracks to the new connection
if (localStream) {
localStream
.getTracks()
.forEach((track) => peerConnection.addTrack(track, localStream));
}
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate && socket) {
socket.emit("webrtc-ice-candidate", {
targetUserId,
candidate: event.candidate,
});
}
};
// Handle incoming remote tracks
peerConnection.ontrack = (event) => {
set((state) => {
const newStreams = new Map(state.remoteStreams);
newStreams.set(targetUserId, event.streams[0]);
return { remoteStreams: newStreams };
});
};
// For debugging connection state
peerConnection.onconnectionstatechange = () => {
if (
peerConnection.connectionState === "disconnected" ||
peerConnection.connectionState === "failed"
) {
cleanupPeerConnection(targetUserId);
}
};
peerConnections.set(targetUserId, peerConnection);
set({ peerConnections: new Map(peerConnections) });
return peerConnection;
};
// --- SOCKET EVENT HANDLERS ---
// These are defined once and can be reused by the join/leave actions.
const onJoinedVoiceChannel = async (data: {
connectedUserIds: string[];
iceServers: IceServerConfig[];
}) => {
set({
iceServers: data.iceServers,
isConnecting: false,
isConnected: true,
});
for (const userId of data.connectedUserIds) {
const peerConnection = createPeerConnection(userId);
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
get().socket?.emit("webrtc-offer", {
targetUserId: userId,
sdp: peerConnection.localDescription,
});
}
};
const onUserLeft = (data: { userId: string }) => {
cleanupPeerConnection(data.userId);
};
const onWebRTCOffer = async (data: {
senderUserId: string;
sdp: RTCSessionDescriptionInit;
}) => {
const peerConnection = createPeerConnection(data.senderUserId);
await peerConnection.setRemoteDescription(
new RTCSessionDescription(data.sdp),
);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
get().socket?.emit("webrtc-answer", {
targetUserId: data.senderUserId,
sdp: peerConnection.localDescription,
});
};
const onWebRTCAnswer = async (data: {
senderUserId: string;
sdp: RTCSessionDescriptionInit;
}) => {
const peerConnection = get().peerConnections.get(data.senderUserId);
if (peerConnection) {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(data.sdp),
);
}
};
const onICECandidate = async (data: {
senderUserId: string;
candidate: RTCIceCandidateInit;
}) => {
const peerConnection = get().peerConnections.get(data.senderUserId);
if (peerConnection && data.candidate) {
try {
await peerConnection.addIceCandidate(
new RTCIceCandidate(data.candidate),
);
} catch (e) {
console.error("Error adding received ICE candidate", e);
}
}
};
const onError = (error: { message: string }) => {
console.error("Voice channel error:", error.message);
get().leaveChannel(); // Disconnect on error
};
// --- STORE DEFINITION (STATE & ACTIONS) ---
return {
// Initial State
socket: null,
localStream: null,
remoteStreams: new Map(),
peerConnections: new Map(),
iceServers: [],
isConnected: false,
isConnecting: false,
activeVoiceChannelId: null,
isMuted: false,
isDeafened: false,
// Actions
init: (socketInstance) => {
set({ socket: socketInstance });
},
joinChannel: async (channelId: string, userId: string, token: string) => {
const { socket, activeVoiceChannelId, leaveChannel, isConnecting } =
get();
if (!socket || isConnecting || activeVoiceChannelId === channelId) return;
if (!userId || !token) {
console.error("Join channel requires user and token.");
return;
}
if (activeVoiceChannelId) {
leaveChannel();
}
set({ isConnecting: true, activeVoiceChannelId: channelId });
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
set({ localStream: stream });
} catch (error) {
console.error("Could not get user media:", error);
set({ isConnecting: false, activeVoiceChannelId: null });
return;
}
// Attach all necessary listeners for a voice session
socket.on("joined-voicechannel", onJoinedVoiceChannel);
socket.on("user-left-voicechannel", onUserLeft);
socket.on("webrtc-offer", onWebRTCOffer);
socket.on("webrtc-answer", onWebRTCAnswer);
socket.on("webrtc-ice-candidate", onICECandidate);
socket.on("error-voicechannel", onError);
// *** THE FIX: Send user credentials with the join request ***
socket.emit("join-voicechannel", {
userId: userId,
userToken: token,
voiceChannelId: channelId,
});
},
leaveChannel: () => {
const { socket, peerConnections, localStream, activeVoiceChannelId } =
get();
if (!socket || !activeVoiceChannelId) return;
socket.emit("leave-voicechannel", { channelId: activeVoiceChannelId });
// Clean up all event listeners
socket.off("joined-voicechannel");
socket.off("user-left-voicechannel");
socket.off("webrtc-offer");
socket.off("webrtc-answer");
socket.off("webrtc-ice-candidate");
socket.off("error-voicechannel");
// Close all peer connections
peerConnections.forEach((pc) => pc.close());
// Stop local media tracks
localStream?.getTracks().forEach((track) => track.stop());
// Reset state to initial values
set({
localStream: null,
remoteStreams: new Map(),
peerConnections: new Map(),
isConnected: false,
isConnecting: false,
activeVoiceChannelId: null,
iceServers: [],
});
},
toggleMute: () => {
set((state) => {
const newMutedState = !state.isMuted;
if (state.localStream) {
state.localStream.getAudioTracks().forEach((track) => {
track.enabled = !newMutedState;
});
}
// Cannot be deafened and unmuted
if (state.isDeafened && !newMutedState) {
return { isMuted: newMutedState, isDeafened: false };
}
return { isMuted: newMutedState };
});
},
toggleDeafen: () => {
set((state) => {
const newDeafenedState = !state.isDeafened;
// When deafening, you are also muted
if (newDeafenedState && !state.isMuted) {
// Manually mute logic without toggling deafen state again
if (state.localStream) {
state.localStream.getAudioTracks().forEach((track) => {
track.enabled = false;
});
}
return { isDeafened: newDeafenedState, isMuted: true };
}
return { isDeafened: newDeafenedState };
});
},
cleanup: () => {
get().leaveChannel();
set({ socket: null });
},
};
});

View File

@@ -1,85 +0,0 @@
import { Instance, Category, Channel, User, Message } from "./database";
// API Response wrappers
export interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
// Extended types with relations for frontend use
export interface ChannelWithCategory extends Channel {
category: Category;
}
export interface CategoryWithChannels extends Category {
channels: Channel[];
}
export interface InstanceWithDetails extends Instance {
categories: CategoryWithChannels[];
}
export interface MessageWithUser extends Message {
user: User;
}
// Request types
export interface CreateInstanceRequest {
name: string;
description?: string;
icon?: string;
}
export interface CreateCategoryRequest {
name: string;
instanceId: string;
position?: number;
}
export interface CreateChannelRequest {
name: string;
type: "text" | "voice";
categoryId: string;
topic?: string;
position?: number;
}
export interface SendMessageRequest {
content: string;
channelId: string;
user: User;
}
export interface UpdateMessageRequest {
content: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
email?: string;
}
export interface AuthResponse {
user: User;
token: string;
refreshToken: string;
}

View File

@@ -1,93 +0,0 @@
export interface Instance {
id: string;
name: string;
icon?: string | null;
description?: string;
createdAt: string;
updatedAt: string;
}
export interface Category {
id: string;
name: string;
instanceId: string;
position: number;
createdAt: string;
updatedAt: string;
}
export interface Channel {
id: string;
name: string;
type: "text" | "voice";
categoryId: string;
instanceId: string;
position: number;
description?: string;
createdAt: string;
updatedAt: string;
}
export type UserStatus = "online" | "away" | "busy" | "offline";
export interface User {
id: string;
username: string;
nickname?: string | null;
bio?: string | null;
picture?: string | null;
banner?: string | null;
hashPassword: string;
admin: boolean;
status: "online" | "away" | "busy" | "offline";
createdAt: string;
updatedAt: string;
roles: Role[];
}
export interface Role {
instanceId: string;
role: "admin" | "mod" | "member";
}
export interface Message {
id: string;
content: string;
channelId: string;
userId: string;
edited: boolean;
createdAt: string;
updatedAt: string;
isGrouped?: boolean | null;
replyTo?: Message | null;
// Relations
user?: User;
channel?: Channel;
replyToId?: string | null;
}
// Direct messages
// export interface DirectMessage {
// id: string;
// content: string;
// senderId: string;
// receiverId: string;
// edited: boolean;
// createdAt: string;
// updatedAt: string;
// // Relations
// sender?: User;
// receiver?: User;
// }
export interface UserRole {
userId: string;
roleId: string;
instanceId: string;
}
export interface UserInstance {
userId: string;
instanceId: string;
joinedAt: string;
}

View File

@@ -1,38 +0,0 @@
// API types
export type {
ApiResponse,
Instance,
Category,
Channel,
BackendUser,
Message,
} from "@/lib/api-client";
// Auth types
export type {
LoginCredentials,
RegisterData,
AuthResponse,
} from "@/lib/auth-client";
// Hook types
export type { CategoryWithChannels, InstanceWithDetails } from "@/types/api";
// Frontend User type (for compatibility with existing components)
export interface User {
id: string;
username: string;
nickname?: string | null;
bio?: string | null;
picture?: string | null;
banner?: string | null;
hashPassword: string;
admin: boolean;
status: string;
createdAt: string;
updatedAt: string;
roles: Array<{
instanceId: string;
role: string;
}>;
}

View File

@@ -1,198 +0,0 @@
import { User } from "@/types/database";
export type UserPermission =
| "view_instance"
| "create_channel"
| "delete_channel"
| "create_category"
| "delete_category"
| "manage_instance"
| "delete_messages"
| "pin_messages"
| "manage_users"
| "create_instance"
| "manage_roles";
export type UserRole = "admin" | "mod" | "member";
// Check if user has a specific role in an instance
export function hasInstanceRole(
user: User | null,
instanceId: string,
role: UserRole,
): boolean {
if (!user) return false;
// Global admins have all permissions
if (user.admin) return true;
const userRole = user.roles.find((r) => r.instanceId === instanceId);
if (!userRole) return false;
switch (role) {
case "admin":
return userRole.role === "admin";
case "mod":
return userRole.role === "admin" || userRole.role === "mod";
case "member":
return (
userRole.role === "admin" ||
userRole.role === "mod" ||
userRole.role === "member"
);
default:
return false;
}
}
// Check if user has access to view an instance
export function canViewInstance(
user: User | null,
instanceId: string,
): boolean {
if (!user) return false;
// Global admins can view all instances
if (user.admin) return true;
// Check if user has any role in this instance
return user.roles.some((role) => role.instanceId === instanceId);
}
// Check if user has a specific permission in an instance
export function hasPermission(
user: User | null,
instanceId: string,
permission: UserPermission,
): boolean {
if (!user) return false;
// Global admins have all permissions everywhere
if (user.admin) return true;
const userRole = user.roles.find((r) => r.instanceId === instanceId);
if (!userRole) return false;
switch (permission) {
case "view_instance":
return hasInstanceRole(user, instanceId, "member");
case "create_channel":
case "delete_channel":
case "create_category":
case "delete_category":
case "manage_instance":
case "manage_users":
case "manage_roles":
return hasInstanceRole(user, instanceId, "admin");
case "delete_messages":
case "pin_messages":
return hasInstanceRole(user, instanceId, "mod");
case "create_instance":
return user.admin; // Only global admins can create instances
default:
return false;
}
}
// Get user's role in a specific instance
export function getUserRole(
user: User | null,
instanceId: string,
): UserRole | null {
if (!user) return null;
// Global admins are always admins
if (user.admin) return "admin";
const userRole = user.roles.find((r) => r.instanceId === instanceId);
return userRole ? (userRole.role as UserRole) : null;
}
// Filter instances that user can access
export function getAccessibleInstances(
user: User | null,
instances: any[],
): any[] {
if (!user) return [];
// Global admins can see all instances
if (user.admin) return instances;
// Filter instances where user has a role
const userInstanceIds = new Set(user.roles.map((role) => role.instanceId));
return instances.filter((instance) => userInstanceIds.has(instance.id));
}
// Check if user can delete a specific message
export function canDeleteMessage(
user: User | null,
instanceId: string,
messageUserId: string,
): boolean {
if (!user) return false;
// Users can always delete their own messages
if (user.id === messageUserId) return true;
// Mods and admins can delete any message
return hasPermission(user, instanceId, "delete_messages");
}
// Check if user can edit a specific message
export function canEditMessage(
user: User | null,
messageUserId: string,
): boolean {
if (!user) return false;
// Users can only edit their own messages
return user.id === messageUserId;
}
// Check if user can pin messages
export function canPinMessage(user: User | null, instanceId: string): boolean {
return hasPermission(user, instanceId, "pin_messages");
}
// Check if user is global admin
export function isGlobalAdmin(user: User | null): boolean {
return user?.admin === true;
}
// Helper to get role display info
export function getRoleDisplayInfo(role: UserRole) {
switch (role) {
case "admin":
return {
name: "Admin",
color: "#ff6b6b",
priority: 3,
description: "Full server permissions",
};
case "mod":
return {
name: "Moderator",
color: "#4ecdc4",
priority: 2,
description: "Can moderate messages and users",
};
case "member":
return {
name: "Member",
color: null,
priority: 1,
description: "Basic server access",
};
default:
return {
name: "Unknown",
color: null,
priority: 0,
description: "Unknown role",
};
}
}

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,29 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
},
"include": ["src", "electron"],
"references": [{ "path": "./tsconfig.node.json" }],
}

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["vite.config.ts"]
}

View File

@@ -1,44 +0,0 @@
import { defineConfig } from "vite";
import path from "path";
import electron from "vite-plugin-electron/simple";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// Check if VITE_APP_MODE is set to 'web'
const isWebApp = process.env.VITE_APP_MODE === "web";
return {
plugins: [
react(),
// Only include the electron plugin if not in 'web' app mode
!isWebApp &&
electron({
main: {
// Shortcut of `build.lib.entry`.
entry: "electron/main.ts",
},
preload: {
// Shortcut of `build.rollupOptions.input`.
// Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
input: path.join(__dirname, "electron/preload.ts"),
},
// Ployfill the Electron and Node.js API for Renderer process.
// If you want use Node.js in Renderer process, the `nodeIntegration` needs to be enabled in the Main process.
// See 👉 https://github.com/electron-vite/vite-plugin-electron-renderer
renderer:
process.env.NODE_ENV === "test"
? // https://github.com/electron-vite/vite-plugin-electron-renderer/issues/78#issuecomment-2053600808
undefined
: {},
}),
tailwindcss(),
].filter(Boolean), // Filter out 'false' values if electron plugin is not included
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
};
});

View File

@@ -1,10 +0,0 @@
# EditorConfig helps maintain consistent coding styles
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

View File

@@ -1,13 +0,0 @@
To install dependencies:
```sh
bun install
```
To run:
```sh
bun run dev
```
open http://localhost:3000

View File

@@ -1,243 +0,0 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "concord-server",
"dependencies": {
"@hono/standard-validator": "^0.1.5",
"@hono/zod-validator": "^0.7.3",
"@prisma/client": "^6.16.2",
"@scalar/hono-api-reference": "^0.9.19",
"@socket.io/bun-engine": "^0.0.3",
"hono": "^4.9.9",
"hono-openapi": "^1.1.0",
"prisma": "^6.16.2",
"socket.io": "^4.8.1",
"zod": "^4.1.11",
},
"devDependencies": {
"@types/bun": "latest",
"prisma-zod-generator": "^1.22.1",
},
},
},
"packages": {
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uYGdgVib3RlGD698WR5dVM0zB3UuPY5vHKXffGUbUh7r4xY+mFIhF3/v4AcQVLrU5CQdBso8BJr4wuVoCrjTuQ=="],
"@prisma/client": ["@prisma/client@6.16.2", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw=="],
"@prisma/config": ["@prisma/config@6.16.2", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.16.12", "empathic": "2.0.0" } }, ""],
"@prisma/debug": ["@prisma/debug@6.16.2", "", {}, ""],
"@prisma/dmmf": ["@prisma/dmmf@6.16.2", "", {}, "sha512-o9ztgdbj2KZXl6DL+oP56TTC0poTLPns9/MeU761b49E1IQ/fd0jwdov1bidlNOiwio8Nsou23xNrYE/db10aA=="],
"@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@6.16.2", "", { "dependencies": { "@prisma/debug": "6.16.2" } }, "sha512-DMgfafnG0zPd+QoAQOC0Trn1xlb0fVAfQi2MpkpzSf641KiVkVPkJRXDSbcTbxGxO2HRdd0vI9U6LlesWad4XA=="],
"@prisma/engines": ["@prisma/engines@6.16.2", "", { "dependencies": { "@prisma/debug": "6.16.2", "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "@prisma/fetch-engine": "6.16.2", "@prisma/get-platform": "6.16.2" } }, ""],
"@prisma/engines-version": ["@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "", {}, ""],
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.16.2", "", { "dependencies": { "@prisma/debug": "6.16.2", "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "@prisma/get-platform": "6.16.2" } }, ""],
"@prisma/generator": ["@prisma/generator@6.16.2", "", {}, "sha512-7bwRmtMIgfe1rUynh1p9VlmYvEiidbRO6aBphPBS6YGEGSvNe8+QExbRpsqFlFBvIX76BhZCxuEj7ZwALMYRKQ=="],
"@prisma/generator-helper": ["@prisma/generator-helper@6.16.2", "", { "dependencies": { "@prisma/debug": "6.16.2", "@prisma/dmmf": "6.16.2", "@prisma/generator": "6.16.2" } }, "sha512-8tVnWM8ETJNrvI5CT9eKCW23+aPLNkidC+g9NJn7ghXm60Q7GGlLX5tmvn5dE8tXvs/FSX3MN7KNmNJpOr89Hw=="],
"@prisma/get-platform": ["@prisma/get-platform@6.16.2", "", { "dependencies": { "@prisma/debug": "6.16.2" } }, ""],
"@prisma/internals": ["@prisma/internals@6.16.2", "", { "dependencies": { "@prisma/config": "6.16.2", "@prisma/debug": "6.16.2", "@prisma/dmmf": "6.16.2", "@prisma/driver-adapter-utils": "6.16.2", "@prisma/engines": "6.16.2", "@prisma/fetch-engine": "6.16.2", "@prisma/generator": "6.16.2", "@prisma/generator-helper": "6.16.2", "@prisma/get-platform": "6.16.2", "@prisma/prisma-schema-wasm": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "@prisma/schema-engine-wasm": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "@prisma/schema-files-loader": "6.16.2", "arg": "5.0.2", "prompts": "2.4.2" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-gwmWl7H8iTbi+58RXua5Lsus5LDbIZGO2wQ4RoSX9YtEbKWHwRP8TUzTVLwRNeJ2DHwfnzhTLrUnybwotqiACg=="],
"@prisma/prisma-schema-wasm": ["@prisma/prisma-schema-wasm@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "", {}, "sha512-DvYi0zKqzPd49Z5japS3FawyMylscaoUmlXNhnRAXb8HZryG4Q7TM1FLX8OIAfCgLmoWS1c/Zf4UZznBXkvWSg=="],
"@prisma/schema-engine-wasm": ["@prisma/schema-engine-wasm@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "", {}, "sha512-HDgFE0um5OHkk2pkQbAgARR284i2VpoM+7GYRAT0zxoTagsdaZ6yquJF2LEZuAKfibib0Ct7JZxRCB8eN/Ru6g=="],
"@prisma/schema-files-loader": ["@prisma/schema-files-loader@6.16.2", "", { "dependencies": { "@prisma/prisma-schema-wasm": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "fs-extra": "11.3.0" } }, "sha512-TN77DUFgOxT/WL6wuxVhn7qVDvwVRl0TEzhFfRh5vKQsuZ5itLzA7Ki4TgOs4Pk18wwZnti6ZKdzR3Y7cu2KsA=="],
"@scalar/core": ["@scalar/core@0.3.17", "", { "dependencies": { "@scalar/types": "0.2.16" } }, "sha512-G6tP+2oorFA90szI8DGiEQ23SmobiuhN93GfTJNpFMhz/kdEtC4lcYawAxL1tWkZhlK/QYRcaCZcVzmpTUgBCA=="],
"@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.9.19", "", { "dependencies": { "@scalar/core": "0.3.17" }, "peerDependencies": { "hono": "^4.0.0" } }, "sha512-MeBdIwuAAhwvsUt1f9174wASu0C8b4WrXuVdC/6FjrsTxLHSbJVx+XFzZAKF5ex+1kaXjvYrm3fiovRFHLyfJA=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.3.7", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-QHSvHBVDze3+dUwAhIGq6l1iOev4jdoqdBK7QpfeN1Q4h+6qpVEw3EEqBiH0AXUSh/iWwObBv4uMgfIx0aNZ5g=="],
"@scalar/types": ["@scalar/types@0.2.16", "", { "dependencies": { "@scalar/openapi-types": "0.3.7", "nanoid": "5.1.5", "zod": "3.24.1" } }, "sha512-XWff9jWfYaj6q3ww94x66S6Q58u/3kA1sDOUhLAwb9va7r58bzk3NRwLOkEEdJmyEns1MEJAM53mY8KRWX6elA=="],
"@socket.io/bun-engine": ["@socket.io/bun-engine@0.0.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-OK2ZObq9hKsxyAcV7xDTcWGubBmfEY3Lt4nb04K+HlYl9G5PDgrY9hxJm9uV+B0xo3MhKFrgdg9VQsVZ3pbT/g=="],
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="],
"@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.8", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-80ap74p5oy/SU4al5HkPwO5+NbN2wH/FBr6kwaE5ROq7AvcDFaxzUfTazewroNaCotbvdGcvzXb9oEoOIyfC/Q=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, ""],
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, ""],
"@types/react": ["@types/react@19.1.14", "", { "dependencies": { "csstype": "^3.0.2" } }, ""],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, ""],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, ""],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, ""],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, ""],
"confbox": ["confbox@0.2.2", "", {}, ""],
"consola": ["consola@3.4.2", "", {}, ""],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"csstype": ["csstype@3.1.3", "", {}, ""],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, ""],
"defu": ["defu@6.1.4", "", {}, ""],
"destr": ["destr@2.0.5", "", {}, ""],
"dotenv": ["dotenv@16.6.1", "", {}, ""],
"effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, ""],
"empathic": ["empathic@2.0.0", "", {}, ""],
"engine.io": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"exsolve": ["exsolve@1.0.7", "", {}, ""],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, ""],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": "dist/cli.mjs" }, ""],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hono": ["hono@4.9.9", "", {}, ""],
"hono-openapi": ["hono-openapi@1.1.0", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-eA5hN8D2O30EkPPUxWFilcZcThAe81TShbH38Y183ZZp8WkgMh4BrPEDeZ/EFN2tyDi3cmTgKTa3+oStyJX0UA=="],
"jiti": ["jiti@2.6.0", "", { "bin": "lib/jiti-cli.mjs" }, ""],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, ""],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": "dist/cli.mjs" }, ""],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"ohash": ["ohash@2.0.11", "", {}, ""],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"pathe": ["pathe@2.0.3", "", {}, ""],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, ""],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, ""],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"prisma": ["prisma@6.16.2", "", { "dependencies": { "@prisma/config": "6.16.2", "@prisma/engines": "6.16.2" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": "build/index.js" }, ""],
"prisma-zod-generator": ["prisma-zod-generator@1.22.1", "", { "dependencies": { "@prisma/client": "^6.16.2", "@prisma/generator-helper": "^6.16.2", "@prisma/internals": "^6.16.2", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "node-fetch": "^3.3.2", "prettier": "^3.6.2", "tslib": "^2.8.1" }, "peerDependencies": { "zod": ">=3.25.0 <5" }, "bin": { "prisma-zod-generator": "lib/generator.js" } }, "sha512-nBr00sfR8onGCD5eIDLHoFrpeJTSuZxSeaO61Zg6CAEyXPR51gpkO1ev9huG7+tsV+mm8me8VNl8hMcVtWl8FA=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, ""],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, ""],
"readdirp": ["readdirp@4.1.2", "", {}, ""],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"socket.io": ["socket.io@4.8.1", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.3.2", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg=="],
"socket.io-adapter": ["socket.io-adapter@2.5.5", "", { "dependencies": { "debug": "~4.3.4", "ws": "~8.17.1" } }, "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg=="],
"socket.io-parser": ["socket.io-parser@4.2.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" } }, "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew=="],
"tinyexec": ["tinyexec@1.0.1", "", {}, ""],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.12.0", "", {}, ""],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
"zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@scalar/openapi-types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@scalar/types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
}
}

View File

@@ -1,131 +0,0 @@
-- CreateTable
CREATE TABLE "public"."Instance" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"icon" TEXT,
CONSTRAINT "Instance_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"nickname" TEXT,
"bio" TEXT,
"picture" TEXT,
"banner" TEXT,
"admin" BOOLEAN NOT NULL,
"status" TEXT NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Role" (
"userId" TEXT NOT NULL,
"instanceId" TEXT NOT NULL,
"type" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "public"."UserAuth" (
"userId" TEXT NOT NULL,
"password" TEXT NOT NULL,
"token" TEXT
);
-- CreateTable
CREATE TABLE "public"."Category" (
"id" TEXT NOT NULL,
"instanceId" TEXT,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Channel" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"categoryId" TEXT,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
CONSTRAINT "Channel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Message" (
"id" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"deleted" BOOLEAN NOT NULL,
"text" TEXT NOT NULL,
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Reply" (
"messageId" TEXT NOT NULL,
"repliesToId" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "public"."MessagePing" (
"messageId" TEXT NOT NULL,
"pingsUserId" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Role_userId_instanceId_key" ON "public"."Role"("userId", "instanceId");
-- CreateIndex
CREATE UNIQUE INDEX "UserAuth_userId_key" ON "public"."UserAuth"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Reply_messageId_key" ON "public"."Reply"("messageId");
-- CreateIndex
CREATE UNIQUE INDEX "Reply_repliesToId_key" ON "public"."Reply"("repliesToId");
-- CreateIndex
CREATE UNIQUE INDEX "Reply_messageId_repliesToId_key" ON "public"."Reply"("messageId", "repliesToId");
-- CreateIndex
CREATE UNIQUE INDEX "MessagePing_messageId_pingsUserId_key" ON "public"."MessagePing"("messageId", "pingsUserId");
-- AddForeignKey
ALTER TABLE "public"."Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Role" ADD CONSTRAINT "Role_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "public"."Instance"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."UserAuth" ADD CONSTRAINT "UserAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Category" ADD CONSTRAINT "Category_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "public"."Instance"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Channel" ADD CONSTRAINT "Channel_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "public"."Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Message" ADD CONSTRAINT "Message_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "public"."Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Message" ADD CONSTRAINT "Message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Reply" ADD CONSTRAINT "Reply_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "public"."Message"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Reply" ADD CONSTRAINT "Reply_repliesToId_fkey" FOREIGN KEY ("repliesToId") REFERENCES "public"."Message"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MessagePing" ADD CONSTRAINT "MessagePing_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "public"."Message"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MessagePing" ADD CONSTRAINT "MessagePing_pingsUserId_fkey" FOREIGN KEY ("pingsUserId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,71 +0,0 @@
/*
Warnings:
- Added the required column `updatedAt` to the `Category` table without a default value. This is not possible if the table is not empty.
- Added the required column `pinnedId` to the `Channel` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Channel` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Instance` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Message` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Reply` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Role` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `UserAuth` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "public"."Category" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "public"."Channel" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "pinnedId" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "public"."Instance" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "public"."Message" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "public"."MessagePing" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "public"."Reply" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "public"."Role" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "public"."User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "public"."UserAuth" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateTable
CREATE TABLE "public"."ChannelPin" (
"messageId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "ChannelPin_messageId_key" ON "public"."ChannelPin"("messageId");
-- CreateIndex
CREATE UNIQUE INDEX "ChannelPin_channelId_key" ON "public"."ChannelPin"("channelId");
-- CreateIndex
CREATE UNIQUE INDEX "ChannelPin_messageId_channelId_key" ON "public"."ChannelPin"("messageId", "channelId");
-- AddForeignKey
ALTER TABLE "public"."ChannelPin" ADD CONSTRAINT "ChannelPin_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "public"."Message"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ChannelPin" ADD CONSTRAINT "ChannelPin_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "public"."Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,26 +0,0 @@
-- AlterTable
ALTER TABLE "public"."Category" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "public"."Channel" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "public"."Channel" ALTER COLUMN "pinnedId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "public"."Instance" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "public"."Message" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "public"."Reply" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "public"."Role" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "public"."User" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "public"."UserAuth" ALTER COLUMN "updatedAt" DROP DEFAULT;

View File

@@ -1,8 +0,0 @@
/*
Warnings:
- You are about to alter the column `text` on the `Message` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(2000)`.
*/
-- AlterTable
ALTER TABLE "public"."Message" ALTER COLUMN "text" SET DATA TYPE VARCHAR(2000);

View File

@@ -1,390 +0,0 @@
{
"name": "concord-server",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "concord-server",
"dependencies": {
"@prisma/client": "^6.16.2",
"hono": "^4.9.9",
"prisma": "^6.16.2"
},
"devDependencies": {
"@types/bun": "latest"
}
},
"node_modules/@prisma/client": {
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.2.tgz",
"integrity": "sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"peerDependencies": {
"prisma": "*",
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@prisma/config": {
"version": "6.16.2",
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
"effect": "3.16.12",
"empathic": "2.0.0"
}
},
"node_modules/@prisma/debug": {
"version": "6.16.2",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.16.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.16.2",
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"@prisma/fetch-engine": "6.16.2",
"@prisma/get-platform": "6.16.2"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.16.2",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.16.2",
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"@prisma/get-platform": "6.16.2"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.16.2",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.16.2"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"license": "MIT"
},
"node_modules/@types/bun": {
"version": "1.2.22",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.2.22.tgz",
"integrity": "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.2.22"
}
},
"node_modules/@types/node": {
"version": "24.5.2",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.12.0"
}
},
"node_modules/@types/react": {
"version": "19.1.14",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/bun-types": {
"version": "1.2.22",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"peerDependencies": {
"@types/react": "^19"
}
},
"node_modules/c12": {
"version": "3.1.0",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
"confbox": "^0.2.2",
"defu": "^6.1.4",
"dotenv": "^16.6.1",
"exsolve": "^1.0.7",
"giget": "^2.0.0",
"jiti": "^2.4.2",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^1.0.0",
"pkg-types": "^2.2.0",
"rc9": "^2.1.2"
},
"peerDependencies": {
"magicast": "^0.3.5"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/citty": {
"version": "0.1.6",
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/confbox": {
"version": "0.2.2",
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/defu": {
"version": "6.1.4",
"license": "MIT"
},
"node_modules/destr": {
"version": "2.0.5",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/effect": {
"version": "3.16.12",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"fast-check": "^3.23.1"
}
},
"node_modules/empathic": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/exsolve": {
"version": "1.0.7",
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^6.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/giget": {
"version": "2.0.0",
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.0",
"defu": "^6.1.4",
"node-fetch-native": "^1.6.6",
"nypm": "^0.6.0",
"pathe": "^2.0.3"
},
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/hono": {
"version": "4.9.9",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/jiti": {
"version": "2.6.0",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"license": "MIT"
},
"node_modules/nypm": {
"version": "0.6.2",
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.2",
"pathe": "^2.0.3",
"pkg-types": "^2.3.0",
"tinyexec": "^1.0.1"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
"node": "^14.16.0 || >=16.10.0"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"license": "MIT"
},
"node_modules/pkg-types": {
"version": "2.3.0",
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/prisma": {
"version": "6.16.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.16.2",
"@prisma/engines": "6.16.2"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=18.18"
},
"peerDependencies": {
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/rc9": {
"version": "2.1.2",
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
"destr": "^2.0.3"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/tinyexec": {
"version": "1.0.1",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.12.0",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -1,22 +0,0 @@
{
"name": "concord-server",
"scripts": {
"dev": "bun run --hot src/index.ts"
},
"dependencies": {
"@hono/standard-validator": "^0.1.5",
"@hono/zod-validator": "^0.7.3",
"@prisma/client": "^6.16.2",
"@scalar/hono-api-reference": "^0.9.19",
"@socket.io/bun-engine": "^0.0.3",
"hono": "^4.9.9",
"hono-openapi": "^1.1.0",
"prisma": "^6.16.2",
"socket.io": "^4.8.1",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/bun": "latest",
"prisma-zod-generator": "^1.22.1"
}
}

View File

@@ -1,135 +0,0 @@
generator client {
provider = "prisma-client-js"
}
generator zod {
provider = "prisma-zod-generator"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Instance {
id String @id @default(uuid(7))
name String
icon String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Role Role[]
Category Category[]
}
model User {
id String @id @default(uuid(7))
username String
nickname String?
bio String?
picture String?
banner String?
admin Boolean
status String // online/offline/dnd/idle/invis
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Role Role[]
UserAuth UserAuth?
Message Message[]
MessagePing MessagePing[]
}
model Role {
User User @relation(fields: [userId], references: [id])
userId String
Instance Instance @relation(fields: [instanceId], references: [id])
instanceId String
type String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, instanceId])
}
model UserAuth {
User User @relation(fields: [userId], references: [id])
userId String
password String // HASHED PASSWORD AS STRING USING SHA-256
token String? // current user token
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId])
}
model Category {
id String @id @default(uuid(7))
Instance Instance? @relation(fields: [instanceId], references: [id])
instanceId String?
name String
position Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Channel Channel[]
}
model Channel {
id String @id @default(uuid(7))
type String
Category Category? @relation(fields: [categoryId], references: [id])
categoryId String?
name String
description String
pinnedId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Message Message[]
ChannelPin ChannelPin?
}
model ChannelPin {
messageId String @unique
channelId String @unique
Message Message @relation(fields: [messageId], references: [id])
Channel Channel @relation(fields: [channelId], references: [id])
createdAt DateTime @default(now())
@@unique([messageId, channelId])
}
model Message {
id String @id @default(uuid(7))
Channel Channel @relation(fields: [channelId], references: [id])
channelId String
User User @relation(fields: [userId], references: [id])
userId String
deleted Boolean
text String @db.VarChar(2000)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
replies Reply? @relation("MessageToReply")
repliedTo Reply? @relation("ReplyToMessage")
MessagePing MessagePing[]
ChannelPin ChannelPin?
}
model Reply {
message Message @relation("MessageToReply", fields: [messageId], references: [id]) //message text
messageId String @unique //message id of the reply
repliesTo Message @relation("ReplyToMessage", fields: [repliesToId], references: [id]) //message id that this message replies to
repliesToId String @unique //replies to this message id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([messageId, repliesToId])
}
model MessagePing {
Message Message @relation(fields: [messageId], references: [id])
messageId String
PingsUser User @relation(fields: [pingsUserId], references: [id])
pingsUserId String
createdAt DateTime @default(now())
@@unique([messageId, pingsUserId])
}

View File

@@ -1,40 +0,0 @@
import {
createCategory,
getCategory,
getCategoriesByInstance,
updateCategory,
deleteCategory,
deleteAllCategoriesFromInstance,
} from "../services/channelService";
import {
CreateCategoryInput,
UpdateCategoryInput,
DeleteCategoryInput,
DeleteCategoriesByInstanceIdInput,
} from "../validators/categoryValidator";
export async function createNewCategory(data: CreateCategoryInput) {
return await createCategory(data);
}
export async function fetchCategoryData(id: string) {
return await getCategory(id);
}
export async function fetchCategoriesByInstance(instanceId: string) {
return await getCategoriesByInstance(instanceId);
}
export async function updateExistingCategory(data: UpdateCategoryInput) {
return await updateCategory(data);
}
export async function deleteExistingCategory(data: DeleteCategoryInput) {
return await deleteCategory(data);
}
export async function deleteAllCategoriesByInstance(
data: DeleteCategoriesByInstanceIdInput,
) {
return await deleteAllCategoriesFromInstance(data);
}

View File

@@ -1,40 +0,0 @@
import {
createChannel,
getChannel,
getChannelsByCategory,
updateChannel,
deleteChannel,
deleteAllChannelsFromCategory,
} from "../services/channelService";
import {
CreateChannelInput,
UpdateChannelInput,
DeleteChannelInput,
DeleteChannelsByCategoryIdInput,
} from "../validators/channelValidator";
export async function createNewChannel(data: CreateChannelInput) {
return await createChannel(data);
}
export async function fetchChannelData(id: string) {
return await getChannel(id);
}
export async function fetchChannelsByCategory(categoryId: string) {
return await getChannelsByCategory(categoryId);
}
export async function updateExistingChannel(data: UpdateChannelInput) {
return await updateChannel(data);
}
export async function deleteExistingChannel(data: DeleteChannelInput) {
return await deleteChannel(data);
}
export async function deleteAllChannelsByCategory(
data: DeleteChannelsByCategoryIdInput,
) {
return await deleteAllChannelsFromCategory(data);
}

View File

@@ -1,10 +0,0 @@
import { createInstance, getAllInstances } from "../services/instanceService";
import { CreateInstanceRequest } from "../validators/instanceValidator";
export async function createInstanceReq(data: CreateInstanceRequest) {
return await createInstance(data);
}
export async function getAllInstancesReq() {
return await getAllInstances();
}

View File

@@ -1,35 +0,0 @@
import {
editMessage,
getMessageInformation,
getMessagesBefore,
sendMessageToChannel,
} from "../services/messageService";
import { PutMessage } from "../validators/messageValidator";
export async function fetchMessageData(id: string) {
return await getMessageInformation(id);
}
export async function fetchMessagesBefore(date: string, channelId: string) {
return getMessagesBefore(date, channelId);
}
export async function sendMessage(
channelId: string,
userId: string,
content: string,
token: string,
repliedMessageId: string | null,
) {
return await sendMessageToChannel(
channelId,
userId,
content,
token,
repliedMessageId,
);
}
export async function putMessage(data: PutMessage) {
return await editMessage(data);
}

View File

@@ -1,23 +0,0 @@
import {
getAllUsersFrom,
getUserInformation,
createUser,
getUserId,
} from "../services/userService";
import { CreateUserInput } from "../validators/userValidator";
export async function fetchUserData(id: string) {
return await getUserInformation(id);
}
export async function fetchAllUsers(instanceId: string) {
return await getAllUsersFrom(instanceId);
}
export async function createNewUser(data: CreateUserInput) {
return await createUser(data);
}
export async function fetchUserId(username: string) {
return await getUserId(username);
}

View File

@@ -1,5 +0,0 @@
import * as crypto from "crypto";
export default function shaHash(data: string, salt: string): string {
return crypto.createHmac("sha256", salt).update(data).digest("hex");
}

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