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 19708ee..377d8f3 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -17,6 +17,7 @@ import { import { CSS } from "@dnd-kit/utilities" import { authClient } from "@repo/auth/client" import type { GuildRole } from "@repo/auth/permissions" +import { Button } from "@repo/ui/components/button" import { DropdownMenu, DropdownMenuContent, @@ -34,6 +35,7 @@ import { Megaphone, MessageSquare, MoreHorizontal, + Plus, Volume2, } from "lucide-react" import { AnimatePresence, motion } from "motion/react" @@ -42,7 +44,12 @@ import { useMobileSidebar } from "@/context/mobile-sidebar-context" import { useUnread } from "@/context/unread-context" import { apiClient } from "@/lib/api-client" import type { Channel, ListChannelsResponse } from "@/lib/api-types" -import { canDeleteChannels, canManageChannels } from "@/lib/permissions" +import { + canCreateChannels, + canDeleteChannels, + canManageChannels, +} from "@/lib/permissions" +import { CreateChannelDialog } from "./create-channel-dialog" import { DeleteChannelDialog } from "./delete-channel-dialog" import { EditChannelDialog } from "./edit-channel-dialog" @@ -153,6 +160,9 @@ export function ChannelList() { }, enabled: !!guildSlug, }) + const canCreate = activeMember?.role + ? canCreateChannels(activeMember.role as GuildRole) + : false const canManage = activeMember?.role ? canManageChannels(activeMember.role as GuildRole) : false @@ -160,6 +170,9 @@ export function ChannelList() { ? canDeleteChannels(activeMember.role as GuildRole) : false + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [createParentId, setCreateParentId] = useState(null) + const [activeItem, setActiveItem] = useState<{ channel: Channel isCategory: boolean @@ -365,93 +378,117 @@ export function ChannelList() { } return ( - - - - - {activeItem && ( -
- {activeItem.isCategory ? ( -
- - - {activeItem.channel.name ?? ""} - -
- ) : ( - - )} -
- )} -
-
+ )} + + + + ) } diff --git a/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx b/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx new file mode 100644 index 0000000..1dfd806 --- /dev/null +++ b/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx @@ -0,0 +1,168 @@ +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/components/select" +import { useQueryClient } from "@tanstack/react-query" +import { useNavigate, useParams } from "@tanstack/react-router" +import { Hash, Loader2, Megaphone } from "lucide-react" +import { useState } from "react" +import { apiClient } from "@/lib/api-client" + +const channelTypes = [ + { value: "text", label: "Text Channel", icon: Hash }, + { value: "announcement", label: "Announcement", icon: Megaphone }, +] as const + +export function CreateChannelDialog({ + open, + onOpenChange, + parentId, +}: { + open: boolean + onOpenChange: (open: boolean) => void + parentId?: string | null +}) { + const { guildSlug } = useParams({ strict: false }) + const navigate = useNavigate() + const queryClient = useQueryClient() + const [name, setName] = useState("") + const [type, setType] = useState<"text" | "announcement">("text") + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault() + const trimmed = name.trim() + if (!trimmed || !guildSlug) return + setError(null) + setLoading(true) + + try { + const res = await apiClient.v1.guilds[":guildSlug"].channels.$post({ + param: { guildSlug }, + json: { + name: trimmed.toLowerCase().replace(/\s+/g, "-"), + type, + ...(parentId ? { parentId } : {}), + }, + }) + + if (!res.ok) { + const data = await res.json().catch(() => null) + setError( + (data as { message?: string } | null)?.message ?? + "Failed to create channel" + ) + return + } + + const channel = await res.json() + await queryClient.invalidateQueries({ + queryKey: ["channels", guildSlug], + }) + onOpenChange(false) + setName("") + setType("text") + setError(null) + + navigate({ + to: "/$guildSlug/$channelId", + params: { guildSlug, channelId: channel.id }, + }) + } catch { + setError("Something went wrong. Please try again.") + } finally { + setLoading(false) + } + } + + const handleOpenChange = (open: boolean) => { + if (!open) { + setName("") + setType("text") + setError(null) + } + onOpenChange(open) + } + + const normalizedName = name + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, "") + + return ( + + + + Create Channel + + Add a new channel to your guild. + + +
+
+ + +
+
+ + setName(e.target.value)} + disabled={loading} + autoFocus + /> + {normalizedName && normalizedName !== name.trim() && ( +

+ Will be created as{" "} + #{normalizedName} +

+ )} +
+ {error &&

{error}

} + +
+
+
+ ) +} diff --git a/apps/web/src/lib/permissions.ts b/apps/web/src/lib/permissions.ts index 446571a..b3a6bc5 100644 --- a/apps/web/src/lib/permissions.ts +++ b/apps/web/src/lib/permissions.ts @@ -5,6 +5,12 @@ import { roleHasPermissions, } from "@repo/auth/permissions" +export function canCreateChannels(role: GuildRole): boolean { + return roleHasPermissions(role, { + channel: ["create"], + }) +} + export function canManageChannels(role: GuildRole): boolean { return roleHasPermissions(role, { channel: ["update"], diff --git a/apps/www/app/components/copy-terminal.tsx b/apps/www/app/components/copy-terminal.tsx new file mode 100644 index 0000000..ac0fd90 --- /dev/null +++ b/apps/www/app/components/copy-terminal.tsx @@ -0,0 +1,64 @@ +"use client" + +import { Check, Copy } from "lucide-react" +import { useState } from "react" + +const CLONE_COMMAND = "git clone https://github.com/BuckyMcYolo/townhall" + +export function CopyTerminal() { + const [copied, setCopied] = useState(false) + + const handleCopy = () => { + navigator.clipboard.writeText(CLONE_COMMAND) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
+
+
+
+ + zsh + +
+
+ {/* Copyable command line */} +
e.key === "Enter" && handleCopy()} + className="group -mx-3 flex cursor-pointer items-center rounded-md px-3 py-1 transition-colors hover:bg-foreground/[0.06]" + > +
+ ~ + $ + {CLONE_COMMAND} +
+ + {copied ? ( + + ) : ( + + )} + +
+ +
+ Cloning into 'townhall'... +
+
+ remote: Enumerating objects: done. +
+
+ ~/townhall + $ + +
+
+
+ ) +} diff --git a/apps/www/app/components/waitlist-form.tsx b/apps/www/app/components/waitlist-form.tsx deleted file mode 100644 index 2d43041..0000000 --- a/apps/www/app/components/waitlist-form.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client" - -import { standardSchemaResolver } from "@hookform/resolvers/standard-schema" -import { Button } from "@repo/ui/components/button" -import { Input } from "@repo/ui/components/input" -import { ArrowRight, Loader2 } from "lucide-react" -import { useState } from "react" -import { useForm } from "react-hook-form" -import { z } from "zod" -import { apiClient } from "@/lib/api-client" - -const schema = z.object({ - email: z.string().email("Please enter a valid email address"), -}) - -type FormData = z.infer - -export function WaitlistForm() { - const [status, setStatus] = useState<"idle" | "success" | "error">("idle") - const [errorMessage, setErrorMessage] = useState("") - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - reset, - } = useForm({ - resolver: standardSchemaResolver(schema), - mode: "onSubmit", - }) - - const onSubmit = async (data: FormData) => { - setStatus("idle") - setErrorMessage("") - - try { - const res = await apiClient.waitlist.$post({ - json: { email: data.email }, - }) - - if (!res.ok) { - const json = (await res.json().catch(() => null)) as { - error?: string - } | null - setStatus("error") - setErrorMessage( - typeof json?.error === "string" ? json.error : "Something went wrong" - ) - return - } - - setStatus("success") - reset() - } catch { - setStatus("error") - setErrorMessage("Unable to connect. Please try again.") - } - } - - if (status === "success") { - return ( -

- You're on the list. We'll be in touch. -

- ) - } - - return ( -
-
- - -
- {errors.email && ( -

{errors.email.message}

- )} - {status === "error" && ( -

{errorMessage}

- )} -
- ) -} diff --git a/apps/www/app/download/page.tsx b/apps/www/app/download/page.tsx new file mode 100644 index 0000000..42c83cf --- /dev/null +++ b/apps/www/app/download/page.tsx @@ -0,0 +1,215 @@ +import { Button } from "@repo/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@repo/ui/components/card" +import { Separator } from "@repo/ui/components/separator" +import { Download, ExternalLink, Github, Globe } from "lucide-react" +import type { Metadata } from "next" +import Image from "next/image" +import Link from "next/link" + +export const metadata: Metadata = { + title: "Download Townhall", + description: "Download Townhall for macOS, Windows, or Linux.", +} + +const VERSION = "0.1.0" +const RELEASES_URL = "https://github.com/BuckyMcYolo/townhall/releases" +const LATEST = + "https://github.com/BuckyMcYolo/townhall/releases/latest/download" + +function AppleIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +function WindowsIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +function LinuxIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +const platforms = [ + { + name: "macOS", + icon: AppleIcon, + subtitle: "Apple Silicon", + description: "Download the .dmg installer for macOS 11+", + href: `${LATEST}/Townhall_${VERSION}_aarch64.dmg`, + note: "Need Intel? Check all releases below.", + }, + { + name: "Windows", + icon: WindowsIcon, + subtitle: "Windows 10+", + description: "Download the .exe installer for Windows", + href: `${LATEST}/Townhall_${VERSION}_x64-setup.exe`, + }, + { + name: "Linux", + icon: LinuxIcon, + subtitle: ".deb, .rpm, .AppImage", + description: "Choose your format from the releases page", + href: RELEASES_URL, + external: true, + }, +] + +export default function DownloadPage() { + return ( +
+ {/* Navbar */} +
+ +
+ +
+ {/* Hero */} +
+

+ Download Townhall +

+

+ Available for macOS, Windows, and Linux. Free and open source. +

+
+ + {/* Platform cards */} +
+
+ {platforms.map((platform) => ( + + + + {platform.name} + {platform.subtitle} + + +

+ {platform.description} +

+
+ + +

+ {"note" in platform && platform.note ? platform.note : ""} +

+
+
+ ))} +
+ + +
+ + + + {/* Try in browser */} +
+ +

+ Don't want to download? +

+

+ Try Townhall instantly in your browser — no installation required. +

+ +
+
+ + {/* Footer */} +
+
+
+ Townhall + © {new Date().getFullYear()} Townhall +
+
+ + GitHub + + + Home + +
+
+
+
+ ) +} diff --git a/apps/www/app/layout.tsx b/apps/www/app/layout.tsx index d93e19b..99705f0 100644 --- a/apps/www/app/layout.tsx +++ b/apps/www/app/layout.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next" import localFont from "next/font/local" -// import { ThemeProvider } from "./components/theme-provider"; import "@repo/ui/globals.css" const geistSans = localFont({ @@ -24,18 +23,11 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - + - {/* */} {children} - {/* */} ) diff --git a/apps/www/app/page.tsx b/apps/www/app/page.tsx index d053424..7c1d453 100644 --- a/apps/www/app/page.tsx +++ b/apps/www/app/page.tsx @@ -1,23 +1,60 @@ +import { Badge } from "@repo/ui/components/badge" import { Button } from "@repo/ui/components/button" -import { Github } from "lucide-react" +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@repo/ui/components/card" +import { + Download, + Github, + MessageSquare, + Monitor, + Smartphone, + Users, +} from "lucide-react" import Image from "next/image" -import { WaitlistForm } from "./components/waitlist-form" +import Link from "next/link" +import { CopyTerminal } from "./components/copy-terminal" function Navbar() { return (
-