Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -124,6 +126,22 @@ When working with Svelte code, always reference the latest documentation:
</script>
```

### 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`
Expand Down Expand Up @@ -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.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,16 @@ bun dev:tauri
const selectedChannel = useQuery(api.channels.get, { id: selectedChannelId });
</script>
```

### (client) Icon の使用について

- unplugin-icons を使っています。 <https://github.com/unplugin/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 はコンポーネントと混同されるため)
1 change: 0 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error",
},
"correctness": {
"useImportExtensions": {
Expand Down
43 changes: 43 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

28 changes: 14 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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/*"
]
}
7 changes: 5 additions & 2 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 3 additions & 0 deletions packages/client/src/app.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/// <reference types="@sveltejs/kit" />
/// <reference types="unplugin-icons/types/svelte" />

// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
Expand Down
102 changes: 102 additions & 0 deletions packages/client/src/components/app/ChatApp.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<script lang="ts">
import { api, type Id } from "@packages/convex";
import { useQuery } from "convex-svelte";
import { goto } from "$app/navigation";
import Channel from "../channels/Channel.svelte";
import ChannelList from "../channels/ChannelList.svelte";

interface Props {
organizationId: Id<"organizations">;
channelId?: Id<"channels">;
}

const { organizationId, channelId }: Props = $props();

const organization = useQuery(api.organizations.get, () => ({
id: organizationId,
}));
</script>

<div class="bg-base-100 flex h-screen">
<div class="bg-base-200 border-base-300 w-80 border-r">
<div class="border-base-300 border-b p-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base-content text-lg font-bold">
{organization.data?.name || "組織"}
</h2>
{#if organization.data?.description}
<p class="text-base-content/70 text-sm">
{organization.data.description}
</p>
{/if}
</div>
<div class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="btn btn-ghost btn-sm btn-circle"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-4 w-4 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
></path>
</svg>
</div>
<ul
role="menu"
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
>
<li role="menuitem">
<a href="/orgs/{organizationId}/settings">組織設定</a>
</li>
<li role="menuitem">
<a href="/">組織選択</a>
</li>
</ul>
</div>
</div>
{#if organization.data?.permission}
<div class="badge badge-outline mt-2 capitalize">
{organization.data.permission}
</div>
{/if}
</div>

<ChannelList
{organizationId}
bind:selectedChannelId={
() => channelId,
(id) => {
goto(`/orgs/${organizationId}/chat/${id}`);
}
}
/>
</div>

<div class="flex flex-1 flex-col">
{#if channelId}
<Channel selectedChannelId={channelId} />
{:else}
<div class="bg-base-200 flex flex-1 items-center justify-center">
<div class="text-center">
<h2 class="text-base-content/60 mb-2 text-2xl font-semibold">
{organization.data?.name || "組織"}へようこそ
</h2>
<p class="text-base-content/50">
左からチャンネルを選択して会話を始めましょう
</p>
</div>
</div>
{/if}
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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">;
Expand All @@ -12,7 +12,7 @@
let { selectedChannelId }: Props = $props();

const selectedChannel = useQuery(api.channels.get, () => ({
id: selectedChannelId,
channelId: selectedChannelId,
}));

let replyingTo = $state<Doc<"messages"> | null>(null);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,24 @@
<script lang="ts">
import { api, type Id } from "@packages/convex";
import { useConvexClient, useQuery } from "convex-svelte";
import { useQuery } from "convex-svelte";
import CreateChannelButton from "./CreateChannelButton.svelte";

interface Props {
organizationId: Id<"organizations">;
selectedChannelId?: Id<"channels">;
}

let { selectedChannelId = $bindable(undefined) }: Props = $props();
let { organizationId, selectedChannelId = $bindable() }: Props = $props();

const convex = useConvexClient();
const channels = useQuery(api.channels.list, () => ({}));

async function createChannel() {
const name = prompt("チャンネル名を入力してください:");
if (name?.trim()) {
await convex.mutation(api.channels.create, { name: name.trim() });
}
}
const channels = useQuery(api.channels.list, () => ({
organizationId,
}));
</script>

<div class="bg-base-200 flex h-full w-64 flex-col">
<div class="flex h-full flex-col">
<div class="border-base-300 border-b p-4">
<h2 class="text-lg font-semibold">チャンネル</h2>
<button class="btn btn-primary btn-sm mt-2 w-full" onclick={createChannel}>
+ 新しいチャンネル
</button>
<h3 class="text-base font-semibold">チャンネル</h3>
<CreateChannelButton {organizationId} />
</div>

<div class="flex-1 overflow-y-auto">
Expand All @@ -50,5 +44,11 @@
チャンネルを読み込み中...
</div>
{/if}

{#if channels.data && channels.data.length === 0}
<div class="text-base-content/60 p-4 text-center text-sm">
まだチャンネルがありません
</div>
{/if}
</div>
</div>
72 changes: 72 additions & 0 deletions packages/client/src/components/channels/CreateChannelButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<script lang="ts">
import { api, type Id } from "@packages/convex";
import { useConvexClient } from "convex-svelte";
import Modal, { ModalManager } from "~/lib/modal/modal.svelte";

const convex = useConvexClient();

interface Props {
organizationId: Id<"organizations">;
}
const { organizationId }: Props = $props();

let newChannelName = $state("");
let form: HTMLFormElement | null = $state(null);
let disabled = $state(false);

const modalManager = new ModalManager();
async function createChannel(event: Event) {
event.preventDefault();

if (disabled) return;
disabled = true;
try {
if (newChannelName.trim()) {
await convex.mutation(api.channels.create, {
name: newChannelName.trim(),
organizationId,
});
}
} catch (error) {
console.error(error);
} finally {
disabled = false;
form?.reset();
modalManager.close();
}
}
</script>

<Modal manager={modalManager} />

<button
class="btn btn-primary btn-sm mt-2 w-full"
onclick={() => {
modalManager.dispatch(createChannelModalContent);
}}
>
+ 新しいチャンネル
</button>

{#snippet createChannelModalContent()}
<form
bind:this={form}
onsubmit={createChannel}
class="flex items-center gap-2"
>
<input
type="text"
placeholder="チャンネル名"
class="input input-bordered"
bind:value={newChannelName}
/>
{#if disabled}
<button type="submit" class="btn btn-primary btn-sm" disabled>
作成中...
<span class="loading loading-spinner"></span>
</button>
{:else}
<button type="submit" class="btn btn-primary btn-sm">作成</button>
{/if}
</form>
{/snippet}
Loading