diff --git a/CLAUDE.md b/CLAUDE.md index 030e4c0..76a5d33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,8 @@ This is a TypeScript monorepo using a Convex backend and SvelteKit frontend with ### Stack - **Frontend**: SvelteKit with Svelte 5, TypeScript, TailwindCSS, DaisyUI + - **CRITICAL**: This project uses Svelte 5 RUNES MODE - NEVER use legacy reactive statements (`$:`) + - **ALWAYS use**: `$state`, `$derived`, `$effect` instead of legacy syntax - **Backend**: Convex (real-time database and functions) - **Desktop**: Tauri (optional, conflicts with web dev server) - **Internationalization**: Paraglide for i18n (English/Japanese) @@ -124,6 +126,22 @@ When working with Svelte code, always reference the latest documentation: ``` +### Mutations with useMutation + +Since `convex-svelte` doesn't export `useMutation`, we have a custom utility at `src/lib/useMutation.svelte.ts`: + +```typescript +import { useMutation } from "~/lib/useMutation.svelte.ts"; + +const createOrganization = useMutation(api.organizations.create); + +// Use like this +await createOrganization.run({ name: "New Org", description: "..." }); +// which exposes these properties +createOrganization.processing; // boolean, use for button disabled state / loading spinners +createOrganization.error; // string | null, use for error messages +``` + ### Backend (Convex) - **Schema**: Defined in `packages/convex/src/convex/schema.ts` @@ -169,3 +187,15 @@ bun dev:tauri ``` Tauri conflicts with the web development server and requires more resources for compilation. + +## Coding Instructions + +- **🚫 NEVER USE LEGACY SVELTE SYNTAX**: This project uses Svelte 5 runes mode + - ❌ FORBIDDEN: `$: reactiveVar = ...` (reactive statements) + - ❌ FORBIDDEN: `let count = 0` for reactive state + - ✅ REQUIRED: `const reactiveVar = $derived(...)` + - ✅ REQUIRED: `let count = $state(0)` for reactive state + - ✅ REQUIRED: `$effect(() => { ... })` for side effects +- Always prefer using DaisyUI classes, and use minimal Tailwind classes. +- Separate components into smallest pieces for readability. +- Name snippets with camelCase instead of PascalCase to avoid confusion with components. diff --git a/README.md b/README.md index af5e7b9..293d179 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,16 @@ bun dev:tauri const selectedChannel = useQuery(api.channels.get, { id: selectedChannelId }); ``` + +### (client) Icon の使用について + +- unplugin-icons を使っています。 +- Usage Example: `import MdiClose from "~icons/mdi/close"` +- 現在インストールされているアイコンセットは以下のとおりです: + - mdi (Material Design Icons) +- 新規アイコンセットを追加する場合は、`cd packages/client; bun add @iconify-json/[iconset]` で追加できます。 +- icon の一覧はここで見れます。: https://icones.js.org/ + +### 独自命名規則 + +- Snippet の命名は camelCase で行います。 (PascalCase はコンポーネントと混同されるため) diff --git a/biome.jsonc b/biome.jsonc index 9b3aa33..2c47572 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -26,7 +26,6 @@ "noUnusedTemplateLiteral": "error", "useNumberNamespace": "error", "noInferrableTypes": "error", - "noUselessElse": "error", }, "correctness": { "useImportExtensions": { diff --git a/bun.lock b/bun.lock index b9bef02..365738b 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "dependencies": { "@auth/core": "^0.40.0", "@convex-dev/auth": "^0.0.87", + "@iconify-json/mdi": "^1.2.3", "@inlang/paraglide-js": "^2.2.0", "@mmailaender/convex-auth-svelte": "^0.0.2", "@packages/convex": "workspace:*", @@ -29,7 +30,9 @@ "@tauri-apps/plugin-opener": "^2.4.0", "convex": "^1.25.4", "convex-svelte": "^0.0.11", + "robot3": "^1.1.1", "runed": "^0.31.0", + "unplugin-icons": "^22.2.0", }, "devDependencies": { "@chromatic-com/storybook": "^4.0.1", @@ -121,6 +124,10 @@ "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + + "@antfu/utils": ["@antfu/utils@8.1.1", "", {}, "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ=="], + "@auth/core": ["@auth/core@0.40.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw=="], "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], @@ -237,6 +244,12 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], + "@iconify-json/mdi": ["@iconify-json/mdi@1.2.3", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-O3cLwbDOK7NNDf2ihaQOH5F9JglnulNDFV7WprU2dSoZu3h3cWH//h74uQAB87brHmvFVxIOkuBX2sZSzYhScg=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@2.3.0", "", { "dependencies": { "@antfu/install-pkg": "^1.0.0", "@antfu/utils": "^8.1.0", "@iconify/types": "^2.0.0", "debug": "^4.4.0", "globals": "^15.14.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "mlly": "^1.7.4" } }, "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA=="], + "@inlang/paraglide-js": ["@inlang/paraglide-js@2.2.0", "", { "dependencies": { "@inlang/recommend-sherlock": "0.2.1", "@inlang/sdk": "2.4.9", "commander": "11.1.0", "consola": "3.4.0", "json5": "2.2.3", "unplugin": "^2.1.2", "urlpattern-polyfill": "^10.0.0" }, "bin": { "paraglide-js": "bin/run.js" } }, "sha512-pkpXu1LanvpcAbvpVPf7PgF11Uq7DliSEBngrcUN36l4ZOOpzn3QBTvVr/tJxvks0O67WseQgiMHet8KH7Oz5A=="], "@inlang/recommend-sherlock": ["@inlang/recommend-sherlock@0.2.1", "", { "dependencies": { "comment-json": "^4.2.3" } }, "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg=="], @@ -611,6 +624,8 @@ "comment-json": ["comment-json@4.2.5", "", { "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", "esprima": "^4.0.1", "has-own-prop": "^2.0.0", "repeat-string": "^1.6.1" } }, "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw=="], + "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + "consola": ["consola@3.4.0", "", {}, "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA=="], "convex": ["convex@1.25.4", "", { "dependencies": { "esbuild": "0.25.4", "jwt-decode": "^4.0.0", "prettier": "3.5.3" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-LiGZZTmbe5iHWwDOYfSA00w+uDM8kgLC0ohFJW0VgQlKcs8famHCE6yuplk4wwXyj9Lhb1+yMRfrAD2ZEquqHg=="], @@ -685,6 +700,8 @@ "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], + "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], + "fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], @@ -699,6 +716,8 @@ "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "happy-dom": ["happy-dom@18.0.1", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA=="], @@ -755,6 +774,8 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + "kysely": ["kysely@0.27.6", "", {}, "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ=="], "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], @@ -803,6 +824,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], @@ -837,6 +860,8 @@ "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -855,6 +880,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="], + "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], "pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="], @@ -875,6 +902,8 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pkg-types": ["pkg-types@2.2.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ=="], + "playwright": ["playwright@1.54.1", "", { "dependencies": { "playwright-core": "1.54.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g=="], "playwright-core": ["playwright-core@1.54.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA=="], @@ -895,6 +924,8 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="], + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], @@ -913,6 +944,8 @@ "resend": ["resend@4.7.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-30IbXGBUbmDweQH2IlO53XOXX7ndjYV9xFZ8IEBiWqefqQ/qmTsgrX0Ab6MUnmobJXbpdReVv+iXGRQPubQL5Q=="], + "robot3": ["robot3@1.1.1", "", {}, "sha512-kuD0oQg2KUE74FCQ1a5uoRsEJ/bUKrU1D3vnluop9X7LSiGLndejQgjUEcMqJMVzUA836HSXhtY7XNtQiPTCLQ=="], + "rollup": ["rollup@4.45.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.45.1", "@rollup/rollup-android-arm64": "4.45.1", "@rollup/rollup-darwin-arm64": "4.45.1", "@rollup/rollup-darwin-x64": "4.45.1", "@rollup/rollup-freebsd-arm64": "4.45.1", "@rollup/rollup-freebsd-x64": "4.45.1", "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", "@rollup/rollup-linux-arm-musleabihf": "4.45.1", "@rollup/rollup-linux-arm64-gnu": "4.45.1", "@rollup/rollup-linux-arm64-musl": "4.45.1", "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-musl": "4.45.1", "@rollup/rollup-linux-s390x-gnu": "4.45.1", "@rollup/rollup-linux-x64-gnu": "4.45.1", "@rollup/rollup-linux-x64-musl": "4.45.1", "@rollup/rollup-win32-arm64-msvc": "4.45.1", "@rollup/rollup-win32-ia32-msvc": "4.45.1", "@rollup/rollup-win32-x64-msvc": "4.45.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw=="], "runed": ["runed@0.31.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ=="], @@ -1009,12 +1042,16 @@ "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unplugin": ["unplugin@2.3.5", "", { "dependencies": { "acorn": "^8.14.1", "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" } }, "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw=="], + "unplugin-icons": ["unplugin-icons@22.2.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/utils": "^2.3.0", "debug": "^4.4.1", "local-pkg": "^1.1.1", "unplugin": "^2.3.5" }, "peerDependencies": { "@svgr/core": ">=7.0.0", "@svgx/core": "^1.0.1", "@vue/compiler-sfc": "^3.0.2 || ^2.7.0", "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0", "vue-template-compiler": "^2.6.12", "vue-template-es2015-compiler": "^1.9.0" }, "optionalPeers": ["@svgr/core", "@svgx/core", "@vue/compiler-sfc", "svelte", "vue-template-compiler", "vue-template-es2015-compiler"] }, "sha512-OdrXCiXexC1rFd0QpliAgcd4cMEEEQtoCf2WIrRIGu4iW6auBPpQKMCBeWxoe55phYdRyZLUWNOtzyTX+HOFSA=="], + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], @@ -1047,6 +1084,8 @@ "zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="], + "@antfu/install-pkg/tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@convex-dev/auth/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], @@ -1081,6 +1120,8 @@ "convex/prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="], + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1107,6 +1148,8 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/package.json b/package.json index 4e419ed..d813b59 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,16 @@ { "name": "prism", - "type": "module", + "devDependencies": { + "@biomejs/biome": "^2.1.2", + "lefthook": "^1.12.2", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "prettier-plugin-tailwindcss": "^0.6.14" + }, + "peerDependencies": { + "typescript": "^5.8.3" + }, "private": true, - "workspaces": [ - "packages/*" - ], "scripts": { "dev": "bun run --filter='@packages/{client,convex}' dev", "dev:all": "(trap 'kill 0' EXIT; bun run dev:convex & bun run dev:web & bun run dev:storybook & wait", @@ -24,14 +30,8 @@ "convex": "cd packages/convex && bun run convex", "paraglide": "cd packages/client && bun run paraglide" }, - "peerDependencies": { - "typescript": "^5.8.3" - }, - "devDependencies": { - "@biomejs/biome": "^2.1.2", - "lefthook": "^1.12.2", - "prettier": "^3.6.2", - "prettier-plugin-svelte": "^3.4.0", - "prettier-plugin-tailwindcss": "^0.6.14" - } + "type": "module", + "workspaces": [ + "packages/*" + ] } diff --git a/packages/client/package.json b/packages/client/package.json index 4af5bd4..dedcc02 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -41,15 +41,18 @@ "dependencies": { "@auth/core": "^0.40.0", "@convex-dev/auth": "^0.0.87", + "@iconify-json/mdi": "^1.2.3", + "@inlang/paraglide-js": "^2.2.0", "@mmailaender/convex-auth-svelte": "^0.0.2", "@packages/convex": "workspace:*", - "@inlang/paraglide-js": "^2.2.0", "@playwright/test": "^1.54.1", "@sveltejs/adapter-static": "^3.0.8", "@tauri-apps/api": "^2.6.0", "@tauri-apps/plugin-opener": "^2.4.0", "convex": "^1.25.4", "convex-svelte": "^0.0.11", - "runed": "^0.31.0" + "robot3": "^1.1.1", + "runed": "^0.31.0", + "unplugin-icons": "^22.2.0" } } diff --git a/packages/client/src/app.d.ts b/packages/client/src/app.d.ts index 520c421..989a13c 100644 --- a/packages/client/src/app.d.ts +++ b/packages/client/src/app.d.ts @@ -1,3 +1,6 @@ +/// +/// + // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { diff --git a/packages/client/src/components/app/ChatApp.svelte b/packages/client/src/components/app/ChatApp.svelte new file mode 100644 index 0000000..0b61ed0 --- /dev/null +++ b/packages/client/src/components/app/ChatApp.svelte @@ -0,0 +1,102 @@ + + +
+
+
+
+
+

