Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
17 changes: 15 additions & 2 deletions packages/client/src/components/chat/ChannelList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
interface Props {
selectedChannelId?: Id<"channels">;
screenMode: string;
Copy link
Contributor

@aster-void aster-void Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selectedChannelId と screenMode は

screenMode = "channel" -> selectedChannelId は必須
screenMode = "personalization" -> selectedChannelId は不要

という関係にあるので、その関連性を反映した型にするといいね

type Selection =
  | {
      type: "chat";
      channelId: Id<"channels">;
  | {
      type: "personalization";
    };

}
let { selectedChannelId = $bindable(undefined) }: Props = $props();
let {
selectedChannelId = $bindable(undefined),
screenMode = $bindable(),
}: Props = $props();
const convex = useConvexClient();
const channels = useQuery(api.channels.list, () => ({}));
Expand Down Expand Up @@ -37,7 +41,10 @@
? "bg-primary text-primary-content"
: "hover:bg-base-300",
].join(" ")}
onclick={() => (selectedChannelId = channel._id)}
onclick={() => {
selectedChannelId = channel._id;
screenMode = "chat";
}}
>
<div class="font-medium"># {channel.name}</div>
{#if channel.description}
Expand All @@ -51,4 +58,10 @@
</div>
{/if}
</div>
<button
class="btn btn-primary mt-auto mb-2 w-full"
onclick={() => {
screenMode = "personalization";
}}>個人用設定</button
>
</div>
33 changes: 19 additions & 14 deletions packages/client/src/components/chat/ChatApp.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,32 @@
import { type Id } from "@packages/convex";
import Channel from "./Channel.svelte";
import ChannelList from "./ChannelList.svelte";
import Personalization from "./Personalization.svelte";
let selectedChannelId = $state<Id<"channels"> | undefined>(undefined);
let screenMode = $state("chat");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

これは、文字列ならなんでもとれるわけではなく、 "chat" か "personalization" の二択だと思うので、それを反映した型をつけるといいね

let screenMode = $state<"chat" | "personalization">("chat");

</script>

<div class="bg-base-100 flex h-screen">
<ChannelList bind:selectedChannelId />

<ChannelList bind:selectedChannelId bind:screenMode />
<div class="flex flex-1 flex-col">
{#if selectedChannelId}
<Channel {selectedChannelId} />
{: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">
チャットアプリへようこそ
</h2>
<p class="text-base-content/50">
左からチャンネルを選択して会話を始めましょう
</p>
{#if screenMode == "chat"}
{#if selectedChannelId}
<Channel {selectedChannelId} />
{: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">
チャットアプリへようこそ
</h2>
<p class="text-base-content/50">
左からチャンネルを選択して会話を始めましょう
</p>
</div>
</div>
</div>
{/if}
{:else if screenMode == "personalization"}
<Personalization />
{/if}
</div>
</div>
16 changes: 16 additions & 0 deletions packages/client/src/components/chat/MessageDropdown.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts">
import type { Snippet } from "svelte";

const {
children,
x,
y,
visible,
}: { children: Snippet; x: number; y: number; visible: boolean } = $props();
</script>

{#if visible}
<div style={`top: ${y}px; left: ${x}px;}`} class="absolute z-10">
{@render children()}
</div>
{/if}
103 changes: 70 additions & 33 deletions packages/client/src/components/chat/MessageList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { Doc } from "@packages/convex/src/convex/_generated/dataModel";
import { useQuery } from "convex-svelte";
import { onMount } from "svelte";
import MessageDropdown from "./MessageDropdown.svelte";

interface Props {
channelId: Id<"channels">;
Expand Down Expand Up @@ -43,46 +44,82 @@
onMount(() => {
scrollToBottom();
});

let clientX = $state(0);
let clientY = $state(0);
let visibleDropdown = $state<Id<"messages"> | null>(null);
document.addEventListener("click", () => {
visibleDropdown = null;
});
</script>

<div bind:this={messagesContainer} class="flex-1 space-y-2 overflow-y-auto p-4">
{#if messages.data}
{#each messages.data as message (message._id)}
{#if message.parentId && messages.data.find((m) => m._id === message.parentId)}
<div class="flex items-center gap-2">
<span class="text-base-content/60 text-xs">返信</span>
<span class="text-primary font-semibold"
>{messagesById.get(message.parentId)?.author}</span
>
<span class="text-base-content/60 text-xs">
{messagesById.get(message.parentId)?.content}
</span>
</div>
{/if}
<div class="group relative flex flex-col">
<div class="flex items-baseline gap-2">
<span class="text-primary font-semibold">{message.author}</span>
<span class="text-base-content/60 text-xs">
{formatTime(message.createdAt)}
</span>
</div>
<div class="text-base-content ml-0 whitespace-pre-wrap">
{message.content}
</div>
<div
class="bg-base-100 absolute top-0 right-4 -translate-y-1/2 rounded-md border opacity-0 group-hover:opacity-100"
{#snippet dropdownContent()}
<ul
class="menu dropdown-content bg-base-100 absolute z-[1] w-40 rounded-md border p-2 shadow"
>
<div class="dropdown dropdown-end">
<button class="btn btn-ghost btn-sm p-2" tabindex="0"> ⋮ </button>
<ul
tabindex="0"
role="menu"
class="menu dropdown-content bg-base-100 z-[1] w-40 rounded-md border p-2 shadow"
<li>
<button onclick={() => (replyingTo = message)}>返信</button>
</li>
</ul>
{/snippet}
<MessageDropdown
x={clientX}
y={clientY}
visible={visibleDropdown === message._id}
>
{@render dropdownContent()}
</MessageDropdown>

<div
role="button"
tabindex="0"
class="p-1 hover:bg-sky-900"
oncontextmenu={(e) => {
e.preventDefault();
clientX = e.clientX;
clientY = e.clientY;
visibleDropdown = message._id;
}}
>
{#if message.parentId && messages.data.find((m) => m._id === message.parentId)}
<div class="flex items-center gap-2">
<span class="text-base-content/60 text-xs">返信</span>
<span class="text-primary font-semibold"
>{messagesById.get(message.parentId)?.author}</span
>
<li>
<button onclick={() => (replyingTo = message)}>返信</button>
</li>
</ul>
<span class="text-base-content/60 text-xs">
{messagesById.get(message.parentId)?.content}
</span>
</div>
{/if}
<div class="group relative flex flex-col">
<div class="flex items-baseline gap-2">
<span class="text-primary font-semibold">{message.author}</span>
<span class="text-base-content/60 text-xs">
{formatTime(message.createdAt)}
</span>
</div>
<div class="text-base-content ml-0 whitespace-pre-wrap">
{message.content}
</div>
<div
class="bg-base-100 absolute top-4 right-4 -translate-y-1/2 rounded-md border opacity-0 group-hover:opacity-100"
>
<div class="dropdown dropdown-end">
<button class="btn btn-ghost btn-sm p-2" tabindex="0"> ⋮ </button>
<ul
tabindex="0"
role="menu"
class="menu dropdown-content bg-base-100 z-[1] w-40 rounded-md border p-2 shadow"
>
<li>
<button onclick={() => (replyingTo = message)}>返信</button>
</li>
</ul>
</div>
</div>
</div>
</div>
Expand Down
124 changes: 124 additions & 0 deletions packages/client/src/components/chat/Personalization.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<script lang="ts">
import { api, type Id } from "@packages/convex";
import { useConvexClient, useQuery } from "convex-svelte";

const convex = useConvexClient();

const identity = useQuery(api.users.me, {});
const personalization = useQuery(api.personalization.getPersonalization, {});
let iconURL = $state<string | null>("");
let imageURL = $derived(iconURL || identity.data?.image);
let userName = $derived(
personalization.data?.nickname || identity.data?.name,
);
let changedImage = $state<string>("");
let changedImageFile = $state<File | undefined>();
let changedUserName = $state<string>("");

$effect(() => {
if (userName) {
changedUserName = userName;
}
if (personalization.data) {
new Promise((resolve) => {
resolve(personalization.data?.icon);
})
.then((value) => {
return new Promise((resolve, reject) => {
const storageId = value as Id<"_storage">;
if (storageId) {
resolve(
convex.mutation(api.personalization.getImageUrl, {
storageId: storageId,
}),
);
} else {
reject();
}
});
})
.then((value) => {
const url = value as string;
iconURL = url;
});
}
});

async function save() {
const image = changedImageFile;
changedImage = "";
changedImageFile = undefined;
if (changedUserName?.trim() && !(userName === changedUserName)) {
await convex.mutation(api.personalization.save, {
name: changedUserName,
});
}
if (image) {
const postUrl = await convex.mutation(
api.personalization.generateUploadUrl,
{},
);
const result = await fetch(postUrl, {
method: "POST",
headers: { "Content-Type": image.type },
body: image,
});

const { storageId } = await result.json();

await convex.mutation(api.personalization.saveImage, {
icon: storageId,
});
}
}
</script>

<h2 class="py-2 text-center text-lg font-semibold">アイコンの変更</h2>
<div class="flex justify-around">
<div class="w-32 flex-col">
<p class="mb-2 text-center">変更前</p>
<img src={imageURL} alt="googleアイコン" class="w-32" />
</div>
{#if changedImage}
<div class="w-32 flex-col">
<p class="mb-2 text-center">変更後</p>
<img src={changedImage} alt="変更後" class="w-32" />
</div>
{/if}
</div>
<input
type="file"
class="file:bg-primary file:text-primary-content
text-sm
text-gray-500 file:mr-4 file:ml-2
file:rounded file:border-0
file:px-4
file:py-2 file:font-semibold"
accept=".jpg, .png"
onchange={(event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
changedImage = URL.createObjectURL(file);
changedImageFile = file;
}
}}
/>
<h2 class="py-2 text-center text-lg font-semibold">名前の変更</h2>
<div class="flex justify-around">
<div>
<h3 class="text-center text-base">変更前</h3>
<h4 class="text-lg">{userName}</h4>
</div>
<div>
<h3 class="text-center text-base">変更後</h3>
<input
type="text"
placeholder="ユーザー名"
class="input input-primary w-full"
bind:value={changedUserName}
/>
</div>
</div>

<button class="btn btn-primary mt-auto mr-2 mb-2 ml-auto w-16" onclick={save}>保存</button>
Loading