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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
})
)
Expand Down
Binary file added apps/web/public/townhall-onboarding.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/townhall-onboarding2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
285 changes: 285 additions & 0 deletions apps/web/src/components/onboarding/onboarding-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function OnboardingDialog({ open }: { open: boolean }) {
const [step, setStep] = useState<Step>("welcome")
const [name, setName] = useState("")
const [slug, setSlug] = useState("")
const [slugEdited, setSlugEdited] = useState(false)
const [inviteLink, setInviteLink] = useState("")
const [error, setError] = useState<string | null>(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() } })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
setError("Something went wrong. Please try again.")
setLoading(false)
}
}
Comment thread
BuckyMcYolo marked this conversation as resolved.

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 (
<Dialog open={open}>
<DialogContent
showCloseButton={false}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className="overflow-hidden p-0 sm:max-w-2xl"
>
<div className="flex min-h-[460px]">
{/* Left decorative panel */}
<div className="relative hidden w-[220px] shrink-0 overflow-hidden sm:block">
<img
src="/townhall-onboarding2.png"
alt="A medieval campsite at night"
className="absolute inset-0 h-full w-full object-cover"
/>
</div>

{/* Right content panel */}
<div className="flex flex-1 flex-col justify-center p-8">
{step === "welcome" && (
<>
<DialogHeader className="mb-8 text-left">
<DialogTitle className="text-2xl">
Welcome to Townhall
</DialogTitle>
<DialogDescription className="text-sm">
Get started by creating a new guild or joining one you've
been invited to.
</DialogDescription>
</DialogHeader>

<div className="space-y-3">
<button
type="button"
onClick={() => setStep("create")}
className="group flex w-full items-center gap-4 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-accent"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary group-hover:bg-primary/20">
<Plus className="size-5" />
</div>
<div>
<p className="font-medium">Create a Guild</p>
<p className="text-sm text-muted-foreground">
Start your own community from scratch
</p>
</div>
</button>

<button
type="button"
onClick={() => setStep("join")}
className="group flex w-full items-center gap-4 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-accent"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary group-hover:bg-primary/20">
<Users className="size-5" />
</div>
<div>
<p className="font-medium">Join an Existing Guild</p>
<p className="text-sm text-muted-foreground">
Enter an invite link to join a guild
</p>
</div>
</button>
</div>
</>
)}

{step === "create" && (
<>
<button
type="button"
onClick={() => {
setStep("welcome")
setError(null)
}}
className="mb-6 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" />
Back
</button>

<DialogHeader className="mb-6 text-left">
<DialogTitle className="text-2xl">Create a Guild</DialogTitle>
<DialogDescription className="text-sm">
Give your community a name and a unique URL.
</DialogDescription>
</DialogHeader>

<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="guild-name">Guild Name</Label>
<Input
id="guild-name"
placeholder="My Awesome Guild"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
autoFocus
/>
</div>

<div className="space-y-1.5">
<Label htmlFor="guild-slug">Slug</Label>
<div className="flex items-center rounded-md border border-input bg-muted px-3 text-sm focus-within:ring-1 focus-within:ring-ring">
<span className="shrink-0 text-muted-foreground">
townhall.chat/
</span>
<Input
id="guild-slug"
className="min-w-0 flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0 px-1"
placeholder="my-awesome-guild"
value={slug}
onChange={(e) => {
setSlugEdited(true)
setSlug(toSlug(e.target.value))
}}
disabled={loading}
/>
</div>
</div>

{error && <p className="text-sm text-destructive">{error}</p>}

<Button
type="submit"
className="w-full"
disabled={loading || !name.trim() || !slug.trim()}
>
{loading && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Create Guild
</Button>
</form>
</>
)}

{step === "join" && (
<>
<button
type="button"
onClick={() => {
setStep("welcome")
setError(null)
}}
className="mb-6 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" />
Back
</button>

<DialogHeader className="mb-6 text-left">
<DialogTitle className="text-2xl">Join a Guild</DialogTitle>
<DialogDescription className="text-sm">
Paste an invite link to join an existing guild.
</DialogDescription>
</DialogHeader>

<form onSubmit={handleJoin} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="invite-link">Invite Link</Label>
<Input
id="invite-link"
placeholder="https://townhall.gg/invite/abc123"
value={inviteLink}
onChange={(e) => setInviteLink(e.target.value)}
disabled={loading}
autoFocus
/>
</div>

{error && <p className="text-sm text-destructive">{error}</p>}

<Button
type="submit"
className="w-full"
disabled={loading || !inviteLink.trim()}
>
{loading && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Join Guild
</Button>
</form>
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
49 changes: 43 additions & 6 deletions apps/web/src/components/sidebar/channel-panel/channel-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -24,6 +31,7 @@ import {
Hash,
Megaphone,
MessageSquare,
MoreHorizontal,
Volume2,
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
Expand Down Expand Up @@ -535,33 +543,62 @@ function SortableChannelItem({
isDragging,
} = useSortable({ id })

const [menuOpen, setMenuOpen] = useState(false)

const style = {
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}

return (
<button
// biome-ignore lint/a11y/useSemanticElements: dnd-kit requires a div here to avoid nested interactive elements
// biome-ignore lint/a11y/noStaticElementInteractions: dnd-kit requires a div here
// biome-ignore lint/a11y/useKeyWithClickEvents: dnd-kit handles keyboard interactions
<div
ref={setNodeRef}
style={style}
type="button"
onClick={onClick}
{...attributes}
{...listeners}
className={cn(
"group relative flex w-full items-center gap-2 rounded-lg px-2 py-[6px] text-[14px] hover:bg-foreground/[0.06] cursor-pointer",
active
? "bg-foreground/[0.06] font-medium text-foreground"
: "text-muted-foreground"
active && "bg-foreground/[0.06] font-medium text-foreground",
!active && "text-muted-foreground",
menuOpen && "bg-foreground/[0.06]"
)}
>
{active && (
<div className="absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-primary" />
)}
<ChannelIcon type={type} />
<span className="truncate">{name}</span>
</button>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
onClick={(e) => 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"
)}
>
<MoreHorizontal className="size-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
{/* TODO: handleEditChannel */}
<DropdownMenuItem disabled>Edit Channel</DropdownMenuItem>
{/* TODO: handleCopyChannelId */}
<DropdownMenuItem disabled>Copy Channel ID</DropdownMenuItem>
<DropdownMenuSeparator />
{/* TODO: handleDeleteChannel — requires confirmation */}
<DropdownMenuItem
disabled
className="text-destructive focus:text-destructive"
>
Delete Channel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

Expand Down
Loading