diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dbfc1ec..8b82992 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,5 +1,4 @@ import { auth } from "@repo/auth" -import { env } from "@repo/env/server" import { cors } from "hono/cors" import createApp from "@/lib/helpers/app/create-app" import configureOpenAPI from "@/lib/helpers/openapi/configure-openapi" @@ -13,10 +12,7 @@ const app = createApp() app.use( "*", cors({ - origin: - env.NODE_ENV === "development" - ? "http://localhost:3000" - : env.NEXT_PUBLIC_API_URL, + origin: (origin) => origin, credentials: true, }) ) diff --git a/apps/web/public/townhall-onboarding.png b/apps/web/public/townhall-onboarding.png new file mode 100644 index 0000000..2cbcdc9 Binary files /dev/null and b/apps/web/public/townhall-onboarding.png differ diff --git a/apps/web/public/townhall-onboarding2.png b/apps/web/public/townhall-onboarding2.png new file mode 100644 index 0000000..a76e237 Binary files /dev/null and b/apps/web/public/townhall-onboarding2.png differ diff --git a/apps/web/src/components/onboarding/onboarding-dialog.tsx b/apps/web/src/components/onboarding/onboarding-dialog.tsx new file mode 100644 index 0000000..ba3aacc --- /dev/null +++ b/apps/web/src/components/onboarding/onboarding-dialog.tsx @@ -0,0 +1,285 @@ +"use client" + +import { authClient } from "@repo/auth/client" +import { Button } from "@repo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog" +import { Input } from "@repo/ui/components/input" +import { Label } from "@repo/ui/components/label" +import { useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { ArrowLeft, Loader2, Plus, Users } from "lucide-react" +import { useEffect, useState } from "react" + +type Step = "welcome" | "create" | "join" + +function toSlug(name: string) { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 50) +} + +export function OnboardingDialog({ open }: { open: boolean }) { + const [step, setStep] = useState("welcome") + const [name, setName] = useState("") + const [slug, setSlug] = useState("") + const [slugEdited, setSlugEdited] = useState(false) + const [inviteLink, setInviteLink] = useState("") + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const queryClient = useQueryClient() + const navigate = useNavigate() + + useEffect(() => { + if (!slugEdited) { + setSlug(toSlug(name)) + } + }, [name, slugEdited]) + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim() || !slug.trim()) return + setError(null) + setLoading(true) + + try { + const res = await authClient.organization.create({ + name: name.trim(), + slug: slug.trim(), + }) + + if (res.error) { + setError(res.error.message ?? "Failed to create guild") + setLoading(false) + return + } + + // Best-effort — guild was created successfully so proceed regardless + try { + await authClient.updateUser({ onboardingCompleted: true }) + } catch { + // Non-blocking: dialog will reappear next session but guild exists + setError("Guild created, but failed to save onboarding state.") + } + + await queryClient.invalidateQueries({ queryKey: ["guilds"] }) + navigate({ to: "/$guildSlug", params: { guildSlug: slug.trim() } }) + } catch { + setError("Something went wrong. Please try again.") + setLoading(false) + } + } + + const handleJoin = async (e: React.FormEvent) => { + e.preventDefault() + if (!inviteLink.trim()) return + setError(null) + setLoading(true) + // TODO: implement join via invite link API + setError("Joining via invite link is not yet supported.") + setLoading(false) + } + + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + className="overflow-hidden p-0 sm:max-w-2xl" + > +
+ {/* Left decorative panel */} +
+ A medieval campsite at night +
+ + {/* Right content panel */} +
+ {step === "welcome" && ( + <> + + + Welcome to Townhall + + + Get started by creating a new guild or joining one you've + been invited to. + + + +
+ + + +
+ + )} + + {step === "create" && ( + <> + + + + Create a Guild + + Give your community a name and a unique URL. + + + +
+
+ + setName(e.target.value)} + disabled={loading} + autoFocus + /> +
+ +
+ +
+ + townhall.chat/ + + { + setSlugEdited(true) + setSlug(toSlug(e.target.value)) + }} + disabled={loading} + /> +
+
+ + {error &&

{error}

} + + +
+ + )} + + {step === "join" && ( + <> + + + + Join a Guild + + Paste an invite link to join an existing guild. + + + +
+
+ + setInviteLink(e.target.value)} + disabled={loading} + autoFocus + /> +
+ + {error &&

{error}

} + + +
+ + )} +
+
+
+
+ ) +} 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 7836b18..0772ff4 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -15,6 +15,13 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@repo/ui/components/dropdown-menu" import { Skeleton } from "@repo/ui/components/skeleton" import { cn } from "@repo/ui/lib/utils" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" @@ -24,6 +31,7 @@ import { Hash, Megaphone, MessageSquare, + MoreHorizontal, Volume2, } from "lucide-react" import { AnimatePresence, motion } from "motion/react" @@ -535,6 +543,8 @@ function SortableChannelItem({ isDragging, } = useSortable({ id }) + const [menuOpen, setMenuOpen] = useState(false) + const style = { transform: CSS.Translate.toString(transform), transition, @@ -542,18 +552,20 @@ function SortableChannelItem({ } return ( - + + e.stopPropagation()} + className={cn( + "ml-auto flex size-5 items-center justify-center rounded opacity-0 hover:bg-foreground/10 group-hover:opacity-100", + menuOpen && "opacity-100" + )} + > + + + + {/* TODO: handleEditChannel */} + Edit Channel + {/* TODO: handleCopyChannelId */} + Copy Channel ID + + {/* TODO: handleDeleteChannel — requires confirmation */} + + Delete Channel + + + + ) } diff --git a/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx b/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx index f63a0c3..244c818 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx @@ -9,7 +9,7 @@ export function ChannelPanel() {
- + 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 497e75e..db87e33 100644 --- a/apps/web/src/components/sidebar/channel-panel/user-bar.tsx +++ b/apps/web/src/components/sidebar/channel-panel/user-bar.tsx @@ -9,6 +9,7 @@ import { DropdownMenuTrigger, } from "@repo/ui/components/dropdown-menu" import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/toggle-group" +import { useNavigate } from "@tanstack/react-router" import { ChevronsUpDown, Laptop, @@ -23,105 +24,126 @@ import { UserAvatar } from "../../ui/user-avatar" export function UserBar() { const { data: session } = authClient.useSession() const { setTheme, theme } = useTheme() + const navigate = useNavigate() const name = session?.user.name ?? "User" const email = session?.user.email ?? "" const handleLogout = async () => { - await authClient.signOut() + try { + await authClient.signOut() + } catch (err) { + console.error("Sign out failed:", err) + } + } + + const handleOpenSettings = () => { + // TODO: Navigate to settings page + // navigate({ to: "/settings" }) } return (
- - - - - - -
- -
- {name} - - {email} - + + + + + +
+ +
+ {name} + + {email} + +
-
- + - e.preventDefault()} - > - Theme - value && setTheme(value)} + e.preventDefault()} > - value && setTheme(value)} > - - - - - - - - - - + + + + + + + + + + + - + - - - - Settings - - + + + + Settings + + - + - - - Log out - - - + + + Log out + + + + +
) } diff --git a/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx b/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx index 75e8f65..e8d08f0 100644 --- a/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx +++ b/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx @@ -1,16 +1,49 @@ import { ScrollArea } from "@repo/ui/components/scroll-area" -import { Plus } from "lucide-react" +import { Separator } from "@repo/ui/components/separator" +import { cn } from "@repo/ui/lib/utils" +import { useNavigate, useParams } from "@tanstack/react-router" +import { Inbox, Plus, Users } 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() { + const navigate = useNavigate() + const { dmId } = useParams({ strict: false }) + return (
-
-

+ +
+ + {/* TODO: implement navigateToMessageRequests */} + +
+ +
+ Direct Messages -

+
- - + diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx index 4cf65d8..f58a948 100644 --- a/apps/web/src/routes/_authenticated.tsx +++ b/apps/web/src/routes/_authenticated.tsx @@ -1,4 +1,5 @@ import { authClient } from "@repo/auth/client" +import { useQuery } from "@tanstack/react-query" import { createFileRoute, Outlet, @@ -6,6 +7,7 @@ import { useNavigate, } from "@tanstack/react-router" import { useEffect } from "react" +import { OnboardingDialog } from "../components/onboarding/onboarding-dialog" import { Sidebar } from "../components/sidebar" const LAST_PATH_KEY = "townhall:last-path" @@ -31,6 +33,15 @@ function AuthenticatedLayout() { } }, [location.pathname]) + const { data: guilds } = useQuery({ + queryKey: ["guilds"], + queryFn: async () => { + const res = await authClient.organization.list() + return res.data + }, + enabled: !!session, + }) + if (isPending) { return (
@@ -43,11 +54,19 @@ function AuthenticatedLayout() { return null } + // Only show onboarding if explicitly not completed AND no existing guilds + // (guards against existing users whose flag defaulted to false) + const showOnboarding = + session.user.onboardingCompleted === false && + guilds !== undefined && + guilds?.length === 0 + return (
+
) } diff --git a/packages/auth/src/lib/auth.ts b/packages/auth/src/lib/auth.ts index e114b42..2e7abaf 100644 --- a/packages/auth/src/lib/auth.ts +++ b/packages/auth/src/lib/auth.ts @@ -8,8 +8,19 @@ export const auth = betterAuth({ baseURL: env.NEXT_PUBLIC_API_URL, database: drizzleAdapter(db, { provider: "pg", schema }), secret: env.BETTER_AUTH_SECRET, + user: { + additionalFields: { + onboardingCompleted: { + type: "boolean", + defaultValue: false, + returned: true, + }, + }, + }, trustedOrigins: - env.NODE_ENV === "development" ? ["http://localhost:3000"] : [], + env.NODE_ENV === "development" + ? ["http://localhost:3000", "http://localhost:3001"] + : [], emailAndPassword: { enabled: true, }, @@ -36,11 +47,11 @@ export const auth = betterAuth({ fieldName: "ownerId", references: { field: "id", - table: "user", model: "user", onDelete: "restrict", }, - required: true, + required: false, + input: false, returned: true, }, }, @@ -68,6 +79,11 @@ export const auth = betterAuth({ }, }, }, + organizationHooks: { + beforeCreateOrganization: async ({ organization, user }) => { + return { data: { ...organization, ownerId: user.id } } + }, + }, dynamicAccessControl: { enabled: true, }, diff --git a/packages/db/src/schemas/users.ts b/packages/db/src/schemas/users.ts index 3a0b66a..e6410bf 100644 --- a/packages/db/src/schemas/users.ts +++ b/packages/db/src/schemas/users.ts @@ -25,6 +25,7 @@ export const user = pgTable("user", { username: text("username").unique(), displayUsername: text("display_username"), twoFactorEnabled: boolean("two_factor_enabled").default(false), + onboardingCompleted: boolean("onboarding_completed").default(false).notNull(), }) export const userRelations = relations(user, ({ many }) => ({ diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx new file mode 100644 index 0000000..8d50f68 --- /dev/null +++ b/packages/ui/src/components/dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import { Button } from "@repo/ui/components/button" +import { cn } from "@repo/ui/lib/utils" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" +import type * as React from "react" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}