+ {organization.data?.name || "組織"} +

+ {#if organization.data?.description} +

+ {organization.data.description} +

+ {/if} +
+ +
+ {#if organization.data?.permission} +
+ {organization.data.permission} +
+ {/if} +
+ + channelId, + (id) => { + goto(`/orgs/${organizationId}/chat/${id}`); + } + } + /> +
+ +
+ {#if channelId} + + {:else} +
+
+

+ {organization.data?.name || "組織"}へようこそ +

+

+ 左からチャンネルを選択して会話を始めましょう +

+
+
+ {/if} +
+
diff --git a/packages/client/src/components/chat/Channel.svelte b/packages/client/src/components/channels/Channel.svelte similarity index 85% rename from packages/client/src/components/chat/Channel.svelte rename to packages/client/src/components/channels/Channel.svelte index 3ac3653..9e1d4e4 100644 --- a/packages/client/src/components/chat/Channel.svelte +++ b/packages/client/src/components/channels/Channel.svelte @@ -2,8 +2,8 @@ import { api, type Id } from "@packages/convex"; import type { Doc } from "@packages/convex/src/convex/_generated/dataModel"; import { useQuery } from "convex-svelte"; - import MessageInput from "./MessageInput.svelte"; - import MessageList from "./MessageList.svelte"; + import MessageInput from "../chat/MessageInput.svelte"; + import MessageList from "../chat/MessageList.svelte"; interface Props { selectedChannelId: Id<"channels">; @@ -12,7 +12,7 @@ let { selectedChannelId }: Props = $props(); const selectedChannel = useQuery(api.channels.get, () => ({ - id: selectedChannelId, + channelId: selectedChannelId, })); let replyingTo = $state | null>(null); diff --git a/packages/client/src/components/chat/ChannelList.svelte b/packages/client/src/components/channels/ChannelList.svelte similarity index 59% rename from packages/client/src/components/chat/ChannelList.svelte rename to packages/client/src/components/channels/ChannelList.svelte index ba79353..e833c66 100644 --- a/packages/client/src/components/chat/ChannelList.svelte +++ b/packages/client/src/components/channels/ChannelList.svelte @@ -1,30 +1,24 @@ -
+
-

チャンネル

- +

チャンネル

+
@@ -50,5 +44,11 @@ チャンネルを読み込み中...
{/if} + + {#if channels.data && channels.data.length === 0} +
+ まだチャンネルがありません +
+ {/if}
diff --git a/packages/client/src/components/channels/CreateChannelButton.svelte b/packages/client/src/components/channels/CreateChannelButton.svelte new file mode 100644 index 0000000..d86d28c --- /dev/null +++ b/packages/client/src/components/channels/CreateChannelButton.svelte @@ -0,0 +1,72 @@ + + + + + + +{#snippet createChannelModalContent()} +
+ + {#if disabled} + + {:else} + + {/if} +
+{/snippet} diff --git a/packages/client/src/components/chat/ChatApp.svelte b/packages/client/src/components/chat/ChatApp.svelte deleted file mode 100644 index 870e977..0000000 --- a/packages/client/src/components/chat/ChatApp.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -
- - -
- {#if selectedChannelId} - - {:else} -
-
-

- チャットアプリへようこそ -

-

- 左からチャンネルを選択して会話を始めましょう -

-
-
- {/if} -
-
diff --git a/packages/client/src/components/chat/MessageInput.svelte b/packages/client/src/components/chat/MessageInput.svelte index 9ca383c..29546a0 100644 --- a/packages/client/src/components/chat/MessageInput.svelte +++ b/packages/client/src/components/chat/MessageInput.svelte @@ -1,7 +1,8 @@ + +
+
+
+

組織を選択

+

+ 参加している組織からチャットする組織を選んでください +

+
+ +
+ {#if organizations.data} + {#each organizations.data as org} + + {/each} + {:else} +
+ +
+ {/if} +
+ + {#if organizations.data && organizations.data.length === 0} +
+

参加している組織がありません

+ + 新しい組織を作成 + +
+ {/if} +
+
diff --git a/packages/client/src/icons/mdi-close.svelte b/packages/client/src/icons/mdi-close.svelte new file mode 100644 index 0000000..bba2a6b --- /dev/null +++ b/packages/client/src/icons/mdi-close.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/client/src/lib/modal/modal.svelte b/packages/client/src/lib/modal/modal.svelte new file mode 100644 index 0000000..fc30535 --- /dev/null +++ b/packages/client/src/lib/modal/modal.svelte @@ -0,0 +1,40 @@ + + + + + m.close()}> + + {#if clickOutsideToClose} + + {/if} + diff --git a/packages/client/src/lib/svelte-robot.svelte.ts b/packages/client/src/lib/svelte-robot.svelte.ts new file mode 100644 index 0000000..12318a1 --- /dev/null +++ b/packages/client/src/lib/svelte-robot.svelte.ts @@ -0,0 +1,29 @@ +import { interpret, type Machine, type MachineStates } from "robot3"; +export function useMachine< + S extends MachineStates, + C extends {}, + K extends string, + F extends string, +>(machine: Machine, context: C) { + let service = $state( + interpret( + machine, + (tx) => { + service = tx; + }, + context, + ), + ); + + return { + get machine() { + return service.machine; + }, + get context() { + return service.context; + }, + send(...args: Parameters) { + service.send(...args); + }, + }; +} diff --git a/packages/client/src/lib/useMutation.svelte.ts b/packages/client/src/lib/useMutation.svelte.ts new file mode 100644 index 0000000..a5f9e97 --- /dev/null +++ b/packages/client/src/lib/useMutation.svelte.ts @@ -0,0 +1,35 @@ +import type { FunctionReference, OptionalRestArgs } from "convex/server"; +import { useConvexClient } from "convex-svelte"; + +export function useMutation>( + mutationFunction: T, +) { + const convex = useConvexClient(); + + let processing = $state(false); + let error = $state(null); + + return { + run: async (args: OptionalRestArgs[0]) => { + if (processing) return; // Prevent multiple runs at the same time + processing = true; + error = null; + try { + console.log("running mutation..."); + return await convex.mutation(mutationFunction, args); + } catch (e) { + console.log("mutation failed:", e); + error = e instanceof Error ? e.message : String(e); + } finally { + console.log("mutation finished"); + processing = false; + } + }, + get processing() { + return processing; + }, + get error() { + return error; + }, + }; +} diff --git a/packages/client/src/routes/+page.svelte b/packages/client/src/routes/+page.svelte index 0176d4a..bab5fe5 100644 --- a/packages/client/src/routes/+page.svelte +++ b/packages/client/src/routes/+page.svelte @@ -1,8 +1,14 @@ {#if auth.isLoading} @@ -10,7 +16,7 @@ {:else if auth.isAuthenticated} - + {:else}
diff --git a/packages/client/src/routes/orgs/[orgId]/+page.svelte b/packages/client/src/routes/orgs/[orgId]/+page.svelte new file mode 100644 index 0000000..47437e0 --- /dev/null +++ b/packages/client/src/routes/orgs/[orgId]/+page.svelte @@ -0,0 +1,9 @@ + + + diff --git a/packages/client/src/routes/orgs/[orgId]/chat/[channelId]/+page.svelte b/packages/client/src/routes/orgs/[orgId]/chat/[channelId]/+page.svelte new file mode 100644 index 0000000..12225ac --- /dev/null +++ b/packages/client/src/routes/orgs/[orgId]/chat/[channelId]/+page.svelte @@ -0,0 +1,12 @@ + + +} + channelId={channelId as Id<"channels">} +/> diff --git a/packages/client/src/routes/orgs/[orgId]/settings/+page.svelte b/packages/client/src/routes/orgs/[orgId]/settings/+page.svelte new file mode 100644 index 0000000..2e2cbc3 --- /dev/null +++ b/packages/client/src/routes/orgs/[orgId]/settings/+page.svelte @@ -0,0 +1,199 @@ + + +
+
+ + ← 戻る + + + {#if organization.data} +
+
+ {#if isEditing} + + + {:else} +

+ {organization.data.name} +

+ {#if organization.data.description} +

+ {organization.data.description} +

+ {/if} + {/if} +
+ + {#if organization.data.permission === "admin"} +
+ {#if isEditing} + + + {:else} + + {/if} +
+ {/if} +
+ {:else} +
+ +
+ {/if} +
+ +
+ +
+
+

組織情報

+ {#if organization.data} +
+
+ 作成日: + {new Date(organization.data.createdAt).toLocaleDateString( + "ja-JP", + )} +
+
+ あなたの権限: +
+ {organization.data.permission} +
+
+
+ {/if} +
+
+ + +
+
+
+

メンバー

+ {#if organization.data?.permission === "admin"} + + {/if} +
+ + {#if members.data} +
+ {#each members.data as member} +
+
+
+
+ {member.user?.name?.[0] || "?"} +
+
+
+
+ {member.user?.name || "Unknown User"} +
+
+ {member.user?.email} +
+
+
+
+
+ {member.permission} +
+ {#if organization.data?.permission === "admin" && member.userId !== organization.data?.ownerId} + + {/if} +
+
+ {/each} +
+ {:else} +
+ +
+ {/if} +
+
+
+
diff --git a/packages/client/src/routes/orgs/new/+page.svelte b/packages/client/src/routes/orgs/new/+page.svelte new file mode 100644 index 0000000..676d076 --- /dev/null +++ b/packages/client/src/routes/orgs/new/+page.svelte @@ -0,0 +1,104 @@ + + +
+
+ + +

新しい組織を作成

+

+ 新しい組織を作成して、メンバーとコラボレーションを始めましょう +

+
+ +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
diff --git a/packages/client/svelte.config.js b/packages/client/svelte.config.js index d808ef2..2fe6c3b 100644 --- a/packages/client/svelte.config.js +++ b/packages/client/svelte.config.js @@ -6,7 +6,9 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; const config = { // Consult https://svelte.dev/docs/kit/integrations // for more information about preprocessors - preprocess: vitePreprocess(), + preprocess: vitePreprocess({ + script: true, + }), kit: { adapter: adapter({ @@ -15,7 +17,6 @@ const config = { }), outDir: "./.svelte-kit", alias: { - "@": "src", "@@": "../..", $components: "src/components", "~": "src/", diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 6f45541..b610953 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -1,6 +1,7 @@ import { paraglideVitePlugin } from "@inlang/paraglide-js"; import { sveltekit } from "@sveltejs/kit/vite"; import tailwindcss from "@tailwindcss/vite"; +import Icons from "unplugin-icons/vite"; import { defineConfig } from "vite"; export default defineConfig({ @@ -11,6 +12,9 @@ export default defineConfig({ project: "./project.inlang", outdir: "./src/lib/paraglide", }), + Icons({ + compiler: "svelte", + }), ], envDir: "../..", envPrefix: "PUBLIC_", diff --git a/packages/convex/src/convex/channels.ts b/packages/convex/src/convex/channels.ts index 4c3d8f4..d713efa 100644 --- a/packages/convex/src/convex/channels.ts +++ b/packages/convex/src/convex/channels.ts @@ -1,10 +1,24 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { getChannelPerms } from "./perms"; export const list = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db.query("channels").order("desc").collect(); + args: { organizationId: v.id("organizations") }, + handler: async (ctx, args) => { + const perms = await getChannelPerms(ctx, { + organizationId: args.organizationId, + }); + if (!perms.read) { + throw new Error("Insufficient permissions"); + } + + return await ctx.db + .query("channels") + .withIndex("by_organization", (q) => + q.eq("organizationId", args.organizationId), + ) + .order("desc") + .collect(); }, }); @@ -12,11 +26,20 @@ export const create = mutation({ args: { name: v.string(), description: v.optional(v.string()), + organizationId: v.id("organizations"), }, handler: async (ctx, args) => { + const perms = await getChannelPerms(ctx, { + organizationId: args.organizationId, + }); + if (!perms.write) { + throw new Error("Insufficient permissions"); + } + const channelId = await ctx.db.insert("channels", { name: args.name, description: args.description, + organizationId: args.organizationId, createdAt: Date.now(), }); return channelId; @@ -24,8 +47,17 @@ export const create = mutation({ }); export const get = query({ - args: { id: v.id("channels") }, + args: { channelId: v.id("channels") }, handler: async (ctx, args) => { - return await ctx.db.get(args.id); + const perms = await getChannelPerms(ctx, { channelId: args.channelId }); + if (!perms.read) { + throw new Error("Insufficient permissions"); + } + const channel = await ctx.db.get(args.channelId); + if (!channel) { + return null; + } + + return channel; }, }); diff --git a/packages/convex/src/convex/messages.ts b/packages/convex/src/convex/messages.ts index a6e4837..0dbd242 100644 --- a/packages/convex/src/convex/messages.ts +++ b/packages/convex/src/convex/messages.ts @@ -1,9 +1,16 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { getMessagePerms } from "./perms"; export const list = query({ args: { channelId: v.id("channels") }, handler: async (ctx, args) => { + const perms = await getMessagePerms(ctx, { + channelId: args.channelId, + }); + if (!perms.read) { + throw new Error("Insufficient permissions"); + } return await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) @@ -20,6 +27,12 @@ export const send = mutation({ parentId: v.optional(v.id("messages")), }, handler: async (ctx, args) => { + const perms = await getMessagePerms(ctx, { + channelId: args.channelId, + }); + if (!perms.create) { + throw new Error("Insufficient permissions"); + } await ctx.db.insert("messages", { channelId: args.channelId, content: args.content, diff --git a/packages/convex/src/convex/organizations.ts b/packages/convex/src/convex/organizations.ts new file mode 100644 index 0000000..eef6778 --- /dev/null +++ b/packages/convex/src/convex/organizations.ts @@ -0,0 +1,190 @@ +import { getAuthUserId } from "@convex-dev/auth/server"; +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { getOrganizationPerms } from "./perms"; + +export const create = mutation({ + args: { + name: v.string(), + description: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new Error("Not authenticated"); + } + + const organizationId = await ctx.db.insert("organizations", { + name: args.name, + description: args.description, + createdAt: Date.now(), + ownerId: userId, + }); + + await ctx.db.insert("organizationMembers", { + organizationId, + userId, + permission: "admin", + joinedAt: Date.now(), + }); + + return organizationId; + }, +}); + +export const list = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new Error("Not authenticated"); + } + + const memberships = await ctx.db + .query("organizationMembers") + .withIndex("by_user", (q) => q.eq("userId", userId)) + .collect(); + + const organizations = await Promise.all( + memberships.map(async (membership) => { + const org = await ctx.db.get(membership.organizationId); + return { + ...org, + permission: membership.permission, + role: membership.role, + }; + }), + ); + + return organizations.filter((org) => org !== null); + }, +}); + +export const get = query({ + args: { id: v.id("organizations") }, + handler: async (ctx, args) => { + const perms = await getOrganizationPerms(ctx, { id: args.id }); + + return { + ...perms.organization, + permission: perms.membership.permission, + role: perms.membership.role, + }; + }, +}); + +export const update = mutation({ + args: { + id: v.id("organizations"), + name: v.optional(v.string()), + description: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const perms = await getOrganizationPerms(ctx, { id: args.id }); + if (!perms.info.update) { + throw new Error("Insufficient permissions"); + } + await ctx.db.patch(args.id, { + name: args.name, + description: args.description, + }); + }, +}); + +export const addMember = mutation({ + args: { + organizationId: v.id("organizations"), + userId: v.id("users"), + role: v.optional(v.string()), + permission: v.union( + v.literal("admin"), + v.literal("member"), + v.literal("visitor"), + ), + }, + handler: async (ctx, args) => { + const perms = await getOrganizationPerms(ctx, { id: args.organizationId }); + if (!perms.members.invite) { + throw new Error("Insufficient permissions"); + } + + const existingMembership = await ctx.db + .query("organizationMembers") + .withIndex("by_organization", (q) => + q.eq("organizationId", args.organizationId), + ) + .filter((q) => q.eq(q.field("userId"), args.userId)) + .first(); + + if (existingMembership) { + throw new Error("User is already a member"); + } + + await ctx.db.insert("organizationMembers", { + organizationId: args.organizationId, + userId: args.userId, + role: args.role, + permission: args.permission, + joinedAt: Date.now(), + }); + }, +}); + +export const removeMember = mutation({ + args: { + organizationId: v.id("organizations"), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const perms = await getOrganizationPerms(ctx, { id: args.organizationId }); + if (!perms.members.kick) { + throw new Error("Insufficient permissions"); + } + if (args.userId === perms.membership.userId) { + throw new Error("Cannot kick yourself"); + } + + const targetMembership = await ctx.db + .query("organizationMembers") + .withIndex("by_organization", (q) => + q.eq("organizationId", args.organizationId), + ) + .filter((q) => q.eq(q.field("userId"), args.userId)) + .first(); + + if (!targetMembership) { + throw new Error("User is not a member"); + } + + await ctx.db.delete(targetMembership._id); + }, +}); + +export const getMembers = query({ + args: { organizationId: v.id("organizations") }, + handler: async (ctx, args) => { + const perms = await getOrganizationPerms(ctx, { id: args.organizationId }); + if (!perms.members.read) { + throw new Error("Insufficient permissions"); + } + + const memberships = await ctx.db + .query("organizationMembers") + .withIndex("by_organization", (q) => + q.eq("organizationId", args.organizationId), + ) + .collect(); + + const members = await Promise.all( + memberships.map(async (membership) => { + const user = await ctx.db.get(membership.userId); + return { + ...membership, + user, + }; + }), + ); + + return members.filter((member) => member.user !== null); + }, +}); diff --git a/packages/convex/src/convex/perms.ts b/packages/convex/src/convex/perms.ts new file mode 100644 index 0000000..b6ee6a7 --- /dev/null +++ b/packages/convex/src/convex/perms.ts @@ -0,0 +1,178 @@ +import { getAuthUserId } from "@convex-dev/auth/server"; +import type { Id } from "./_generated/dataModel"; +import type { QueryCtx } from "./_generated/server"; + +/** + +# Channel Permissions + +[Fellow = Everyone in the organization = admin, member, visitor] + +- Fellow can list channels and get channel details. +- Admin and member can create, update and delete channels. + + */ + +export async function getChannelPerms( + ctx: QueryCtx, + query: + | { + channelId: Id<"channels">; + } + | { + organizationId: Id<"organizations">; + }, +) { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new Error("User is not authenticated"); + } + + const organizationId = + "organizationId" in query + ? query.organizationId + : (await ctx.db.get(query.channelId))?.organizationId; + + if (!organizationId) { + throw new Error("Organization not found"); + } + + const membership = await ctx.db + .query("organizationMembers") + .withIndex("by_organization", (q) => q.eq("organizationId", organizationId)) + .filter((q) => q.eq(q.field("userId"), userId)) + .first(); + + if (!membership) { + throw new Error("User is not a member of the organization"); + } + + return { + userId, + membership, + read: true, + write: + membership.permission === "admin" || membership.permission === "member", + }; +} + +/** +# Messages + +- Fellow can list messages and get message details. +- Fellow can create messages. +- Only the creator can update a message. +- Creator and admin can delete a message. + +*/ +export async function getMessagePerms( + ctx: QueryCtx, + query: + | { + messageId: Id<"messages">; + } + | { + channelId: Id<"channels">; + }, +) { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new Error("User is not authenticated"); + } + + const { message, channel } = await (async () => { + if ("messageId" in query) { + const message = await ctx.db.get(query.messageId); + if (!message) { + throw new Error("Message not found"); + } + const channel = await ctx.db.get(message.channelId); + return { message, channel }; + } else { + const channel = await ctx.db.get(query.channelId); + if (!channel) { + throw new Error("Channel not found"); + } + return { message: null, channel }; + } + })(); + + const organizationId = channel?.organizationId; + if (!organizationId) { + throw new Error("Channel not found"); + } + + const membership = await ctx.db + .query("organizationMembers") + .withIndex("by_organization", (q) => q.eq("organizationId", organizationId)) + .filter((q) => q.eq(q.field("userId"), userId)) + .first(); + + if (!membership) { + throw new Error("User is not a member of the organization"); + } + + return { + userId, + membership, + read: true, + create: true, + update: message?.author === userId, + delete: message?.author === userId || membership.permission === "admin", + }; +} + +/** +# Organizations + +- Anyone can create organizations. (it does not call this function) +- Anyone can list their organizations. +- Fellow can get organization details. +- Only admin can update and delete organizations. +- Only admin can add members to the organization. +- Only admin can remove members from the organization. +- Only admin can update members' permissions. + */ +export async function getOrganizationPerms( + ctx: QueryCtx, + query: { id: Id<"organizations"> }, +) { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new Error("User is not authenticated"); + } + + const organization = await ctx.db.get(query.id); + if (!organization) { + throw new Error("Organization not found"); + } + + const membership = await ctx.db + .query("organizationMembers") + .withIndex("by_organization", (q) => + q.eq("organizationId", organization._id), + ) + .filter((q) => q.eq(q.field("userId"), userId)) + .first(); + + if (!membership) { + throw new Error("User is not a member of the organization"); + } + + return { + userId, + membership, + organization, + delete: membership.permission === "admin", + info: { + read: true, + update: membership.permission === "admin", + }, + members: { + read: true, + invite: membership.permission === "admin", + changePermission: membership.permission === "admin", + kick: membership.permission === "admin", + }, + }; +} diff --git a/packages/convex/src/convex/schema.ts b/packages/convex/src/convex/schema.ts index 777bb6e..e55f27c 100644 --- a/packages/convex/src/convex/schema.ts +++ b/packages/convex/src/convex/schema.ts @@ -8,11 +8,31 @@ export default defineSchema({ isCompleted: v.boolean(), assigner: v.string(), }), - channels: defineTable({ + organizations: defineTable({ name: v.string(), description: v.optional(v.string()), createdAt: v.number(), + ownerId: v.id("users"), }), + organizationMembers: defineTable({ + organizationId: v.id("organizations"), + userId: v.id("users"), + role: v.optional(v.string()), + permission: v.union( + v.literal("admin"), + v.literal("member"), + v.literal("visitor"), + ), + joinedAt: v.number(), + }) + .index("by_organization", ["organizationId"]) + .index("by_user", ["userId"]), + channels: defineTable({ + name: v.string(), + description: v.optional(v.string()), + organizationId: v.id("organizations"), + createdAt: v.number(), + }).index("by_organization", ["organizationId"]), messages: defineTable({ channelId: v.id("channels"), content: v.string(), diff --git a/packages/markdown/src/lib/components/ThemeToggle.svelte b/packages/markdown/src/lib/components/ThemeToggle.svelte index 2c330bf..c6ec1d1 100644 --- a/packages/markdown/src/lib/components/ThemeToggle.svelte +++ b/packages/markdown/src/lib/components/ThemeToggle.svelte @@ -1,5 +1,5 @@