Compare commits
10 Commits
0cbb496fab
...
v1/develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
62b3bf1c83
|
|||
|
47a76569c1
|
|||
|
e7fff00001
|
|||
|
|
ccceb77179
|
||
|
|
1d1d28da75
|
||
|
7325fddd45
|
|||
|
2f91713c11
|
|||
|
24a99900b1
|
|||
|
99ade46247
|
|||
|
2edf97bf1c
|
170
.gitignore
vendored
170
.gitignore
vendored
@@ -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
43
README.md
Normal 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...
|
||||
```
|
||||
@@ -1,3 +1,2 @@
|
||||
# deps
|
||||
node_modules/
|
||||
generated/
|
||||
22
apps/api/package.json
Normal file
22
apps/api/package.json
Normal 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
37
apps/api/src/index.ts
Normal 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
10
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@concord/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
3
biome.json
Normal file
3
biome.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["./packages/biome-config/biome.json"]
|
||||
}
|
||||
182
bun.lock
Normal file
182
bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
}
|
||||
26
concord-client/.gitignore
vendored
26
concord-client/.gitignore
vendored
@@ -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?
|
||||
@@ -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
@@ -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": {}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
27
concord-client/electron/electron-env.d.ts
vendored
27
concord-client/electron/electron-env.d.ts
vendored
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
// ...
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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.13",
|
||||
"@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.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"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.13",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/bun": "^1.2.22",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"electron": "^30.0.1",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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;
|
||||
@@ -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 ^
|
||||
@@ -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 |
@@ -1,108 +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) {
|
||||
console.log({
|
||||
channelId: channel.id,
|
||||
currentUser: currentUser.id,
|
||||
token: token,
|
||||
});
|
||||
joinChannel(channel.id, currentUser.id, token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = channel.type === "voice" ? Volume2 : Hash;
|
||||
const connectedUserIds = Array.from(remoteStreams.keys());
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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;
|
||||
@@ -1,134 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Plus, Edit } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-1 py-1 h-6 text-xs font-semibold text-gray-400 uppercase tracking-wide hover:text-gray-300 group"
|
||||
onClick={(e) => {
|
||||
// Only toggle if not right-clicking (which opens dropdown)
|
||||
if (e.button === 0) {
|
||||
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>
|
||||
<Plus
|
||||
size={12}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem>
|
||||
<Plus size={14} className="mr-2" />
|
||||
Create Channel
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Edit size={14} className="mr-2" />
|
||||
Edit Category
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-400 focus:text-red-400">
|
||||
Delete Category
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
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)}
|
||||
/>
|
||||
|
||||
{/* Channels */}
|
||||
{isExpanded && (
|
||||
<div className="ml-2 space-y-0.5">
|
||||
{category.channels
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((channel) => (
|
||||
<ChannelItem key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelList;
|
||||
@@ -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;
|
||||
@@ -1,119 +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";
|
||||
|
||||
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-gray-900 flex items-center justify-center p-4">
|
||||
<div className="max-w-md 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-gray-800 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -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;
|
||||
@@ -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-hidden 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 w-[72px] 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 sidebar-secondary order-l border-sidebar">
|
||||
<MemberList />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -1,104 +0,0 @@
|
||||
import React from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { ChevronDown, Plus, Users } 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 { CreateCategoryModal, CreateChannelModal } from "../server/ServerIcon";
|
||||
|
||||
const ChannelSidebar: React.FC = () => {
|
||||
const { instanceId } = useParams();
|
||||
const { data: instance, isLoading: instanceLoading } =
|
||||
useInstanceDetails(instanceId);
|
||||
const categories = instance?.categories;
|
||||
const {
|
||||
toggleMemberList,
|
||||
showMemberList,
|
||||
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-4 py-3">
|
||||
<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 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 justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="interactive-hover"
|
||||
onClick={openCreateChannel}
|
||||
>
|
||||
<Plus size={16} className="mr-1" />
|
||||
Add Channel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 ${showMemberList ? "text-interactive-active" : "interactive-hover"}`}
|
||||
onClick={toggleMemberList}
|
||||
>
|
||||
<Users size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<CreateChannelModal />
|
||||
<CreateCategoryModal />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelSidebar;
|
||||
@@ -1,217 +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 } from "@/types/database";
|
||||
import { useInstanceMembers } from "@/hooks/useServers";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { User } from "@/types/database";
|
||||
|
||||
// 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";
|
||||
}
|
||||
};
|
||||
|
||||
interface MemberItemProps {
|
||||
member: User;
|
||||
instanceId: string;
|
||||
isOwner?: boolean;
|
||||
currentUserRole: "member" | "mod" | "admin";
|
||||
}
|
||||
|
||||
// Get the user's role for this specific instance
|
||||
const getUserRoleForInstance = (roles: Role[], instanceId: string) => {
|
||||
return roles.find((r) => r.instanceId === instanceId)?.role || "member";
|
||||
};
|
||||
|
||||
// Define role colors and priorities
|
||||
const getRoleInfo = (role: string) => {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return { color: "#ff6b6b", priority: 3, name: "Admin" };
|
||||
case "mod":
|
||||
return { color: "#4ecdc4", priority: 2, name: "Moderator" };
|
||||
default:
|
||||
return { color: null, priority: 1, name: "Member" };
|
||||
}
|
||||
};
|
||||
|
||||
const MemberItem: React.FC<MemberItemProps> = ({
|
||||
member,
|
||||
instanceId,
|
||||
isOwner = false,
|
||||
}) => {
|
||||
const userRole = getUserRoleForInstance(member.roles, instanceId || "");
|
||||
const roleInfo = getRoleInfo(userRole);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
|
||||
disabled={member.admin}
|
||||
>
|
||||
<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" />
|
||||
)}
|
||||
{!isOwner && userRole !== "member" && (
|
||||
<Shield
|
||||
size={12}
|
||||
className="flex-shrink-0"
|
||||
style={{ color: roleInfo.color || "var(--background)" }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: roleInfo.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();
|
||||
const { data: members, isLoading } = useInstanceMembers(instanceId);
|
||||
const { user: currentUser } = useAuthStore();
|
||||
|
||||
const currentUserRole = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) return "member";
|
||||
if (currentUser.admin) return "admin";
|
||||
|
||||
const userRole = currentUser.roles.find(
|
||||
(role) => role.instanceId === instanceId,
|
||||
);
|
||||
return userRole?.role || "member";
|
||||
}, [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</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group members by role
|
||||
const groupedMembers = members.reduce(
|
||||
(acc, member) => {
|
||||
const userRole =
|
||||
member.roles.find((r) => r.instanceId === instanceId)?.role || "member";
|
||||
const roleInfo = getRoleInfo(userRole);
|
||||
|
||||
if (!acc[roleInfo.name]) {
|
||||
acc[roleInfo.name] = [];
|
||||
}
|
||||
acc[roleInfo.name].push(member as User);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, User[]>,
|
||||
);
|
||||
|
||||
// Sort role groups by priority (admin > mod > member)
|
||||
const sortedRoleGroups = Object.entries(groupedMembers).sort(
|
||||
([roleNameA], [roleNameB]) => {
|
||||
const priorityA = getRoleInfo(roleNameA.toLowerCase())?.priority || 1;
|
||||
const priorityB = getRoleInfo(roleNameB.toLowerCase())?.priority || 1;
|
||||
return priorityB - priorityA;
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 border-l border-concord-primary h-full bg-concord-secondary">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-concord-primary flex items-center justify-between">
|
||||
<UserIcon size={20} className="text-concord-primary h-8" />
|
||||
<p className="text-sm font-semibold text-concord-secondary tracking-wide">
|
||||
{members.length} Members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Member List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-2">
|
||||
{sortedRoleGroups.map(([roleName, roleMembers]) => (
|
||||
<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">
|
||||
{roleMembers
|
||||
.sort((a, b) => a.username.localeCompare(b.username))
|
||||
.map((member) => (
|
||||
<MemberItem
|
||||
key={member.id}
|
||||
member={member}
|
||||
instanceId={instanceId}
|
||||
currentUserRole={currentUserRole}
|
||||
isOwner={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberList;
|
||||
@@ -1,144 +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 py-2 space-y-2">
|
||||
{/* Home/DM Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-12 h-12 ml-0 rounded-2xl hover:rounded-xl transition-all duration-200 ${
|
||||
!instanceId || instanceId === "@me"
|
||||
? "bg-primary text-primary-foreground rounded-xl"
|
||||
: "hover:bg-primary/10"
|
||||
}`}
|
||||
onClick={handleHomeClick}
|
||||
>
|
||||
<Home size={24} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{isGlobalAdmin(currentUser) ? "Admin Dashboard" : "Home"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-8 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}
|
||||
</div>
|
||||
|
||||
{/* Add Server Button - Only show if user can create servers */}
|
||||
{canCreateServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 ml-3 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>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerSidebar;
|
||||
@@ -1,259 +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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useLogout } from "@/hooks/useAuth";
|
||||
import { useVoiceStore } from "@/stores/voiceStore";
|
||||
|
||||
// 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";
|
||||
}
|
||||
};
|
||||
|
||||
// User Status Dropdown Component
|
||||
interface UserStatusDropdownProps {
|
||||
currentStatus: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const UserStatusDropdown: React.FC<UserStatusDropdownProps> = ({
|
||||
// currentStatus,
|
||||
onStatusChange,
|
||||
children,
|
||||
}) => {
|
||||
const { mutate: logout } = useLogout();
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "online", label: "Online", color: "bg-status-online" },
|
||||
{ value: "away", label: "Away", color: "bg-status-away" },
|
||||
{ value: "busy", label: "Do Not Disturb", color: "bg-status-busy" },
|
||||
{ value: "offline", label: "Invisible", color: "bg-status-offline" },
|
||||
];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="checkchekchek" asChild>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-48">
|
||||
{statusOptions.map((status) => (
|
||||
<DropdownMenuItem
|
||||
key={status.value}
|
||||
onClick={() => onStatusChange(status.value)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-3 h-3 rounded-full ${status.color}`} />
|
||||
<span>{status.label}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => useUiStore.getState().openUserSettings()}
|
||||
>
|
||||
<Settings size={16} className="mr-2" />
|
||||
User Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => logout()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Log Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
// Voice Controls Component
|
||||
interface VoiceControlsProps {
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
onMuteToggle: () => void;
|
||||
onDeafenToggle: () => void;
|
||||
onSettingsClick: () => void;
|
||||
}
|
||||
|
||||
const VoiceControls: React.FC<VoiceControlsProps> = ({
|
||||
isMuted,
|
||||
isDeafened,
|
||||
onMuteToggle,
|
||||
onDeafenToggle,
|
||||
onSettingsClick,
|
||||
}) => {
|
||||
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 */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 interactive-hover"
|
||||
onClick={onSettingsClick}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>User Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
// Main UserPanel Component
|
||||
const UserPanel: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const { openUserSettings } = useUiStore();
|
||||
|
||||
const { isConnected, isMuted, isDeafened, toggleMute, toggleDeafen } =
|
||||
useVoiceStore();
|
||||
|
||||
const handleStatusChange = (newStatus: string) => {
|
||||
console.log("Status change to:", newStatus);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
|
||||
{/* User Info with Dropdown */}
|
||||
<UserStatusDropdown
|
||||
currentStatus={user?.status as string}
|
||||
onStatusChange={handleStatusChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-1 flex items-center h-auto p-1 rounded-md hover:bg-concord-secondary"
|
||||
>
|
||||
<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>
|
||||
</Button>
|
||||
</UserStatusDropdown>
|
||||
|
||||
{/* Voice Controls */}
|
||||
{isConnected && (
|
||||
<VoiceControls
|
||||
isMuted={isMuted}
|
||||
isDeafened={isDeafened}
|
||||
onMuteToggle={toggleMute}
|
||||
onDeafenToggle={toggleDeafen}
|
||||
onSettingsClick={openUserSettings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPanel;
|
||||
@@ -1,254 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import Avatar from "@/components/common/Avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Edit, Trash2, Reply, MoreHorizontal } from "lucide-react";
|
||||
import { Message, User } from "@/types/database";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface MessageProps {
|
||||
message: Message;
|
||||
user: any;
|
||||
currentUser?: any;
|
||||
isGrouped?: boolean | null;
|
||||
onEdit?: (messageId: string) => void;
|
||||
onDelete?: (messageId: string) => void;
|
||||
onReply?: (messageId: string) => void;
|
||||
replyTo?: Message | null;
|
||||
replyToUser?: User | null;
|
||||
}
|
||||
|
||||
const MessageComponent: React.FC<MessageProps> = ({
|
||||
message,
|
||||
user,
|
||||
currentUser,
|
||||
isGrouped = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
|
||||
};
|
||||
|
||||
const renderContent = (content: string) => {
|
||||
// Simple code block detection
|
||||
const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = codeBlockRegex.exec(content)) !== null) {
|
||||
// Add text before code block
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(
|
||||
<span key={lastIndex}>{content.slice(lastIndex, match.index)}</span>,
|
||||
);
|
||||
}
|
||||
|
||||
// Add code block
|
||||
const language = match[1] || "text";
|
||||
const code = match[2];
|
||||
parts.push(
|
||||
<div key={match.index} className="my-2">
|
||||
<div className="bg-concord-tertiary rounded-md p-3 border border-border">
|
||||
<div className="text-xs text-concord-secondary mb-2 font-mono">
|
||||
{language}
|
||||
</div>
|
||||
<pre className="text-sm font-mono text-concord-primary overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
lastIndex = codeBlockRegex.lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(<span key={lastIndex}>{content.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : content;
|
||||
};
|
||||
|
||||
const isOwnMessage = currentUser?.id === message.userId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative px-4 py-2 hover:bg-concord-secondary/50 transition-colors ${
|
||||
isGrouped ? "mt-0.5" : "mt-4"
|
||||
}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Avatar - only show if not grouped */}
|
||||
<div className="w-10 flex-shrink-0">
|
||||
{!isGrouped && <Avatar user={user} size="md" showStatus={true} />}
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header - only show if not grouped */}
|
||||
{!isGrouped && (
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-semibold text-concord-primary">
|
||||
{user.nickname || user.username}
|
||||
</span>
|
||||
<span className="text-xs text-concord-secondary">
|
||||
{formatTimestamp(message.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message content */}
|
||||
<div className="text-concord-primary leading-relaxed">
|
||||
{renderContent(message.content)}
|
||||
</div>
|
||||
|
||||
{/* Reactions */}
|
||||
</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>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 interactive-hover"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(message.content)}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Text
|
||||
</DropdownMenuItem>
|
||||
{isOwnMessage && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => onEdit?.(message.id)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit Message
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete?.(message.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Message
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Message Input Component
|
||||
interface MessageInputProps {
|
||||
channelName?: string;
|
||||
onSendMessage: (content: string) => void;
|
||||
replyingTo?: Message;
|
||||
onCancelReply?: () => void;
|
||||
}
|
||||
|
||||
const MessageInput: React.FC<MessageInputProps> = ({
|
||||
channelName,
|
||||
onSendMessage,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
}) => {
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim()) {
|
||||
onSendMessage(content.trim());
|
||||
setContent("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
{replyingTo && (
|
||||
<div className="mb-2 p-2 bg-concord-tertiary rounded-t-lg border border-b-0 border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-concord-secondary">
|
||||
Replying to {replyingTo.userId}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-1"
|
||||
onClick={onCancelReply}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-concord-primary truncate">
|
||||
{replyingTo.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Message #${channelName || "channel"}`}
|
||||
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"
|
||||
rows={1}
|
||||
style={{
|
||||
minHeight: "44px",
|
||||
maxHeight: "200px",
|
||||
resize: "none",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
|
||||
Press Enter to send
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { type MessageProps, MessageComponent, MessageInput };
|
||||
@@ -1,149 +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";
|
||||
|
||||
interface CreateChannelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
instanceId: string; // Changed to use instanceId instead of categories prop
|
||||
defaultCategoryId?: string;
|
||||
}
|
||||
|
||||
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
defaultCategoryId,
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState(defaultCategoryId || "");
|
||||
|
||||
const createChannelMutation = useCreateChannel();
|
||||
|
||||
// Reset form when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId(defaultCategoryId || "");
|
||||
}
|
||||
}, [isOpen, defaultCategoryId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !categoryId) return;
|
||||
|
||||
try {
|
||||
await createChannelMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
type,
|
||||
categoryId,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId(defaultCategoryId || "");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create channel:", error);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="space-y-2">
|
||||
<Label>Channel Type</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === "text" ? "default" : "outline"}
|
||||
onClick={() => setType("text")}
|
||||
className="flex-1"
|
||||
>
|
||||
<Hash className="h-4 w-4 mr-2" />
|
||||
Text
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === "voice" ? "default" : "outline"}
|
||||
onClick={() => setType("voice")}
|
||||
className="flex-1"
|
||||
>
|
||||
<Volume2 className="h-4 w-4 mr-2" />
|
||||
Voice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
!categoryId ||
|
||||
createChannelMutation.isPending ||
|
||||
categoryId === "loading" ||
|
||||
categoryId === "no-categories"
|
||||
}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Reply } from "lucide-react";
|
||||
import { Message } from "@/lib/api-client";
|
||||
|
||||
interface MessageActionsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
message: Message;
|
||||
isOwnMessage: boolean;
|
||||
onReply?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
message,
|
||||
onReply,
|
||||
}) => {
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[300px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message Actions</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onReply?.(message.id))}
|
||||
>
|
||||
<Reply className="h-4 w-4 mr-2" />
|
||||
Reply
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() =>
|
||||
handleAction(() => navigator.clipboard.writeText(message.text))
|
||||
}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Text
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,387 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Instance } from "@/types/database";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "../ui/dialog";
|
||||
import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog";
|
||||
import { Label } from "../ui/label";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Category } from "@/types";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import {
|
||||
useCreateCategory,
|
||||
useCreateChannel,
|
||||
useCreateInstance,
|
||||
} from "@/hooks/useServers";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
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">
|
||||
{/* Active indicator */}
|
||||
<div
|
||||
className={`absolute left-0 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 ml-3 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>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Server Modal
|
||||
export const CreateServerModal: React.FC = () => {
|
||||
const { showCreateServer, closeCreateServer } = useUiStore();
|
||||
const { mutate: createInstance, isPending } = useCreateInstance();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
createInstance(
|
||||
{
|
||||
name: name.trim(),
|
||||
icon: icon.trim() || undefined,
|
||||
// description: description.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
closeCreateServer();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create server:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
closeCreateServer();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showCreateServer} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new server to chat with friends and communities.
|
||||
</DialogDescription>
|
||||
</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="Enter server name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="server-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this server about?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-icon">Server Icon URL (Optional)</Label>
|
||||
<Input
|
||||
id="server-icon"
|
||||
type="url"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="https://example.com/icon.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Server"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Channel Modal
|
||||
export const CreateChannelModal: React.FC = () => {
|
||||
const { showCreateChannel, closeCreateChannel } = useUiStore();
|
||||
const { mutate: createChannel, isPending } = useCreateChannel();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
|
||||
// You'd need to get categories for the current instance
|
||||
// This is a simplified version
|
||||
const categories: Category[] = []; // Get from context or props
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && categoryId) {
|
||||
createChannel(
|
||||
{
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
type,
|
||||
categoryId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId("");
|
||||
closeCreateChannel();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create channel:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId("");
|
||||
closeCreateChannel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showCreateChannel} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new text or voice channel in this server.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-type">Channel Type</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value: "text" | "voice") => setType(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channel type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text Channel</SelectItem>
|
||||
<SelectItem value="voice">Voice Channel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-category">Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<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="Enter channel name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="channel-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this channel for?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Category Modal
|
||||
export const CreateCategoryModal: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutate: createCategory, isPending } = useCreateCategory();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [instanceId, setInstanceId] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && instanceId) {
|
||||
createCategory(
|
||||
{
|
||||
name: name.trim(),
|
||||
instanceId,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setInstanceId("");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create category:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setInstanceId("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Category</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new category to organize your channels.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category-name">Category Name</Label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter category name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Category"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminModals: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<CreateServerModal />
|
||||
<CreateChannelModal />
|
||||
<CreateCategoryModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ServerIcon;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,485 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
Palette,
|
||||
Plus,
|
||||
Trash2,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
useTheme,
|
||||
ThemeDefinition,
|
||||
ThemeColors,
|
||||
} from "@/components/theme-provider";
|
||||
|
||||
// Theme color input component for OKLCH values
|
||||
const ColorInput: React.FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ label, value, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor={label} className="text-right text-sm">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={label}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="col-span-3 font-mono text-sm"
|
||||
placeholder="oklch(0.5 0.1 180)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom theme creation modal
|
||||
const CreateThemeModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (theme: Omit<ThemeDefinition, "id" | "isCustom">) => void;
|
||||
}> = ({ isOpen, onClose, onSave }) => {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [mode, setMode] = useState<"light" | "dark">("dark");
|
||||
const [colors, setColors] = useState<ThemeColors>({
|
||||
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)",
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
mode,
|
||||
colors,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setName("");
|
||||
setDescription("");
|
||||
setMode("dark");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const updateColor = (key: keyof ThemeColors, value: string) => {
|
||||
setColors((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Custom Theme</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a custom theme by defining colors in OKLCH format (e.g.,
|
||||
"oklch(0.5 0.1 180)")
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-96">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="theme-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="My Custom Theme"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-description" className="text-right">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="theme-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-mode" className="text-right">
|
||||
Mode
|
||||
</Label>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(v: "light" | "dark") => setMode(v)}
|
||||
>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Color sections */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Basic Colors</h4>
|
||||
<div className="space-y-3">
|
||||
<ColorInput
|
||||
label="Background"
|
||||
value={colors.background}
|
||||
onChange={(v) => updateColor("background", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Foreground"
|
||||
value={colors.foreground}
|
||||
onChange={(v) => updateColor("foreground", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Primary"
|
||||
value={colors.primary}
|
||||
onChange={(v) => updateColor("primary", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Secondary"
|
||||
value={colors.secondary}
|
||||
onChange={(v) => updateColor("secondary", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<h4 className="font-medium">Sidebar Colors</h4>
|
||||
<div className="space-y-3">
|
||||
<ColorInput
|
||||
label="Sidebar"
|
||||
value={colors.sidebar}
|
||||
onChange={(v) => updateColor("sidebar", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Sidebar Primary"
|
||||
value={colors.sidebarPrimary}
|
||||
onChange={(v) => updateColor("sidebarPrimary", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Sidebar Accent"
|
||||
value={colors.sidebarAccent}
|
||||
onChange={(v) => updateColor("sidebarAccent", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!name.trim()}>
|
||||
Create Theme
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Main theme selector component as modal
|
||||
export function ThemeSelector() {
|
||||
const {
|
||||
mode,
|
||||
currentTheme,
|
||||
setMode,
|
||||
setTheme,
|
||||
addCustomTheme,
|
||||
removeCustomTheme,
|
||||
getThemesForMode,
|
||||
} = useTheme();
|
||||
|
||||
const [isMainModalOpen, setIsMainModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const lightThemes = getThemesForMode("light");
|
||||
const darkThemes = getThemesForMode("dark");
|
||||
|
||||
const getCurrentModeIcon = () => {
|
||||
switch (mode) {
|
||||
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 (
|
||||
<>
|
||||
<Dialog open={isMainModalOpen} onOpenChange={setIsMainModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Theme
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{getCurrentModeIcon()}
|
||||
Appearance Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose your preferred theme and color scheme
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Current Theme Display */}
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{currentTheme.name}</p>
|
||||
{currentTheme.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentTheme.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
</div>
|
||||
|
||||
{/* Mode Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Display Mode</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={mode === "light" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMode("light")}
|
||||
className="flex flex-col gap-1 h-auto py-3"
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
<span className="text-xs">Light</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMode("dark")}
|
||||
className="flex flex-col gap-1 h-auto py-3"
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
<span className="text-xs">Dark</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "system" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMode("system")}
|
||||
className="flex flex-col gap-1 h-auto py-3"
|
||||
>
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span className="text-xs">System</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Selection */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Themes</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="space-y-3">
|
||||
{/* Light Themes */}
|
||||
{lightThemes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Light Themes
|
||||
</p>
|
||||
{lightThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => setTheme(theme.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{theme.name}
|
||||
</p>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{theme.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTheme.id === theme.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{theme.isCustom && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeCustomTheme(theme.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dark Themes */}
|
||||
{darkThemes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{lightThemes.length > 0 && <Separator />}
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Dark Themes
|
||||
</p>
|
||||
{darkThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => setTheme(theme.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{theme.name}
|
||||
</p>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{theme.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTheme.id === theme.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{theme.isCustom && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeCustomTheme(theme.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsMainModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreateThemeModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSave={addCustomTheme}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
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,
|
||||
};
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,313 +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
|
||||
console.log(
|
||||
"Editing message:",
|
||||
data.messageId,
|
||||
"New content:",
|
||||
data.content,
|
||||
);
|
||||
|
||||
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
|
||||
console.log(
|
||||
`${data.pinned ? "Pinning" : "Unpinning"} message:`,
|
||||
data.messageId,
|
||||
);
|
||||
|
||||
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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,357 +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> {
|
||||
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();
|
||||
@@ -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();
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,33 +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";
|
||||
|
||||
function printPayload(data: unknown) {
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
const socket = io("http://localhost:3000");
|
||||
socket.on("connect", () => {
|
||||
console.log("connected!");
|
||||
socket.emit("ping", "world");
|
||||
});
|
||||
socket.on("pong", () => {
|
||||
console.log("pong");
|
||||
});
|
||||
|
||||
socket.on("joined-voicechannel", printPayload);
|
||||
socket.on("user-joined-voicechannel", printPayload);
|
||||
socket.on("error-voicechannel", printPayload);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App socket={socket} />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
// Use contextBridge
|
||||
window.ipcRenderer.on("main-process-message", (_event, message) => {
|
||||
console.log(message);
|
||||
});
|
||||
@@ -1,727 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import {
|
||||
Hash,
|
||||
Volume2,
|
||||
Users,
|
||||
Pin,
|
||||
MoreHorizontal,
|
||||
Reply,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow, isValid, parseISO } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
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";
|
||||
|
||||
// Updated imports for API integration
|
||||
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
|
||||
import {
|
||||
useChannelMessages,
|
||||
useLoadMoreMessages,
|
||||
useSendMessage,
|
||||
} from "@/hooks/useMessages";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { Message } from "@/lib/api-client";
|
||||
|
||||
// Modal imports
|
||||
import { MessageActionsModal } from "@/components/modals/MessageActionsModal";
|
||||
|
||||
// User type for message component
|
||||
interface MessageUser {
|
||||
id: string;
|
||||
username?: string;
|
||||
userName?: string;
|
||||
nickname?: string | null;
|
||||
nickName?: string | null;
|
||||
picture?: string | null;
|
||||
}
|
||||
|
||||
// Message Props interface
|
||||
interface MessageProps {
|
||||
message: Message;
|
||||
user: MessageUser;
|
||||
currentUser: any;
|
||||
replyTo?: Message;
|
||||
replyToUser?: MessageUser;
|
||||
onReply?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
const MessageComponent: React.FC<MessageProps> = ({
|
||||
message,
|
||||
user,
|
||||
currentUser,
|
||||
replyTo,
|
||||
replyToUser,
|
||||
onReply,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [showActionsModal, setShowActionsModal] = 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.nickName ||
|
||||
replyToUser.username ||
|
||||
replyToUser.userName}
|
||||
</span>
|
||||
<span className="truncate max-w-xs opacity-75">
|
||||
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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.nickName ||
|
||||
replyToUser.username ||
|
||||
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-yellow-500" />
|
||||
)}
|
||||
</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"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 interactive-hover"
|
||||
onClick={() => setShowActionsModal(true)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Actions Modal */}
|
||||
<MessageActionsModal
|
||||
isOpen={showActionsModal}
|
||||
onClose={() => setShowActionsModal(false)}
|
||||
message={message}
|
||||
isOwnMessage={isOwnMessage}
|
||||
onReply={onReply}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Message Input Component
|
||||
interface MessageInputProps {
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
replyingTo?: Message | null;
|
||||
onCancelReply?: () => void;
|
||||
replyingToUser: MessageUser | null;
|
||||
}
|
||||
|
||||
const MessageInput: React.FC<MessageInputProps> = ({
|
||||
channelId,
|
||||
channelName,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
replyingToUser,
|
||||
}) => {
|
||||
const [content, setContent] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
// Use the API hook for sending messages
|
||||
const sendMessageMutation = useSendMessage();
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.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-4 pb-4">
|
||||
{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.nickName ||
|
||||
replyingToUser.username ||
|
||||
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}>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
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"
|
||||
style={{
|
||||
minHeight: "44px",
|
||||
maxHeight: "200px",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
|
||||
{sendMessageMutation.isPending
|
||||
? "Sending..."
|
||||
: "Press Enter to send • Shift+Enter for new line"}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatPage: React.FC = () => {
|
||||
const { instanceId, channelId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL LOGIC
|
||||
// API hooks - called unconditionally
|
||||
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);
|
||||
|
||||
// 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(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [channelMessages]);
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
// 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-4 py-3 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-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-hidden">
|
||||
{/* Messages Area */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{/* Load More Button */}
|
||||
{channelMessages && channelMessages.length > 0 && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary mr-2"></div>
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isLoadingMore ? "Loading..." : "Load older messages"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{currentChannel?.description && (
|
||||
<div className="text-concord-secondary bg-concord-secondary/50 p-3 rounded border-l-4 border-primary">
|
||||
{currentChannel.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pb-4">
|
||||
{/* Messages */}
|
||||
{sortedMessages && sortedMessages.length > 0 ? (
|
||||
<div>
|
||||
{sortedMessages.map((message) => {
|
||||
console.log(message);
|
||||
const user = users?.find((u) => u.id === message.userId);
|
||||
const replyToMessage = channelMessages?.find(
|
||||
(m) => m.id === (message as any).repliedMessageId,
|
||||
);
|
||||
const replyToUser = replyToMessage
|
||||
? users?.find((u) => u.id === replyToMessage.userId)
|
||||
: undefined;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<MessageComponent
|
||||
key={message.id}
|
||||
message={message}
|
||||
user={user}
|
||||
currentUser={currentUser}
|
||||
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>
|
||||
</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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,935 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
Palette,
|
||||
User,
|
||||
Shield,
|
||||
Mic,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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 { ThemeSelector } from "@/components/theme-selector";
|
||||
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: "security",
|
||||
// title: "Security",
|
||||
// icon: Lock,
|
||||
// description: "Password and security 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 SecuritySettings: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
const [passwordSuccess, setPasswordSuccess] = useState("");
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordError("");
|
||||
setPasswordSuccess("");
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setPasswordError("All password fields are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError("New passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordError("New password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingPassword(true);
|
||||
|
||||
try {
|
||||
// TODO: Implement actual password change API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
|
||||
|
||||
console.log("Changing password for user:", user?.id);
|
||||
// const result = await authClient.changePassword({
|
||||
// userId: user.id,
|
||||
// currentPassword,
|
||||
// newPassword,
|
||||
// token: authStore.token
|
||||
// });
|
||||
|
||||
setPasswordSuccess("Password changed successfully");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (error) {
|
||||
setPasswordError(
|
||||
"Failed to change password. Please check your current password.",
|
||||
);
|
||||
} finally {
|
||||
setIsChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5" />
|
||||
Change Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{passwordError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{passwordError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{passwordSuccess && (
|
||||
<Alert>
|
||||
<AlertDescription>{passwordSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="current-password">Current Password</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="max-w-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="max-w-sm"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="max-w-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isChangingPassword ||
|
||||
!currentPassword ||
|
||||
!newPassword ||
|
||||
!confirmPassword
|
||||
}
|
||||
>
|
||||
{isChangingPassword
|
||||
? "Changing Password..."
|
||||
: "Change Password"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Security Options
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Additional security features for your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Two-Factor Authentication
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add an extra layer of security to your account
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={twoFactorEnabled}
|
||||
onCheckedChange={setTwoFactorEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-medium">Active Sessions</Label>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Manage devices that are currently logged into your account
|
||||
</p>
|
||||
<Button variant="outline" size="sm">
|
||||
View Active Sessions
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-medium">Account Backup</Label>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Download a copy of your account data
|
||||
</p>
|
||||
<Button variant="outline" size="sm">
|
||||
Request Data Export
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountSettings: React.FC = () => {
|
||||
const { user, updateUser } = 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
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
|
||||
|
||||
console.log("Updating profile:", { username, nickname, bio });
|
||||
// const updatedUser = await userClient.updateProfile({
|
||||
// userId: user.id,
|
||||
// username: username.trim(),
|
||||
// nickname: nickname.trim() || null,
|
||||
// bio: bio.trim() || null,
|
||||
// token: authStore.token
|
||||
// });
|
||||
|
||||
// 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-2/3">
|
||||
<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>
|
||||
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Privacy
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Control who can contact you and see your information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Allow Direct Messages
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Let other users send you direct messages
|
||||
</p>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Show Online Status
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display when you're online to other users
|
||||
</p>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Appearance Settings Component (keeping existing implementation)
|
||||
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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Quick Theme Selector
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access the theme selector with custom theme creation
|
||||
</p>
|
||||
</div>
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
// Voice Settings Component
|
||||
const VoiceSettings: React.FC = () => {
|
||||
const [inputVolume, setInputVolume] = useState(75);
|
||||
const [outputVolume, setOutputVolume] = useState(100);
|
||||
const [pushToTalk, setPushToTalk] = useState(false);
|
||||
const [noiseSuppression, setNoiseSuppression] = useState(true);
|
||||
const [echoCancellation, setEchoCancellation] = useState(true);
|
||||
const [autoGainControl, setAutoGainControl] = useState(true);
|
||||
|
||||
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 />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Push to Talk</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use a key to transmit voice instead of voice activity
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={pushToTalk} onCheckedChange={setPushToTalk} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Audio Processing</CardTitle>
|
||||
<CardDescription>
|
||||
Advanced audio processing features to improve call quality.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Noise Suppression</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reduce background noise during calls
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={noiseSuppression}
|
||||
onCheckedChange={setNoiseSuppression}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Echo Cancellation</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Prevent audio feedback and echo
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={echoCancellation}
|
||||
onCheckedChange={setEchoCancellation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Auto Gain Control</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically adjust microphone sensitivity
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoGainControl}
|
||||
onCheckedChange={setAutoGainControl}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { section } = useParams();
|
||||
const currentSection = section || "account";
|
||||
|
||||
const renderSettingsContent = () => {
|
||||
switch (currentSection) {
|
||||
case "account":
|
||||
return <AccountSettings />;
|
||||
case "security":
|
||||
return <SecuritySettings />;
|
||||
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"}
|
||||
className="w-full justify-start mb-1 h-auto p-2"
|
||||
asChild
|
||||
>
|
||||
<a href={`/settings/${settingsSection.id}`}>
|
||||
<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" />
|
||||
</a>
|
||||
</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">
|
||||
<div className="p-6 flex w-full">{renderSettingsContent()}</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,341 +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 => {
|
||||
console.log(`Creating peer connection for: ${targetUserId}`);
|
||||
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) => {
|
||||
console.log(`Received remote track from: ${targetUserId}`);
|
||||
set((state) => {
|
||||
const newStreams = new Map(state.remoteStreams);
|
||||
newStreams.set(targetUserId, event.streams[0]);
|
||||
return { remoteStreams: newStreams };
|
||||
});
|
||||
};
|
||||
|
||||
// For debugging connection state
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log(
|
||||
`Connection state change for ${targetUserId}: ${peerConnection.connectionState}`,
|
||||
);
|
||||
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[];
|
||||
}) => {
|
||||
console.log(
|
||||
"Successfully joined voice channel. Users:",
|
||||
data.connectedUserIds,
|
||||
);
|
||||
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 }) => {
|
||||
console.log(`User ${data.userId} left the channel.`);
|
||||
cleanupPeerConnection(data.userId);
|
||||
};
|
||||
|
||||
const onWebRTCOffer = async (data: {
|
||||
senderUserId: string;
|
||||
sdp: RTCSessionDescriptionInit;
|
||||
}) => {
|
||||
console.log("Received WebRTC offer from:", data.senderUserId);
|
||||
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;
|
||||
}) => {
|
||||
console.log("Received WebRTC answer from:", data.senderUserId);
|
||||
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;
|
||||
|
||||
console.log(`Leaving voice channel: ${activeVoiceChannelId}`);
|
||||
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 });
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
1
concord-client/src/vite-env.d.ts
vendored
1
concord-client/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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" }],
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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
|
||||
@@ -1,13 +0,0 @@
|
||||
To install dependencies:
|
||||
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```sh
|
||||
bun run dev
|
||||
```
|
||||
|
||||
open http://localhost:3000
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
390
concord-server/package-lock.json
generated
390
concord-server/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { Server as Engine } from "@socket.io/bun-engine";
|
||||
import { Server } from "socket.io";
|
||||
import routes from "./routes/index";
|
||||
import { Scalar } from "@scalar/hono-api-reference";
|
||||
import { openAPIRouteHandler } from "hono-openapi";
|
||||
import { registerSocketHandlers } from "./sockets";
|
||||
|
||||
// Routes
|
||||
const app = new Hono();
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: ["http://localhost:5173", "https://concord.kpuig.net", "http://localhost:3000"],
|
||||
allowHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"Access-Control-Allow-Origin",
|
||||
],
|
||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.route("/api", routes);
|
||||
|
||||
app.get(
|
||||
"/openapi",
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Hono API",
|
||||
version: "1.0.0",
|
||||
description: "Greeting API",
|
||||
},
|
||||
servers: [{ url: "http://localhost:3000", description: "Local Server" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("/scalar", Scalar({ url: "/openapi" }));
|
||||
|
||||
// initialize socket.io server
|
||||
const io = new Server({
|
||||
cors: {
|
||||
origin: ["http://localhost:5173", "https://concord.kpuig.net", "http://localhost:3000"],
|
||||
credentials: true,
|
||||
},
|
||||
});
|
||||
const engine = new Engine();
|
||||
io.bind(engine);
|
||||
|
||||
// Register socket.io events
|
||||
registerSocketHandlers(io);
|
||||
|
||||
const { websocket } = engine.handler();
|
||||
|
||||
export default {
|
||||
port: 3000,
|
||||
idleTimeout: 30, // must be greater than the "pingInterval" option of the engine, which defaults to 25 seconds
|
||||
|
||||
async fetch(req: Request, server: Bun.Server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === "/socket.io/") {
|
||||
const response = await engine.handleRequest(req, server);
|
||||
// Add CORS headers explicitly
|
||||
const origin = req.headers.get("Origin");
|
||||
if (
|
||||
origin &&
|
||||
["http://localhost:5173", "https://concord.kpuig.net", "http://localhost:3000"].includes(origin)
|
||||
) {
|
||||
response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
}
|
||||
response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||
return response;
|
||||
} else {
|
||||
return app.fetch(req, server);
|
||||
}
|
||||
},
|
||||
|
||||
websocket,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user