diff --git a/apps/api/scripts/seed-channels.ts b/apps/api/scripts/seed-channels.ts new file mode 100644 index 0000000..357653c --- /dev/null +++ b/apps/api/scripts/seed-channels.ts @@ -0,0 +1,109 @@ +/** + * Seed a guild with sample channels. + * + * Usage: + * pnpm --filter @repo/api exec tsx scripts/seed-channels.ts + */ + +import { db } from "@repo/db" +import { channel } from "@repo/db/schema" +import { eq } from "drizzle-orm" + +const guildId = process.argv[2] +if (!guildId) { + console.error( + "Usage: pnpm --filter @repo/api exec tsx scripts/seed-channels.ts " + ) + process.exit(1) +} + +const categories = [ + { + name: "General", + channels: [ + { name: "general", type: "text" as const }, + { name: "introductions", type: "text" as const }, + { name: "off-topic", type: "text" as const }, + ], + }, + { + name: "Development", + channels: [ + { name: "frontend", type: "text" as const }, + { name: "backend", type: "text" as const }, + { name: "devops", type: "text" as const }, + { name: "code-review", type: "text" as const }, + ], + }, + { + name: "Community", + channels: [ + { name: "announcements", type: "announcement" as const }, + { name: "feedback", type: "text" as const }, + { name: "showcase", type: "text" as const }, + ], + }, + { + name: "Voice", + channels: [ + { name: "Lounge", type: "voice" as const }, + { name: "Dev Session", type: "voice" as const }, + { name: "Music", type: "voice" as const }, + ], + }, +] + +async function seed() { + // Clear existing channels for this guild + await db.delete(channel).where(eq(channel.guildId, guildId)) + console.log(`Cleared existing channels for guild ${guildId}`) + + // Uncategorized channels at the top + const uncategorized = [ + { name: "welcome", type: "text" as const }, + { name: "rules", type: "text" as const }, + ] + + for (let i = 0; i < uncategorized.length; i++) { + await db.insert(channel).values({ + name: uncategorized[i].name, + type: uncategorized[i].type, + guildId, + position: i, + }) + console.log(` # ${uncategorized[i].name}`) + } + + // Categories with children + for (let catIdx = 0; catIdx < categories.length; catIdx++) { + const cat = categories[catIdx] + const [categoryRow] = await db + .insert(channel) + .values({ + name: cat.name, + type: "category", + guildId, + position: catIdx, + }) + .returning() + + console.log(`\n ${cat.name.toUpperCase()}`) + + for (let chIdx = 0; chIdx < cat.channels.length; chIdx++) { + const ch = cat.channels[chIdx] + await db.insert(channel).values({ + name: ch.name, + type: ch.type, + guildId, + parentId: categoryRow.id, + position: chIdx, + }) + console.log(` ${ch.type === "voice" ? "🔊" : "#"} ${ch.name}`) + } + } + + console.log("\nDone!") + process.exit(0) +} + +seed() diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 55f095d..3c6f768 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -27,20 +27,25 @@ configureOpenAPI(app) // Health check at root app.route("/", index) -// Internal routes (not versioned) -const internalRoutes = [waitlistRouter] as const -for (const route of internalRoutes) { - app.route("/", route) -} - -// Versioned public API routes -const v1Routes = [channelsRouter] as const -for (const route of v1Routes) { - app.route("/v1", route) -} - -const allRoutes = [...internalRoutes, ...v1Routes] as const - -export type AppType = (typeof allRoutes)[number] +// Route mounting — chained for Hono RPC type inference +const routes = app.route("/", waitlistRouter).route("/v1", channelsRouter) + +export type AppType = typeof routes + +// // Internal routes (not versioned) +// const internalRoutes = [waitlistRouter] as const +// for (const route of internalRoutes) { +// app.route("/", route) +// } +// +// // Versioned public API routes +// const v1Routes = [channelsRouter] as const +// for (const route of v1Routes) { +// app.route("/v1", route) +// } +// +// const allRoutes = [...internalRoutes, ...v1Routes] as const +// +// export type AppType = (typeof allRoutes)[number] export default app diff --git a/apps/api/src/routes/v1/channels/handlers.ts b/apps/api/src/routes/v1/channels/handlers.ts index a3751aa..1cb9f60 100644 --- a/apps/api/src/routes/v1/channels/handlers.ts +++ b/apps/api/src/routes/v1/channels/handlers.ts @@ -1,6 +1,6 @@ import { db } from "@repo/db" import { channel } from "@repo/db/schema" -import { eq } from "drizzle-orm" +import { asc, eq } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" import type { AppRouteHandler } from "@/lib/types/app-types" import type { CreateChannelRoute, ListChannelsRoute } from "./routes" @@ -12,8 +12,42 @@ export const listChannels: AppRouteHandler = async (c) => { .select() .from(channel) .where(eq(channel.guildId, guild.id)) + .orderBy(asc(channel.position)) - return c.json({ success: true, data: channels }, HttpStatusCodes.OK) + const categoryMap = new Map() + const categories: typeof channels = [] + const uncategorized: typeof channels = [] + + for (const ch of channels) { + if (ch.type === "category") { + categories.push(ch) + categoryMap.set(ch.id, []) + } + } + + for (const ch of channels) { + if (ch.type === "category") continue + const parent = ch.parentId ? categoryMap.get(ch.parentId) : undefined + if (parent) { + parent.push(ch) + } else { + uncategorized.push(ch) + } + } + + return c.json( + { + success: true, + data: { + uncategorized, + categories: categories.map((cat) => ({ + ...cat, + channels: categoryMap.get(cat.id) ?? [], + })), + }, + }, + HttpStatusCodes.OK + ) } export const createChannel: AppRouteHandler = async (c) => { diff --git a/apps/api/src/routes/v1/channels/schema.ts b/apps/api/src/routes/v1/channels/schema.ts index c1def52..412c962 100644 --- a/apps/api/src/routes/v1/channels/schema.ts +++ b/apps/api/src/routes/v1/channels/schema.ts @@ -3,9 +3,16 @@ import { insertChannelSchema, selectChannelSchema } from "@repo/db/schema" export const channelResponseSchema = selectChannelSchema +export const categoryWithChannelsSchema = selectChannelSchema.extend({ + channels: z.array(selectChannelSchema), +}) + export const listChannelsResponseSchema = z.object({ success: z.literal(true), - data: z.array(selectChannelSchema), + data: z.object({ + uncategorized: z.array(selectChannelSchema), + categories: z.array(categoryWithChannelsSchema), + }), }) export const createChannelRequestSchema = insertChannelSchema diff --git a/apps/web/src/components/sidebar/channel-list.tsx b/apps/web/src/components/sidebar/channel-list.tsx deleted file mode 100644 index b81fe4c..0000000 --- a/apps/web/src/components/sidebar/channel-list.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { cn } from "@repo/ui/lib/utils" -import { Hash, Volume2 } from "lucide-react" -import { UserAvatar } from "../user-avatar" - -// Hardcoded mock data — will be replaced with real data -const channels = [ - { name: "general", active: true, unread: false }, - { name: "introductions", active: false, unread: true }, - { name: "development", active: false, unread: false }, - { name: "design", active: false, unread: false }, - { name: "off-topic", active: false, unread: true }, -] - -const voiceChannels = [ - { name: "Lounge", usersIn: ["Sam Chen", "Jordan Blake"] }, - { name: "Dev Session", usersIn: [] as string[] }, -] - -export function ChannelList() { - return ( - - ) -} diff --git a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx new file mode 100644 index 0000000..f6cdf25 --- /dev/null +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -0,0 +1,113 @@ +import { cn } from "@repo/ui/lib/utils" +import { useQuery } from "@tanstack/react-query" +import { useParams } from "@tanstack/react-router" +import { + ChevronDown, + Hash, + Megaphone, + MessageSquare, + Volume2, +} from "lucide-react" +import { apiClient } from "@/lib/api-client" + +const channelIcons = { + text: Hash, + voice: Volume2, + announcement: Megaphone, + forum: MessageSquare, +} as const + +function ChannelIcon({ type }: { type: string }) { + const Icon = channelIcons[type as keyof typeof channelIcons] ?? Hash + return +} + +export function ChannelList() { + const { guildSlug } = useParams({ strict: false }) + + const { data } = useQuery({ + queryKey: ["channels", guildSlug], + queryFn: async () => { + const res = await apiClient.v1.channels.$get() + if (!res.ok) { + throw new Error("Failed to fetch channels") + } + const json = await res.json() + return json.data + }, + enabled: !!guildSlug, + }) + + if (!data) { + return null + } + + const isEmpty = + data.uncategorized.length === 0 && data.categories.length === 0 + + if (isEmpty) { + return ( +
+

No channels yet.

+

Create one to get started.

+
+ ) + } + + return ( + + ) +} + +function ChannelItem({ + name, + type, + active = false, +}: { + name: string + type: string + active?: boolean +}) { + return ( + + ) +} diff --git a/apps/web/src/components/sidebar/channel-panel.tsx b/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx similarity index 100% rename from apps/web/src/components/sidebar/channel-panel.tsx rename to apps/web/src/components/sidebar/channel-panel/channel-panel.tsx diff --git a/apps/web/src/components/sidebar/channel-panel/guild-header.tsx b/apps/web/src/components/sidebar/channel-panel/guild-header.tsx new file mode 100644 index 0000000..1e74b8f --- /dev/null +++ b/apps/web/src/components/sidebar/channel-panel/guild-header.tsx @@ -0,0 +1,28 @@ +import { authClient } from "@repo/auth/client" +import { useQuery } from "@tanstack/react-query" +import { useParams } from "@tanstack/react-router" +import { ChevronDown } from "lucide-react" + +export function GuildHeader() { + const { guildSlug } = useParams({ strict: false }) + + const { data: activeOrg } = useQuery({ + queryKey: ["active-guild", guildSlug], + queryFn: async () => { + const res = await authClient.organization.getFullOrganization() + return res.data + }, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/sidebar/search-bar.tsx b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx similarity index 100% rename from apps/web/src/components/sidebar/search-bar.tsx rename to apps/web/src/components/sidebar/channel-panel/search-bar.tsx diff --git a/apps/web/src/components/sidebar/user-bar.tsx b/apps/web/src/components/sidebar/channel-panel/user-bar.tsx similarity index 95% rename from apps/web/src/components/sidebar/user-bar.tsx rename to apps/web/src/components/sidebar/channel-panel/user-bar.tsx index c9b9c8b..e54e396 100644 --- a/apps/web/src/components/sidebar/user-bar.tsx +++ b/apps/web/src/components/sidebar/channel-panel/user-bar.tsx @@ -1,6 +1,6 @@ import { authClient } from "@repo/auth/client" import { Settings } from "lucide-react" -import { UserAvatar } from "../user-avatar" +import { UserAvatar } from "../../ui/user-avatar" export function UserBar() { const { data: session } = authClient.useSession() diff --git a/apps/web/src/components/sidebar/guild-bar.tsx b/apps/web/src/components/sidebar/guild-bar.tsx deleted file mode 100644 index 1c8253b..0000000 --- a/apps/web/src/components/sidebar/guild-bar.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { cn } from "@repo/ui/lib/utils" -import { MessageCircle, Plus } from "lucide-react" - -// Mock data — will be replaced with real data -const guilds = [ - { id: "1", name: "Townhall", active: true }, - { id: "2", name: "Design Team", active: false }, - { id: "3", name: "Open Source", active: false }, -] - -function GuildIcon({ name, active }: { name: string; active: boolean }) { - const initials = name - .split(" ") - .map((w) => w[0]) - .join("") - .slice(0, 2) - - return ( -
- {/* Left pill indicator */} -
- -
- {initials} -
-
- ) -} - -export function GuildBar() { - return ( -
- {/* Home / DMs button */} -
-
-
- -
-
- - {/* Separator */} -
- - {/* Guild icons */} - {guilds.map((guild) => ( - - ))} - - {/* Separator */} -
- - {/* Add guild button */} -
-
- -
-
-
- ) -} diff --git a/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx b/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx new file mode 100644 index 0000000..7f9c3a3 --- /dev/null +++ b/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx @@ -0,0 +1,126 @@ +import { authClient } from "@repo/auth/client" +import { cn } from "@repo/ui/lib/utils" +import { useQuery } from "@tanstack/react-query" +import { useNavigate, useParams } from "@tanstack/react-router" +import { MessageCircle, Plus } from "lucide-react" + +function GuildIcon({ + name, + logo, + active, + onClick, +}: { + name: string + logo: string | null | undefined + active: boolean + onClick: () => void +}) { + const initials = name + .split(" ") + .map((w) => w[0]) + .join("") + .slice(0, 2) + + return ( + + ) +} + +export function GuildBar() { + const navigate = useNavigate() + + const { guildSlug } = useParams({ strict: false }) + + const { data: guilds } = useQuery({ + queryKey: ["guilds"], + queryFn: async () => { + const res = await authClient.organization.list() + return res.data + }, + }) + const { data: activeOrg } = useQuery({ + queryKey: ["active-guild", guildSlug], + queryFn: async () => { + const res = await authClient.organization.getFullOrganization() + return res.data + }, + }) + + return ( +
+ {/* Home / DMs button */} + + + {/* Separator */} +
+ + {/* Guild icons */} + {guilds && guilds.length > 0 ? ( + guilds.map((guild) => ( + + navigate({ + to: "/$guildSlug", + params: { guildSlug: guild.slug }, + }) + } + /> + )) + ) : ( +
+ — +
+ )} + + {/* Separator */} +
+ + {/* Add guild button */} +
+
+ +
+
+
+ ) +} diff --git a/apps/web/src/components/sidebar/guild-header.tsx b/apps/web/src/components/sidebar/guild-header.tsx deleted file mode 100644 index d1ad74a..0000000 --- a/apps/web/src/components/sidebar/guild-header.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ChevronDown } from "lucide-react" - -export function GuildHeader() { - return ( - - ) -} diff --git a/apps/web/src/components/sidebar/sidebar.tsx b/apps/web/src/components/sidebar/index.tsx similarity index 53% rename from apps/web/src/components/sidebar/sidebar.tsx rename to apps/web/src/components/sidebar/index.tsx index 32cc162..f1c0d5a 100644 --- a/apps/web/src/components/sidebar/sidebar.tsx +++ b/apps/web/src/components/sidebar/index.tsx @@ -1,5 +1,5 @@ -import { ChannelPanel } from "./channel-panel" -import { GuildBar } from "./guild-bar" +import { ChannelPanel } from "./channel-panel/channel-panel" +import { GuildBar } from "./guild-bar/guild-bar" export function Sidebar() { return ( diff --git a/apps/web/src/components/user-avatar.tsx b/apps/web/src/components/ui/user-avatar.tsx similarity index 100% rename from apps/web/src/components/user-avatar.tsx rename to apps/web/src/components/ui/user-avatar.tsx diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 4a022d4..7df7ffd 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -5,4 +5,7 @@ export const apiClient = honoClient(env.NEXT_PUBLIC_API_URL, { headers: { "Content-Type": "application/json", }, + init: { + credentials: "include", + }, }) diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx index 8672a0f..84f311f 100644 --- a/apps/web/src/routes/_authenticated.tsx +++ b/apps/web/src/routes/_authenticated.tsx @@ -1,7 +1,7 @@ import { authClient } from "@repo/auth/client" import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router" import { useEffect } from "react" -import { Sidebar } from "../components/sidebar/sidebar" +import { Sidebar } from "../components/sidebar" export const Route = createFileRoute("/_authenticated")({ component: AuthenticatedLayout, diff --git a/apps/web/src/routes/_authenticated/$guildSlug.tsx b/apps/web/src/routes/_authenticated/$guildSlug.tsx new file mode 100644 index 0000000..eb6e055 --- /dev/null +++ b/apps/web/src/routes/_authenticated/$guildSlug.tsx @@ -0,0 +1,56 @@ +import { authClient } from "@repo/auth/client" +import { useQuery } from "@tanstack/react-query" +import { createFileRoute, Outlet } from "@tanstack/react-router" +import { useEffect, useMemo } from "react" + +export const Route = createFileRoute("/_authenticated/$guildSlug")({ + component: GuildLayout, +}) + +function GuildLayout() { + const { guildSlug } = Route.useParams() + + const { data: guilds, isPending: guildsLoading } = useQuery({ + queryKey: ["guilds"], + queryFn: async () => { + const res = await authClient.organization.list() + return res.data + }, + }) + const { data: activeOrg } = useQuery({ + queryKey: ["active-guild", guildSlug], + queryFn: async () => { + const res = await authClient.organization.getFullOrganization() + return res.data + }, + }) + + const guild = useMemo( + () => guilds?.find((g) => g.slug === guildSlug), + [guilds, guildSlug] + ) + + useEffect(() => { + if (!guild) return + if (activeOrg?.id === guild.id) return + authClient.organization.setActive({ organizationId: guild.id }) + }, [guild, activeOrg?.id]) + + if (guildsLoading) { + return ( +
+ Loading... +
+ ) + } + + if (!guild) { + return ( +
+ Guild not found +
+ ) + } + + return +} diff --git a/apps/web/src/routes/_authenticated/$guildSlug/index.tsx b/apps/web/src/routes/_authenticated/$guildSlug/index.tsx new file mode 100644 index 0000000..604d7ee --- /dev/null +++ b/apps/web/src/routes/_authenticated/$guildSlug/index.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/_authenticated/$guildSlug/")({ + component: GuildHome, +}) + +function GuildHome() { + return ( +
+ + Select a channel to start chatting + +
+ ) +}