diff --git a/apps/api/scripts/seed-dms.ts b/apps/api/scripts/seed-dms.ts new file mode 100644 index 0000000..53c4f1e --- /dev/null +++ b/apps/api/scripts/seed-dms.ts @@ -0,0 +1,258 @@ +/** + * Seed DM conversations for a user. + * + * Usage: + * pnpm --filter @repo/api exec tsx scripts/seed-dms.ts + */ + +import { db } from "@repo/db" +import { channel, channelMember, message, user } from "@repo/db/schema" +import { eq, inArray } from "drizzle-orm" + +const userId = process.argv[2] +if (!userId) { + console.error( + "Usage: pnpm --filter @repo/api exec tsx scripts/seed-dms.ts " + ) + process.exit(1) +} + +const fakeUsers = [ + { name: "Alice Chen", username: "alice", email: "alice@fake.local" }, + { name: "Bob Martinez", username: "bobm", email: "bob@fake.local" }, + { name: "Charlie Kim", username: "charliek", email: "charlie@fake.local" }, + { name: "Dana Patel", username: "danap", email: "dana@fake.local" }, + { name: "Eli Thompson", username: "elit", email: "eli@fake.local" }, + { name: "Fay Nakamura", username: "fayn", email: "fay@fake.local" }, + { name: "Gus Rivera", username: "gusr", email: "gus@fake.local" }, + { name: "Hana Okonkwo", username: "hanao", email: "hana@fake.local" }, +] + +const lastMessages = [ + "Hey, are you free to chat?", + "Thanks for the help earlier!", + "Did you see the new update?", + "Let me know when you're online", + "lol that was hilarious", + "Sure, I'll send it over tomorrow", + "Can you review my PR when you get a chance?", + "GG, that was a close one", +] + +const groupDms = [ + { + name: "Weekend Plans", + members: ["alice", "bobm", "charliek"], + messages: [ + { from: "alice", content: "Anyone free Saturday?" }, + { from: "bobm", content: "I'm down, what are you thinking?" }, + { from: "charliek", content: "Same, let me know the plan" }, + ], + }, + { + name: "Dev Team", + members: ["danap", "elit", "fayn", "gusr"], + messages: [ + { from: "gusr", content: "standup in 5" }, + { from: "elit", content: "be there" }, + { from: "fayn", content: "omw" }, + { from: "danap", content: "👍" }, + ], + }, + { + name: "Book Club", + members: ["hanao", "alice", "danap"], + messages: [ + { from: "hanao", content: "finished chapter 12, no spoilers please!" }, + { from: "alice", content: "same, that ending tho" }, + { from: "danap", content: "meeting Thursday still?" }, + ], + }, + { + name: "Game Night", + members: ["bobm", "charliek", "elit", "gusr", "fayn"], + messages: [ + { from: "charliek", content: "who's playing tonight?" }, + { from: "bobm", content: "in" }, + { from: "elit", content: "🎮 let's go" }, + { from: "gusr", content: "starting at 9?" }, + { from: "fayn", content: "works for me" }, + ], + }, +] + +async function seed() { + // Verify the target user exists + const [targetUser] = await db.select().from(user).where(eq(user.id, userId)) + if (!targetUser) { + console.error(`User ${userId} not found`) + process.exit(1) + } + console.log(`Seeding DMs for ${targetUser.name} (${targetUser.email})\n`) + + // Clean up previous seed data + const existingFake = await db + .select({ id: user.id }) + .from(user) + .where( + inArray( + user.email, + fakeUsers.map((u) => u.email) + ) + ) + if (existingFake.length > 0) { + const fakeIds = existingFake.map((u) => u.id) + // Deleting users cascades to channel_member and messages + await db.delete(user).where(inArray(user.id, fakeIds)) + // Clean up orphaned DM channels with no members + const orphaned = await db + .select({ id: channel.id }) + .from(channel) + .where(eq(channel.type, "dm")) + for (const ch of orphaned) { + const members = await db + .select({ id: channelMember.id }) + .from(channelMember) + .where(eq(channelMember.channelId, ch.id)) + if (members.length === 0) { + await db.delete(channel).where(eq(channel.id, ch.id)) + } + } + console.log("Cleared previous seed data") + } + + // Create fake users and DM channels + for (let i = 0; i < fakeUsers.length; i++) { + const fake = fakeUsers[i] + + const [fakeUser] = await db + .insert(user) + .values({ + name: fake.name, + email: fake.email, + username: fake.username, + displayUsername: fake.username, + emailVerified: true, + }) + .returning() + + // Create DM channel + const [dmChannel] = await db + .insert(channel) + .values({ + type: "dm", + guildId: null, + position: 0, + }) + .returning() + + // Add both users as members + await db.insert(channelMember).values([ + { channelId: dmChannel.id, userId }, + { channelId: dmChannel.id, userId: fakeUser.id }, + ]) + + // Add a last message from the fake user + const minutesAgo = (fakeUsers.length - i) * 15 + const createdAt = new Date(Date.now() - minutesAgo * 60 * 1000) + await db.insert(message).values({ + channelId: dmChannel.id, + authorId: fakeUser.id, + content: lastMessages[i], + type: "default", + createdAt, + }) + + // Update channel updatedAt to match message time for correct sort order + await db + .update(channel) + .set({ updatedAt: createdAt }) + .where(eq(channel.id, dmChannel.id)) + + console.log(` ${fake.name} (@${fake.username}): "${lastMessages[i]}"`) + } + + console.log(`\nCreated ${fakeUsers.length} DM conversations`) + + // Build a map of username → user id for group DM membership + const seededUsers = await db + .select({ id: user.id, username: user.username }) + .from(user) + .where( + inArray( + user.username, + fakeUsers.map((u) => u.username) + ) + ) + const usernameToId = Object.fromEntries( + seededUsers.map((u) => [u.username, u.id]) + ) + + // Create group DMs + for (let i = 0; i < groupDms.length; i++) { + const group = groupDms[i] + const minutesAgo = (groupDms.length - i) * 30 + fakeUsers.length * 15 + + const [dmChannel] = await db + .insert(channel) + .values({ + type: "group_dm", + name: group.name, + guildId: null, + ownerId: userId, + position: 0, + }) + .returning() + + // Add the target user + all named members + const memberIds = [ + userId, + ...group.members + .map((username) => usernameToId[username]) + .filter(Boolean), + ] + await db.insert(channelMember).values( + memberIds.map((memberId) => ({ + channelId: dmChannel.id, + userId: memberId, + })) + ) + + // Insert messages with staggered timestamps + for (let j = 0; j < group.messages.length; j++) { + const msg = group.messages[j] + const authorId = usernameToId[msg.from] + if (!authorId) continue + const msgTime = new Date( + Date.now() - minutesAgo * 60 * 1000 + j * 2 * 60 * 1000 + ) + await db.insert(message).values({ + channelId: dmChannel.id, + authorId, + content: msg.content, + type: "default", + createdAt: msgTime, + }) + } + + // Set channel updatedAt to last message time + const lastMsgTime = new Date( + Date.now() - + minutesAgo * 60 * 1000 + + (group.messages.length - 1) * 2 * 60 * 1000 + ) + await db + .update(channel) + .set({ updatedAt: lastMsgTime }) + .where(eq(channel.id, dmChannel.id)) + + console.log( + ` Group "${group.name}" (${memberIds.length} members): "${group.messages[group.messages.length - 1].content}"` + ) + } + + console.log(`\nCreated ${groupDms.length} group DM conversations`) + process.exit(0) +} + +seed() diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 3c6f768..dbfc1ec 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,6 +5,7 @@ import createApp from "@/lib/helpers/app/create-app" import configureOpenAPI from "@/lib/helpers/openapi/configure-openapi" import index from "@/routes/index.route" import channelsRouter from "@/routes/v1/channels/index" +import dmsRouter from "@/routes/v1/dms/index" import waitlistRouter from "@/routes/waitlist/index" const app = createApp() @@ -28,7 +29,10 @@ configureOpenAPI(app) app.route("/", index) // Route mounting — chained for Hono RPC type inference -const routes = app.route("/", waitlistRouter).route("/v1", channelsRouter) +const routes = app + .route("/", waitlistRouter) + .route("/v1", channelsRouter) + .route("/v1", dmsRouter) export type AppType = typeof routes diff --git a/apps/api/src/lib/helpers/openapi/schemas.ts b/apps/api/src/lib/helpers/openapi/schemas.ts index 4264b65..8de4f32 100644 --- a/apps/api/src/lib/helpers/openapi/schemas.ts +++ b/apps/api/src/lib/helpers/openapi/schemas.ts @@ -1,6 +1,23 @@ import { z } from "@hono/zod-openapi" +import type { ZodType } from "zod" import jsonContent from "./json-content" +// ── Pagination ────────────────────────────────────────── + +export const paginationQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + perPage: z.coerce.number().int().min(1).max(100).default(50), +}) + +export const paginatedResponseSchema = (itemSchema: T) => + z.object({ + itemsTotal: z.number(), + currentPage: z.number(), + nextPage: z.number().nullable(), + prevPage: z.number().nullable(), + data: z.array(itemSchema), + }) + const errorSchema = z.object({ success: z.literal(false), message: z.string(), diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/guild-auth.ts similarity index 70% rename from apps/api/src/middleware/auth.ts rename to apps/api/src/middleware/guild-auth.ts index a6b1302..8cd6335 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/guild-auth.ts @@ -8,15 +8,19 @@ import type { AppBindings } from "@/lib/types/app-types" /** * Authenticates the request via better-auth session and resolves the - * user's active guild + membership. + * guild from the :guildSlug path parameter. Verifies the user is a + * member of the guild. * * Sets in context: * - user: The authenticated user * - session: The session object - * - guild: The user's active guild - * - member: The user's membership in the active guild + * - guild: The resolved guild + * - member: The user's membership in the guild */ -export const authMiddleware = async (c: Context, next: Next) => { +export const guildAuthMiddleware = async ( + c: Context, + next: Next +) => { const session = await auth.api.getSession({ headers: c.req.raw.headers }) if (!session) { @@ -26,12 +30,19 @@ export const authMiddleware = async (c: Context, next: Next) => { ) } - const activeGuildId = session.session.activeOrganizationId + const guildSlug = c.req.param("guildSlug") - if (!activeGuildId) { + const guildRecord = await db + .select() + .from(guild) + .where(eq(guild.slug, guildSlug)) + .limit(1) + .then((rows) => rows[0]) + + if (!guildRecord) { return c.json( - { success: false, message: "No active guild selected" }, - HttpStatusCodes.BAD_REQUEST + { success: false, message: "Guild not found" }, + HttpStatusCodes.NOT_FOUND ) } @@ -41,7 +52,7 @@ export const authMiddleware = async (c: Context, next: Next) => { .where( and( eq(guildMember.userId, session.user.id), - eq(guildMember.guildId, activeGuildId) + eq(guildMember.guildId, guildRecord.id) ) ) .limit(1) @@ -49,25 +60,11 @@ export const authMiddleware = async (c: Context, next: Next) => { if (!memberRecord) { return c.json( - { success: false, message: "You are not a member of this guild" }, + { success: false, message: "Forbidden" }, HttpStatusCodes.FORBIDDEN ) } - const guildRecord = await db - .select() - .from(guild) - .where(eq(guild.id, activeGuildId)) - .limit(1) - .then((rows) => rows[0]) - - if (!guildRecord) { - return c.json( - { success: false, message: "Guild not found" }, - HttpStatusCodes.NOT_FOUND - ) - } - c.set("user", session.user) c.set("session", session.session) c.set("guild", guildRecord) diff --git a/apps/api/src/middleware/session-auth.ts b/apps/api/src/middleware/session-auth.ts new file mode 100644 index 0000000..3d5144c --- /dev/null +++ b/apps/api/src/middleware/session-auth.ts @@ -0,0 +1,33 @@ +import { auth } from "@repo/auth" +import type { Context, Next } from "hono" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import type { AppBindings } from "@/lib/types/app-types" + +/** + * Lightweight auth middleware that only validates the session. + * Does NOT require or resolve an active guild. + * + * Use this for guild-independent routes like DMs. + * + * Sets in context: + * - user: The authenticated user + * - session: The session object + */ +export const sessionAuthMiddleware = async ( + c: Context, + next: Next +) => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }) + + if (!session) { + return c.json( + { success: false, message: "Unauthorized" }, + HttpStatusCodes.UNAUTHORIZED + ) + } + + c.set("user", session.user) + c.set("session", session.session) + + await next() +} diff --git a/apps/api/src/routes/v1/channels/handlers.ts b/apps/api/src/routes/v1/channels/handlers.ts index 1cb9f60..dd7ef9f 100644 --- a/apps/api/src/routes/v1/channels/handlers.ts +++ b/apps/api/src/routes/v1/channels/handlers.ts @@ -1,9 +1,13 @@ import { db } from "@repo/db" import { channel } from "@repo/db/schema" -import { asc, eq } from "drizzle-orm" +import { and, asc, eq, inArray } 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" +import type { + CreateChannelRoute, + ListChannelsRoute, + ReorderChannelsRoute, +} from "./routes" export const listChannels: AppRouteHandler = async (c) => { const guild = c.var.guild @@ -72,3 +76,39 @@ export const createChannel: AppRouteHandler = async (c) => { return c.json({ success: true, data: newChannel }, HttpStatusCodes.CREATED) } + +export const reorderChannels: AppRouteHandler = async ( + c +) => { + const guild = c.var.guild + const { channels: updates } = c.req.valid("json") + + const channelIds = updates.map((u) => u.id) + const uniqueChannelIds = [...new Set(channelIds)] + + // Verify all channels belong to this guild + const existing = await db + .select({ id: channel.id }) + .from(channel) + .where( + and(eq(channel.guildId, guild.id), inArray(channel.id, uniqueChannelIds)) + ) + + if (existing.length !== uniqueChannelIds.length) { + return c.json( + { success: false, message: "One or more channels not found in guild" }, + HttpStatusCodes.FORBIDDEN + ) + } + + await db.transaction(async (tx) => { + for (const update of updates) { + await tx + .update(channel) + .set({ position: update.position, parentId: update.parentId }) + .where(and(eq(channel.id, update.id), eq(channel.guildId, guild.id))) + } + }) + + return c.json({ success: true }, HttpStatusCodes.OK) +} diff --git a/apps/api/src/routes/v1/channels/index.ts b/apps/api/src/routes/v1/channels/index.ts index 8b53988..996ded7 100644 --- a/apps/api/src/routes/v1/channels/index.ts +++ b/apps/api/src/routes/v1/channels/index.ts @@ -5,5 +5,6 @@ import * as routes from "./routes" const channelsRouter = createRouter() .openapi(routes.listChannels, handlers.listChannels) .openapi(routes.createChannel, handlers.createChannel) + .openapi(routes.reorderChannels, handlers.reorderChannels) export default channelsRouter diff --git a/apps/api/src/routes/v1/channels/routes.ts b/apps/api/src/routes/v1/channels/routes.ts index dad0bec..a7634e7 100644 --- a/apps/api/src/routes/v1/channels/routes.ts +++ b/apps/api/src/routes/v1/channels/routes.ts @@ -6,20 +6,26 @@ import { internalServerErrorSchema, unauthorizedSchema, } from "@/lib/helpers/openapi/schemas" -import { authMiddleware } from "@/middleware/auth" +import { guildAuthMiddleware } from "@/middleware/guild-auth" import { createChannelRequestSchema, createChannelResponseSchema, + guildSlugParamsSchema, listChannelsResponseSchema, + reorderChannelsRequestSchema, + reorderChannelsResponseSchema, } from "./schema" export const listChannels = createRoute({ - path: "/channels", + path: "/guilds/{guildSlug}/channels", method: "get", summary: "List channels", - description: "Lists all channels in the user's active guild.", + description: "Lists all channels in the specified guild.", tags: ["Channels"], - middleware: [authMiddleware] as const, + middleware: [guildAuthMiddleware] as const, + request: { + params: guildSlugParamsSchema, + }, responses: { [HttpStatusCodes.OK]: jsonContent({ schema: listChannelsResponseSchema, @@ -32,14 +38,14 @@ export const listChannels = createRoute({ }) export const createChannel = createRoute({ - path: "/channels", + path: "/guilds/{guildSlug}/channels", method: "post", summary: "Create a channel", - description: - "Creates a new channel in the user's active guild. Guild ID is derived from the session.", + description: "Creates a new channel in the specified guild.", tags: ["Channels"], - middleware: [authMiddleware] as const, + middleware: [guildAuthMiddleware] as const, request: { + params: guildSlugParamsSchema, body: jsonContent({ schema: createChannelRequestSchema, description: "Channel details", @@ -56,5 +62,32 @@ export const createChannel = createRoute({ }, }) +export const reorderChannels = createRoute({ + path: "/guilds/{guildSlug}/channels/reorder", + method: "patch", + summary: "Reorder channels", + description: + "Batch-update channel positions and parent categories within the specified guild.", + tags: ["Channels"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildSlugParamsSchema, + body: jsonContent({ + schema: reorderChannelsRequestSchema, + description: "Channel positions to update", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: reorderChannelsResponseSchema, + description: "Channels reordered", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + export type ListChannelsRoute = typeof listChannels export type CreateChannelRoute = typeof createChannel +export type ReorderChannelsRoute = typeof reorderChannels diff --git a/apps/api/src/routes/v1/channels/schema.ts b/apps/api/src/routes/v1/channels/schema.ts index 412c962..b42b61a 100644 --- a/apps/api/src/routes/v1/channels/schema.ts +++ b/apps/api/src/routes/v1/channels/schema.ts @@ -1,6 +1,19 @@ import { z } from "@hono/zod-openapi" import { insertChannelSchema, selectChannelSchema } from "@repo/db/schema" +// ── Path Params ────────────────────────────────────────── + +export const guildSlugParamsSchema = z.object({ + guildSlug: z.string().openapi({ + param: { + name: "guildSlug", + in: "path", + required: true, + }, + example: "my-guild", + }), +}) + export const channelResponseSchema = selectChannelSchema export const categoryWithChannelsSchema = selectChannelSchema.extend({ @@ -21,3 +34,19 @@ export const createChannelResponseSchema = z.object({ success: z.literal(true), data: selectChannelSchema, }) + +// ── Reorder ────────────────────────────────────────── + +export const reorderChannelItemSchema = z.object({ + id: z.string().uuid(), + position: z.number().int().min(0), + parentId: z.string().uuid().nullable(), +}) + +export const reorderChannelsRequestSchema = z.object({ + channels: z.array(reorderChannelItemSchema).min(1), +}) + +export const reorderChannelsResponseSchema = z.object({ + success: z.literal(true), +}) diff --git a/apps/api/src/routes/v1/dms/handlers.ts b/apps/api/src/routes/v1/dms/handlers.ts new file mode 100644 index 0000000..390f97d --- /dev/null +++ b/apps/api/src/routes/v1/dms/handlers.ts @@ -0,0 +1,164 @@ +import { db } from "@repo/db" +import { channel, channelMember, message, user } from "@repo/db/schema" +import { and, count, desc, eq, inArray } from "drizzle-orm" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import type { AppRouteHandler } from "@/lib/types/app-types" +import type { ListDMsRoute } from "./routes" + +const emptyPage = (page: number) => ({ + itemsTotal: 0, + currentPage: page, + nextPage: null, + prevPage: null, + data: [], +}) + +export const listDMs: AppRouteHandler = async (c) => { + const currentUser = c.var.user + const { page, perPage } = c.req.valid("query") + const offset = (page - 1) * perPage + + const dmFilter = and( + eq(channelMember.userId, currentUser.id), + inArray(channel.type, ["dm", "group_dm"]) + ) + + // Query 1: Count + fetch paginated DM channels + const [countResult, dmChannels] = await Promise.all([ + db + .select({ total: count() }) + .from(channel) + .innerJoin(channelMember, eq(channelMember.channelId, channel.id)) + .where(dmFilter), + db + .select({ + id: channel.id, + createdAt: channel.createdAt, + updatedAt: channel.updatedAt, + name: channel.name, + topic: channel.topic, + type: channel.type, + guildId: channel.guildId, + parentId: channel.parentId, + position: channel.position, + ownerId: channel.ownerId, + rateLimitPerUser: channel.rateLimitPerUser, + }) + .from(channel) + .innerJoin(channelMember, eq(channelMember.channelId, channel.id)) + .where(dmFilter) + .orderBy(desc(channel.updatedAt)) + .limit(perPage) + .offset(offset), + ]) + + const itemsTotal = countResult[0]?.total ?? 0 + + if (dmChannels.length === 0) { + return c.json(emptyPage(page), HttpStatusCodes.OK) + } + + // Query 2: Latest message + author for this page's channels + // Uses DISTINCT ON to get one row per channel (the most recent message) + const dmChannelIds = dmChannels.map((ch) => ch.id) + + const latestMessages = await db + .select({ + channelId: message.channelId, + id: message.id, + content: message.content, + createdAt: message.createdAt, + authorId: user.id, + authorName: user.name, + authorUsername: user.username, + authorDisplayUsername: user.displayUsername, + authorImage: user.image, + }) + .from(message) + .innerJoin(user, eq(message.authorId, user.id)) + .where(inArray(message.channelId, dmChannelIds)) + .orderBy(message.channelId, desc(message.createdAt)) + .then((rows) => { + // Keep only the first (most recent) message per channel + const seen = new Set() + return rows.filter((row) => { + if (seen.has(row.channelId)) return false + seen.add(row.channelId) + return true + }) + }) + + const lastMessageByChannel = new Map( + latestMessages.map((msg) => [ + msg.channelId, + { + id: msg.id, + content: msg.content, + createdAt: msg.createdAt.toISOString(), + author: { + id: msg.authorId, + name: msg.authorName, + username: msg.authorUsername, + displayUsername: msg.authorDisplayUsername, + image: msg.authorImage, + }, + }, + ]) + ) + + // Query 3: Members for each DM channel (excluding the current user) + const members = await db + .select({ + channelId: channelMember.channelId, + id: user.id, + name: user.name, + username: user.username, + displayUsername: user.displayUsername, + image: user.image, + }) + .from(channelMember) + .innerJoin(user, eq(channelMember.userId, user.id)) + .where(inArray(channelMember.channelId, dmChannelIds)) + + const membersByChannel = new Map< + string, + { + id: string + name: string + username: string | null + displayUsername: string | null + image: string | null + }[] + >() + for (const m of members) { + if (m.id === currentUser.id) continue + const list = membersByChannel.get(m.channelId) ?? [] + list.push({ + id: m.id, + name: m.name, + username: m.username, + displayUsername: m.displayUsername, + image: m.image, + }) + membersByChannel.set(m.channelId, list) + } + + const totalPages = Math.ceil(itemsTotal / perPage) + + const data = dmChannels.map((ch) => ({ + ...ch, + members: membersByChannel.get(ch.id) ?? [], + lastMessage: lastMessageByChannel.get(ch.id) ?? null, + })) + + return c.json( + { + itemsTotal, + currentPage: page, + nextPage: page < totalPages ? page + 1 : null, + prevPage: page > 1 ? page - 1 : null, + data, + }, + HttpStatusCodes.OK + ) +} diff --git a/apps/api/src/routes/v1/dms/index.ts b/apps/api/src/routes/v1/dms/index.ts new file mode 100644 index 0000000..01fbec0 --- /dev/null +++ b/apps/api/src/routes/v1/dms/index.ts @@ -0,0 +1,7 @@ +import { createRouter } from "@/lib/helpers/app/create-app" +import * as handlers from "./handlers" +import * as routes from "./routes" + +const dmsRouter = createRouter().openapi(routes.listDMs, handlers.listDMs) + +export default dmsRouter diff --git a/apps/api/src/routes/v1/dms/routes.ts b/apps/api/src/routes/v1/dms/routes.ts new file mode 100644 index 0000000..64367b8 --- /dev/null +++ b/apps/api/src/routes/v1/dms/routes.ts @@ -0,0 +1,32 @@ +import { createRoute } from "@hono/zod-openapi" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import jsonContent from "@/lib/helpers/openapi/json-content" +import { + internalServerErrorSchema, + paginationQuerySchema, + unauthorizedSchema, +} from "@/lib/helpers/openapi/schemas" +import { sessionAuthMiddleware } from "@/middleware/session-auth" +import { listDMsResponseSchema } from "./schema" + +export const listDMs = createRoute({ + path: "/dms", + method: "get", + summary: "List DMs", + description: "Lists all DM and group DM channels for the authenticated user.", + tags: ["DMs"], + middleware: [sessionAuthMiddleware] as const, + request: { + query: paginationQuerySchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: listDMsResponseSchema, + description: "List of DM channels with member info", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type ListDMsRoute = typeof listDMs diff --git a/apps/api/src/routes/v1/dms/schema.ts b/apps/api/src/routes/v1/dms/schema.ts new file mode 100644 index 0000000..3ebb9e0 --- /dev/null +++ b/apps/api/src/routes/v1/dms/schema.ts @@ -0,0 +1,33 @@ +import { z } from "@hono/zod-openapi" +import { selectChannelSchema } from "@repo/db/schema" +import { paginatedResponseSchema } from "@/lib/helpers/openapi/schemas" + +export const lastMessageAuthorSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + username: z.string().nullable(), + displayUsername: z.string().nullable(), + image: z.string().nullable(), +}) + +export const lastMessageSchema = z.object({ + id: z.string().uuid(), + content: z.string().nullable(), + createdAt: z.string().datetime(), + author: lastMessageAuthorSchema, +}) + +export const dmMemberSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + username: z.string().nullable(), + displayUsername: z.string().nullable(), + image: z.string().nullable(), +}) + +export const dmChannelSchema = selectChannelSchema.extend({ + members: z.array(dmMemberSchema), + lastMessage: lastMessageSchema.nullable(), +}) + +export const listDMsResponseSchema = paginatedResponseSchema(dmChannelSchema) diff --git a/apps/web/package.json b/apps/web/package.json index 6d58a8f..bf4ddd0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,9 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@repo/api-client": "workspace:*", "@repo/auth": "workspace:*", "@repo/env": "workspace:*", @@ -20,6 +23,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "motion": "^12.34.0", "next-themes": "^0.4.6", "postcss": "^8.5.6", "radix-ui": "^1.4.3", diff --git a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx index f6cdf25..7836b18 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -1,6 +1,24 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + type DragOverEvent, + DragOverlay, + type DragStartEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { Skeleton } from "@repo/ui/components/skeleton" import { cn } from "@repo/ui/lib/utils" -import { useQuery } from "@tanstack/react-query" -import { useParams } from "@tanstack/react-router" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useNavigate, useParams } from "@tanstack/react-router" import { ChevronDown, Hash, @@ -8,6 +26,8 @@ import { MessageSquare, Volume2, } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import { useCallback, useState } from "react" import { apiClient } from "@/lib/api-client" const channelIcons = { @@ -17,18 +37,85 @@ const channelIcons = { forum: MessageSquare, } as const +type Channel = { + id: string + name: string | null + type: string + position: number + parentId: string | null +} + +type Category = Channel & { + channels: Channel[] +} + +type ChannelData = { + uncategorized: Channel[] + categories: Category[] +} + function ChannelIcon({ type }: { type: string }) { const Icon = channelIcons[type as keyof typeof channelIcons] ?? Hash return } +function ChannelListSkeleton() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton +
+ + +
+ ))} +
+
+
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton +
+ + +
+ ))} +
+
+ ) +} + +function buildReorderPayload(data: ChannelData) { + const channels: { id: string; position: number; parentId: string | null }[] = + [] + + data.uncategorized.forEach((ch, i) => { + channels.push({ id: ch.id, position: i, parentId: null }) + }) + + data.categories.forEach((cat, ci) => { + channels.push({ id: cat.id, position: ci, parentId: null }) + cat.channels.forEach((ch, i) => { + channels.push({ id: ch.id, position: i, parentId: cat.id }) + }) + }) + + return channels +} + export function ChannelList() { - const { guildSlug } = useParams({ strict: false }) + const { guildSlug, channelId: activeChannelId } = useParams({ strict: false }) + const navigate = useNavigate() + const queryClient = useQueryClient() - const { data } = useQuery({ + const { data, isPending } = useQuery({ queryKey: ["channels", guildSlug], queryFn: async () => { - const res = await apiClient.v1.channels.$get() + const res = await apiClient.v1.guilds[":guildSlug"].channels.$get({ + param: { guildSlug: guildSlug as string }, + }) if (!res.ok) { throw new Error("Failed to fetch channels") } @@ -38,6 +125,213 @@ export function ChannelList() { enabled: !!guildSlug, }) + const reorderMutation = useMutation({ + mutationFn: async ( + channels: { id: string; position: number; parentId: string | null }[] + ) => { + const res = await apiClient.v1.guilds[ + ":guildSlug" + ].channels.reorder.$patch({ + param: { guildSlug: guildSlug as string }, + json: { channels }, + }) + if (!res.ok) { + throw new Error("Failed to reorder channels") + } + }, + onError: () => { + queryClient.invalidateQueries({ queryKey: ["channels", guildSlug] }) + }, + }) + + const [activeItem, setActiveItem] = useState<{ + channel: Channel + isCategory: boolean + } | null>(null) + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }) + ) + + const findChannel = useCallback( + (id: string): { channel: Channel; isCategory: boolean } | null => { + if (!data) return null + for (const ch of data.uncategorized) { + if (ch.id === id) return { channel: ch, isCategory: false } + } + for (const cat of data.categories) { + if (cat.id === id) return { channel: cat, isCategory: true } + for (const ch of cat.channels) { + if (ch.id === id) return { channel: ch, isCategory: false } + } + } + return null + }, + [data] + ) + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const found = findChannel(event.active.id as string) + setActiveItem(found ? { ...found } : null) + }, + [findChannel] + ) + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + if (!data) return + const { active, over } = event + if (!over || active.id === over.id) return + + const activeItem = findChannel(active.id as string) + const overItem = findChannel(over.id as string) + if (!activeItem || !overItem) return + + // Don't allow dragging categories into other categories + if (activeItem.isCategory) return + + // Find which container the active item is in + const activeContainer = activeItem.channel.parentId + // Determine the target container + const overContainer = overItem.isCategory + ? (over.id as string) + : overItem.channel.parentId + + // If moving between containers, update optimistically + if (activeContainer !== overContainer) { + queryClient.setQueryData( + ["channels", guildSlug], + (old: ChannelData | undefined) => { + if (!old) return old + const newData = structuredClone(old) + const activeChannel = active.id as string + + // Remove from source + if (activeContainer === null) { + newData.uncategorized = newData.uncategorized.filter( + (ch) => ch.id !== activeChannel + ) + } else { + const srcCat = newData.categories.find( + (c) => c.id === activeContainer + ) + if (srcCat) { + srcCat.channels = srcCat.channels.filter( + (ch) => ch.id !== activeChannel + ) + } + } + + // Find the channel data + const chData = activeItem.channel + + // Add to destination + if (overContainer === null) { + const overIdx = newData.uncategorized.findIndex( + (ch) => ch.id === over.id + ) + newData.uncategorized.splice( + overIdx >= 0 ? overIdx : newData.uncategorized.length, + 0, + { ...chData, parentId: null } + ) + } else { + const destCat = newData.categories.find( + (c) => c.id === overContainer + ) + if (destCat) { + const overIdx = destCat.channels.findIndex( + (ch) => ch.id === over.id + ) + destCat.channels.splice( + overIdx >= 0 ? overIdx : destCat.channels.length, + 0, + { ...chData, parentId: overContainer } + ) + } + } + + return newData + } + ) + } + }, + [data, findChannel, guildSlug, queryClient] + ) + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + setActiveItem(null) + const { active, over } = event + if (!over || !data || active.id === over.id) return + + const draggedItem = findChannel(active.id as string) + const overItem = findChannel(over.id as string) + if (!draggedItem || !overItem) return + + let newData: ChannelData | undefined + queryClient.setQueryData( + ["channels", guildSlug], + (old: ChannelData | undefined) => { + if (!old) return old + const updated = structuredClone(old) + + if (draggedItem.isCategory && overItem.isCategory) { + // Reorder categories + const oldIdx = updated.categories.findIndex( + (c) => c.id === active.id + ) + const newIdx = updated.categories.findIndex((c) => c.id === over.id) + if (oldIdx >= 0 && newIdx >= 0) { + const moved = updated.categories.splice(oldIdx, 1)[0] + if (moved) updated.categories.splice(newIdx, 0, moved) + } + } else if (!draggedItem.isCategory) { + // Reorder within same container + const container = draggedItem.channel.parentId + if (container === null) { + const oldIdx = updated.uncategorized.findIndex( + (c) => c.id === active.id + ) + const newIdx = updated.uncategorized.findIndex( + (c) => c.id === over.id + ) + if (oldIdx >= 0 && newIdx >= 0) { + const moved = updated.uncategorized.splice(oldIdx, 1)[0] + if (moved) updated.uncategorized.splice(newIdx, 0, moved) + } + } else { + const cat = updated.categories.find((c) => c.id === container) + if (cat) { + const oldIdx = cat.channels.findIndex((c) => c.id === active.id) + const newIdx = cat.channels.findIndex((c) => c.id === over.id) + if (oldIdx >= 0 && newIdx >= 0) { + const moved = cat.channels.splice(oldIdx, 1)[0] + if (moved) cat.channels.splice(newIdx, 0, moved) + } + } + } + } + + newData = updated + return updated + } + ) + + if (newData) { + reorderMutation.mutate(buildReorderPayload(newData)) + } + }, + [data, findChannel, guildSlug, queryClient, reorderMutation] + ) + + if (isPending) { + return + } + if (!data) { return null } @@ -55,49 +349,208 @@ export function ChannelList() { } return ( - + + + {activeItem && ( +
+ {activeItem.isCategory ? ( +
+ + + {activeItem.channel.name ?? ""} + +
+ ) : ( + + )} +
+ )} +
+ + ) +} - {/* Categories with children */} - {data.categories.map((cat) => ( -
- + + {!collapsed && ( + - - {cat.name} - - {cat.channels.map((ch) => ( - - ))} -
- ))} - + ch.id)} + strategy={verticalListSortingStrategy} + disabled={draggingCategory} + > + {channels.map((ch) => ( + onChannelClick?.(ch.id)} + /> + ))} + + + )} + + ) } -function ChannelItem({ +function SortableChannelItem({ + id, name, type, active = false, + onClick, }: { + id: string name: string type: string active?: boolean + onClick?: () => void }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const style = { + transform: CSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + return ( ) diff --git a/apps/web/src/components/sidebar/channel-panel/user-bar.tsx b/apps/web/src/components/sidebar/channel-panel/user-bar.tsx index e54e396..497e75e 100644 --- a/apps/web/src/components/sidebar/channel-panel/user-bar.tsx +++ b/apps/web/src/components/sidebar/channel-panel/user-bar.tsx @@ -1,35 +1,127 @@ import { authClient } from "@repo/auth/client" -import { Settings } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@repo/ui/components/dropdown-menu" +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/toggle-group" +import { + ChevronsUpDown, + Laptop, + LogOut, + Moon, + Settings, + Sun, +} from "lucide-react" +import { useTheme } from "next-themes" import { UserAvatar } from "../../ui/user-avatar" export function UserBar() { const { data: session } = authClient.useSession() + const { setTheme, theme } = useTheme() const name = session?.user.name ?? "User" - const username = session?.user.name ?? "user" + const email = session?.user.email ?? "" + + const handleLogout = async () => { + await authClient.signOut() + } return ( -
-
-
- -
-
-
-
- {username} -
-
- Online -
-
- + + - - -
+ +
+ +
+ {name} + + {email} + +
+
+
+ + e.preventDefault()} + > + Theme + value && setTheme(value)} + > + + + + + + + + + + + + + + + + + + Settings + + + + + + + + Log out + + +
) } diff --git a/apps/web/src/components/sidebar/dm-panel/dm-list.tsx b/apps/web/src/components/sidebar/dm-panel/dm-list.tsx new file mode 100644 index 0000000..2f4ec39 --- /dev/null +++ b/apps/web/src/components/sidebar/dm-panel/dm-list.tsx @@ -0,0 +1,144 @@ +import { + Avatar, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarImage, +} from "@repo/ui/components/avatar" +import { cn } from "@repo/ui/lib/utils" +import { useQuery } from "@tanstack/react-query" +import { useNavigate, useParams } from "@tanstack/react-router" +import { apiClient } from "@/lib/api-client" +import { UserAvatar } from "../../ui/user-avatar" + +export function DMList() { + const navigate = useNavigate() + const { dmId } = useParams({ strict: false }) + + const { data } = useQuery({ + queryKey: ["dms"], + queryFn: async () => { + const res = await apiClient.v1.dms.$get({ query: {} }) + if (!res.ok) throw new Error("Failed to fetch DMs") + return res.json() + }, + }) + + if (!data || data.data.length === 0) { + return ( +
+

No conversations yet.

+
+ ) + } + + return ( + + ) +} + +type DMember = { id: string; name: string | null; image: string | null } + +function getInitials(name: string | null) { + return ( + name + ?.split(" ") + .map((n) => n[0]) + .join("") + .slice(0, 2) + .toUpperCase() ?? "?" + ) +} + +function GroupDMAvatars({ members }: { members: DMember[] }) { + const shown = members.slice(0, 2) + const overflow = members.length - shown.length + + return ( + + {shown.map((m) => ( + + {m.image && } + {getInitials(m.name)} + + ))} + {overflow > 0 && ( + +{overflow} + )} + + ) +} + +function DMItem({ + name, + members, + lastMessage, + lastMessageAuthor, + isGroupDM, + active = false, + onClick, +}: { + name: string + members: DMember[] + lastMessage: string | null + lastMessageAuthor: string | null + isGroupDM: boolean + active?: boolean + onClick?: () => void +}) { + const preview = + isGroupDM && lastMessageAuthor && lastMessage + ? `${lastMessageAuthor}: ${lastMessage}` + : lastMessage + + return ( + + ) +} diff --git a/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx b/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx new file mode 100644 index 0000000..75e8f65 --- /dev/null +++ b/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx @@ -0,0 +1,28 @@ +import { ScrollArea } from "@repo/ui/components/scroll-area" +import { Plus } from "lucide-react" +import { SearchBar } from "../channel-panel/search-bar" +import { UserBar } from "../channel-panel/user-bar" +import { DMList } from "./dm-list" + +export function DMPanel() { + return ( +
+
+

+ Direct Messages +

+ +
+ + + + + +
+ ) +} diff --git a/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx b/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx index 7f9c3a3..4feca09 100644 --- a/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx +++ b/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx @@ -65,24 +65,28 @@ export function GuildBar() { 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 */} @@ -97,7 +101,7 @@ export function GuildBar() { key={guild.id} name={guild.name} logo={guild.logo} - active={activeOrg?.id === guild.id} + active={guildSlug === guild.slug} onClick={() => navigate({ to: "/$guildSlug", diff --git a/apps/web/src/components/sidebar/index.tsx b/apps/web/src/components/sidebar/index.tsx index f1c0d5a..eba0ed5 100644 --- a/apps/web/src/components/sidebar/index.tsx +++ b/apps/web/src/components/sidebar/index.tsx @@ -1,11 +1,36 @@ +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + useDefaultLayout, +} from "@repo/ui/components/resizable" +import { useParams } from "@tanstack/react-router" import { ChannelPanel } from "./channel-panel/channel-panel" +import { DMPanel } from "./dm-panel/dm-panel" import { GuildBar } from "./guild-bar/guild-bar" -export function Sidebar() { +export function Sidebar({ children }: { children: React.ReactNode }) { + const { guildSlug } = useParams({ strict: false }) + + const { defaultLayout, onLayoutChange } = useDefaultLayout({ + groupId: "townhall-sidebar", + storage: localStorage, + }) + return ( -
+
- + + + {guildSlug ? : } + + + {children} +
) } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 11a3c95..523f916 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,19 +1,5 @@ import { createRootRoute, Outlet } from "@tanstack/react-router" -import { lazy, Suspense } from "react" - -const TanStackRouterDevtools = import.meta.env.DEV - ? lazy(() => - import("@tanstack/react-router-devtools").then((mod) => ({ - default: mod.TanStackRouterDevtools, - })) - ) - : () => null export const Route = createRootRoute({ - component: () => ( - <> - - {/**/} - - ), + component: () => , }) diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx index 84f311f..4cf65d8 100644 --- a/apps/web/src/routes/_authenticated.tsx +++ b/apps/web/src/routes/_authenticated.tsx @@ -1,14 +1,22 @@ import { authClient } from "@repo/auth/client" -import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router" +import { + createFileRoute, + Outlet, + useLocation, + useNavigate, +} from "@tanstack/react-router" import { useEffect } from "react" import { Sidebar } from "../components/sidebar" +const LAST_PATH_KEY = "townhall:last-path" + export const Route = createFileRoute("/_authenticated")({ component: AuthenticatedLayout, }) function AuthenticatedLayout() { const navigate = useNavigate() + const location = useLocation() const { data: session, isPending } = authClient.useSession() useEffect(() => { @@ -17,6 +25,12 @@ function AuthenticatedLayout() { } }, [isPending, session, navigate]) + useEffect(() => { + if (location.pathname !== "/") { + localStorage.setItem(LAST_PATH_KEY, location.pathname) + } + }, [location.pathname]) + if (isPending) { return (
@@ -31,8 +45,9 @@ function AuthenticatedLayout() { return (
- - + + +
) } diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx new file mode 100644 index 0000000..7ca1987 --- /dev/null +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/_authenticated/$guildSlug/$channelId")({ + component: ChannelView, +}) + +function ChannelView() { + const { channelId } = Route.useParams() + + return ( +
+ Channel {channelId} +
+ ) +} diff --git a/apps/web/src/routes/_authenticated/dms.tsx b/apps/web/src/routes/_authenticated/dms.tsx new file mode 100644 index 0000000..feb4317 --- /dev/null +++ b/apps/web/src/routes/_authenticated/dms.tsx @@ -0,0 +1,9 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router" + +export const Route = createFileRoute("/_authenticated/dms")({ + component: DMsLayout, +}) + +function DMsLayout() { + return +} diff --git a/apps/web/src/routes/_authenticated/dms/$dmId.tsx b/apps/web/src/routes/_authenticated/dms/$dmId.tsx new file mode 100644 index 0000000..da835e9 --- /dev/null +++ b/apps/web/src/routes/_authenticated/dms/$dmId.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/_authenticated/dms/$dmId")({ + component: DMConversation, +}) + +function DMConversation() { + const { dmId } = Route.useParams() + + return ( +
+ + DM conversation {dmId} + +
+ ) +} diff --git a/apps/web/src/routes/_authenticated/dms/index.tsx b/apps/web/src/routes/_authenticated/dms/index.tsx new file mode 100644 index 0000000..6248c48 --- /dev/null +++ b/apps/web/src/routes/_authenticated/dms/index.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/_authenticated/dms/")({ + component: DMsHome, +}) + +function DMsHome() { + return ( +
+ + Select a conversation to start chatting + +
+ ) +} diff --git a/apps/web/src/routes/_authenticated/index.tsx b/apps/web/src/routes/_authenticated/index.tsx index 53bebbc..7104e06 100644 --- a/apps/web/src/routes/_authenticated/index.tsx +++ b/apps/web/src/routes/_authenticated/index.tsx @@ -1,15 +1,7 @@ -import { createFileRoute } from "@tanstack/react-router" +import { createFileRoute, redirect } from "@tanstack/react-router" export const Route = createFileRoute("/_authenticated/")({ - component: Home, + beforeLoad: () => { + throw redirect({ to: "/dms" }) + }, }) - -function Home() { - return ( -
- - Select a channel to start chatting - -
- ) -} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 743062c..38bf79a 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -12,7 +12,7 @@ import { Input } from "@repo/ui/components/input" import { Label } from "@repo/ui/components/label" import { useMutation } from "@tanstack/react-query" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" -import { type FormEvent, useState } from "react" +import { type FormEvent, useEffect, useState } from "react" export const Route = createFileRoute("/login")({ component: LoginPage, @@ -20,9 +20,16 @@ export const Route = createFileRoute("/login")({ function LoginPage() { const navigate = useNavigate() + const { data: session, isPending: sessionPending } = authClient.useSession() const [email, setEmail] = useState("") const [password, setPassword] = useState("") + useEffect(() => { + if (!sessionPending && session) { + navigate({ to: "/" }) + } + }, [sessionPending, session, navigate]) + const { mutate: signIn, isPending, diff --git a/apps/web/src/routes/signup.tsx b/apps/web/src/routes/signup.tsx index bc3daaa..4e93d20 100644 --- a/apps/web/src/routes/signup.tsx +++ b/apps/web/src/routes/signup.tsx @@ -12,7 +12,7 @@ import { Input } from "@repo/ui/components/input" import { Label } from "@repo/ui/components/label" import { useMutation } from "@tanstack/react-query" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" -import { type FormEvent, useState } from "react" +import { type FormEvent, useEffect, useState } from "react" export const Route = createFileRoute("/signup")({ component: SignUpPage, @@ -20,11 +20,18 @@ export const Route = createFileRoute("/signup")({ function SignUpPage() { const navigate = useNavigate() + const { data: session, isPending: sessionPending } = authClient.useSession() const [name, setName] = useState("") const [username, setUsername] = useState("") const [email, setEmail] = useState("") const [password, setPassword] = useState("") + useEffect(() => { + if (!sessionPending && session) { + navigate({ to: "/" }) + } + }, [sessionPending, session, navigate]) + const { mutate: signUp, isPending, diff --git a/packages/auth/src/lib/auth.ts b/packages/auth/src/lib/auth.ts index 019ed99..e114b42 100644 --- a/packages/auth/src/lib/auth.ts +++ b/packages/auth/src/lib/auth.ts @@ -19,6 +19,12 @@ export const auth = betterAuth({ generateId: false, }, }, + session: { + cookieCache: { + enabled: true, + maxAge: 5 * 60, + }, + }, plugins: [ organization({ schema: { diff --git a/packages/ui/package.json b/packages/ui/package.json index 6d3d30b..ceb0e3c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,6 +21,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-resizable-panels": "^4", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0" }, diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx new file mode 100644 index 0000000..f335ec9 --- /dev/null +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -0,0 +1,256 @@ +"use client" + +import { cn } from "@repo/ui/lib/utils" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" +import type * as React from "react" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/packages/ui/src/components/resizable.tsx b/packages/ui/src/components/resizable.tsx new file mode 100644 index 0000000..d9c4e7b --- /dev/null +++ b/packages/ui/src/components/resizable.tsx @@ -0,0 +1,53 @@ +"use client" + +import { cn } from "@repo/ui/lib/utils" +import { GripVerticalIcon } from "lucide-react" +import * as ResizablePrimitive from "react-resizable-panels" + +function ResizablePanelGroup({ + className, + ...props +}: ResizablePrimitive.GroupProps) { + return ( + + ) +} + +function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { + return +} + +function ResizableHandle({ + withHandle, + className, + ...props +}: ResizablePrimitive.SeparatorProps & { + withHandle?: boolean +}) { + return ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+ ) +} + +export { useDefaultLayout } from "react-resizable-panels" +export { ResizableHandle, ResizablePanel, ResizablePanelGroup } diff --git a/packages/ui/src/components/skeleton.tsx b/packages/ui/src/components/skeleton.tsx new file mode 100644 index 0000000..a06e5a8 --- /dev/null +++ b/packages/ui/src/components/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@repo/ui/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/packages/ui/src/components/toggle-group.tsx b/packages/ui/src/components/toggle-group.tsx new file mode 100644 index 0000000..74fb4f8 --- /dev/null +++ b/packages/ui/src/components/toggle-group.tsx @@ -0,0 +1,82 @@ +"use client" + +import { toggleVariants } from "@repo/ui/components/toggle" +import { cn } from "@repo/ui/lib/utils" +import type { VariantProps } from "class-variance-authority" +import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui" +import * as React from "react" + +const ToggleGroupContext = React.createContext< + VariantProps & { + spacing?: number + } +>({ + size: "default", + variant: "default", + spacing: 0, +}) + +function ToggleGroup({ + className, + variant, + size, + spacing = 0, + children, + ...props +}: React.ComponentProps & + VariantProps & { + spacing?: number + }) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant, + size, + ...props +}: React.ComponentProps & + VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/packages/ui/src/components/toggle.tsx b/packages/ui/src/components/toggle.tsx new file mode 100644 index 0000000..bf28204 --- /dev/null +++ b/packages/ui/src/components/toggle.tsx @@ -0,0 +1,46 @@ +"use client" + +import { cn } from "@repo/ui/lib/utils" +import { cva, type VariantProps } from "class-variance-authority" +import { Toggle as TogglePrimitive } from "radix-ui" +import type * as React from "react" + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Toggle({ + className, + variant, + size, + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +export { Toggle, toggleVariants } diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index e62e90d..b861ad2 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -49,68 +49,97 @@ :root { --radius: 0.625rem; + --background: oklch(0.99 0.009 78.24); --foreground: oklch(0.201 0.014 34.34); + --card: oklch(0.975 0.019 72.54); --card-foreground: oklch(0.201 0.014 34.34); + --popover: oklch(0.975 0.019 72.54); --popover-foreground: oklch(0.201 0.014 34.34); - --primary: oklch(0.414 0.105 45.89); - --primary-foreground: oklch(0.951 0.011 54.29); + + /* ↓ CHANGED: deep amber — rich, readable, on-brand */ + --primary: oklch(0.54 0.138 55); + --primary-foreground: oklch(0.97 0.008 54); + --secondary: oklch(0.932 0.03 73.87); --secondary-foreground: oklch(0.201 0.014 34.34); + --muted: oklch(0.954 0.037 75.15); --muted-foreground: oklch(0.481 0.029 57.14); - --accent: oklch(0.837 0.164 84.43); + + /* ↓ CHANGED: quiet warm sand replaces vibrant yellow-gold */ + --accent: oklch(0.935 0.025 72); --accent-foreground: oklch(0.201 0.014 34.34); + --destructive: oklch(0.637 0.208 25.32); + --border: oklch(0.883 0.035 70.11); --input: oklch(0.883 0.035 70.11); - --ring: oklch(0.666 0.157 58.31); + + /* ↓ CHANGED: ring matches new primary */ + --ring: oklch(0.54 0.138 55); + --chart-1: oklch(0.414 0.105 45.89); --chart-2: oklch(0.769 0.165 70.08); --chart-3: oklch(0.666 0.157 58.31); --chart-4: oklch(0.723 0.192 149.6); --chart-5: oklch(0.623 0.188 259.82); + --sidebar: oklch(0.975 0.019 72.54); --sidebar-foreground: oklch(0.201 0.014 34.34); - --sidebar-primary: oklch(0.414 0.105 45.89); - --sidebar-primary-foreground: oklch(0.951 0.011 54.29); - --sidebar-accent: oklch(0.954 0.037 75.15); + /* ↓ CHANGED: sidebar primary matches new primary */ + --sidebar-primary: oklch(0.54 0.138 55); + --sidebar-primary-foreground: oklch(0.97 0.008 54); + --sidebar-accent: oklch(0.935 0.025 72); --sidebar-accent-foreground: oklch(0.201 0.014 34.34); --sidebar-border: oklch(0.883 0.035 70.11); - --sidebar-ring: oklch(0.666 0.157 58.31); + --sidebar-ring: oklch(0.54 0.138 55); } .dark { --background: oklch(0.143 0.007 59.16); --foreground: oklch(0.951 0.011 54.29); + --card: oklch(0.201 0.014 34.34); --card-foreground: oklch(0.951 0.011 54.29); + --popover: oklch(0.201 0.014 34.34); --popover-foreground: oklch(0.951 0.011 54.29); + + /* dark primary stays gold — already correct */ --primary: oklch(0.769 0.165 70.08); --primary-foreground: oklch(0.143 0.007 59.16); + --secondary: oklch(0.241 0.018 47.91); --secondary-foreground: oklch(0.951 0.011 54.29); + --muted: oklch(0.279 0.023 40.5); --muted-foreground: oklch(0.682 0.032 62.32); - --accent: oklch(0.666 0.157 58.31); + + /* ↓ CHANGED: calm warm dark tone for hover — not saturated amber */ + --accent: oklch(0.255 0.018 60); --accent-foreground: oklch(0.951 0.011 54.29); + --destructive: oklch(0.637 0.208 25.32); + --border: oklch(0.337 0.029 49.2); --input: oklch(0.279 0.023 40.5); --ring: oklch(0.769 0.165 70.08); + --chart-1: oklch(0.769 0.165 70.08); --chart-2: oklch(0.837 0.164 84.43); --chart-3: oklch(0.569 0.135 49.95); --chart-4: oklch(0.723 0.192 149.6); --chart-5: oklch(0.623 0.188 259.82); + --sidebar: oklch(0.201 0.014 34.34); --sidebar-foreground: oklch(0.951 0.011 54.29); --sidebar-primary: oklch(0.769 0.165 70.08); --sidebar-primary-foreground: oklch(0.143 0.007 59.16); - --sidebar-accent: oklch(0.279 0.023 40.5); + /* ↓ CHANGED: sidebar accent matches new calm dark accent */ + --sidebar-accent: oklch(0.255 0.018 60); --sidebar-accent-foreground: oklch(0.951 0.011 54.29); --sidebar-border: oklch(0.337 0.029 49.2); --sidebar-ring: oklch(0.769 0.165 70.08); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7058f67..d9b5c3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,15 @@ importers: apps/web: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.4) '@repo/api-client': specifier: workspace:* version: link:../../packages/api-client @@ -105,6 +114,9 @@ importers: lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) + motion: + specifier: ^12.34.0 + version: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -323,6 +335,9 @@ importers: react-dom: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + react-resizable-panels: + specifier: ^4 + version: 4.6.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -576,6 +591,28 @@ packages: cpu: [x64] os: [win32] + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@dotenvx/dotenvx@1.52.0': resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} hasBin: true @@ -3091,6 +3128,20 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@12.34.0: + resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3528,6 +3579,26 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + motion-dom@12.34.0: + resolution: {integrity: sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==} + + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + + motion@12.34.0: + resolution: {integrity: sha512-01Sfa/zgsD/di8zA/uFW5Eb7/SPXoGyUfy+uMRMW5Spa8j0z/UbfQewAYvPMYFCXRlyD6e5aLHh76TxeeJD+RA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3862,6 +3933,12 @@ packages: '@types/react': optional: true + react-resizable-panels@4.6.4: + resolution: {integrity: sha512-E7Szs1xyaMZ7xOI2gG4TECNz4r/gmpV1AsXyZRnER6OQnfFf9uclFmrHHZR3h/iF8vQS+nQ1LKyZv9bzwGxPSg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -4713,6 +4790,31 @@ snapshots: '@biomejs/cli-win32-x64@2.3.14': optional: true + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + '@dotenvx/dotenvx@1.52.0': dependencies: commander: 11.1.0 @@ -6900,6 +7002,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.34.0 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fresh@2.0.0: {} fs-extra@11.3.3: @@ -7224,6 +7335,20 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + motion-dom@12.34.0: + dependencies: + motion-utils: 12.29.2 + + motion-utils@12.29.2: {} + + motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + framer-motion: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + ms@2.1.3: {} msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3): @@ -7614,6 +7739,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.13 + react-resizable-panels@4.6.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-style-singleton@2.2.3(@types/react@19.2.13)(react@19.2.4): dependencies: get-nonce: 1.0.1