diff --git a/app/actions/discord.ts b/app/actions/discord.ts index 30aa09b..aa339ae 100644 --- a/app/actions/discord.ts +++ b/app/actions/discord.ts @@ -61,3 +61,58 @@ export async function getGuildMemberRoles(discordId: string): Promise return []; } } + +export async function getDiscordMemberProfile(discordId: string) { + const token = process.env.DISCORD_BOT_TOKEN; + const guildId = process.env.DISCORD_GUILD_ID; + if (!token || !guildId || !discordId) return null; + + try { + const [memberRes, allRoles] = await Promise.all([ + fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, { + headers: { Authorization: `Bot ${token}` }, + cache: "no-store", + }), + getGuildRoles() + ]); + + if (!memberRes.ok) return null; + const member = await memberRes.json(); + + // Map member role IDs to actual role objects + const memberRoleIds = member.roles || []; + const roles = memberRoleIds + .map((id: string) => allRoles.find(r => r.id === id)) + .filter(Boolean); + + // Sort roles by position (descending) + roles.sort((a: any, b: any) => (b?.position || 0) - (a?.position || 0)); + + const user = member.user; + + let avatarUrl = null; + if (user.avatar) { + const ext = user.avatar.startsWith("a_") ? "gif" : "png"; + avatarUrl = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${ext}?size=128`; + } + + let bannerUrl = null; + if (user.banner) { + const ext = user.banner.startsWith("a_") ? "gif" : "png"; + bannerUrl = `https://cdn.discordapp.com/banners/${user.id}/${user.banner}.${ext}?size=512`; + } + + return { + id: user.id, + username: user.username, + globalName: user.global_name || null, + avatarUrl, + bannerUrl, + accentColor: user.accent_color || null, + roles, + }; + } catch (e) { + console.error("Failed to fetch member profile", e); + return null; + } +} diff --git a/app/admin/backend/page.tsx b/app/admin/backend/page.tsx index 0a8aa45..c4a5b62 100644 --- a/app/admin/backend/page.tsx +++ b/app/admin/backend/page.tsx @@ -1,4 +1,4 @@ -import { AdminShell } from "@/components/admin-shell"; +import { AdminShellServer } from "@/components/admin-shell-server"; import { readdirSync } from "fs"; import { join } from "path"; import { BackendClient } from "./client"; @@ -31,7 +31,7 @@ export default async function BackendPage() { })); return ( - +

Backend

@@ -41,6 +41,6 @@ export default async function BackendPage() {
-
+ ); } diff --git a/app/admin/form/[id]/client.tsx b/app/admin/form/[id]/client.tsx index 470471f..cd6dbec 100644 --- a/app/admin/form/[id]/client.tsx +++ b/app/admin/form/[id]/client.tsx @@ -38,6 +38,7 @@ import Image from 'next/image'; import { RichTextEditor } from "@/components/rich-text-editor"; import { FormattedText } from "@/components/formatted-text"; import { ThemeToggle } from "@/components/theme-toggle"; +import { DropdownMenuAvatar, DiscordMemberProfile } from "@/components/dropdown-menu-avatar"; interface Question { id: string; @@ -609,7 +610,17 @@ function AnswerPreview({ question }: { question: Question }) { ); } -export function AdminSidebar({ formId }: { formId: string }) { +export function AdminSidebar({ + formId, + userName, + userImage, + profile, +}: { + formId: string; + userName?: string; + userImage?: string; + profile?: DiscordMemberProfile | null; +}) { const pathname = usePathname(); return ( @@ -618,7 +629,17 @@ export function AdminSidebar({ formId }: { formId: string }) { Form Admin - +
+ {(userName || profile) && ( + + )} + +
diff --git a/app/admin/form/[id]/layout.tsx b/app/admin/form/[id]/layout.tsx index 303b634..c6f75b3 100644 --- a/app/admin/form/[id]/layout.tsx +++ b/app/admin/form/[id]/layout.tsx @@ -1,6 +1,9 @@ import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { AdminSidebar } from "./client"; import { Metadata } from "next"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getDiscordMemberProfile } from "@/app/actions/discord"; export const metadata: Metadata = { title: { @@ -16,11 +19,23 @@ export default async function AdminFormLayout( } ) { const params = await props.params; + const session = await getServerSession(authOptions); + + let profile = null; + const discordId = (session?.user as any)?.discordId; + if (discordId) { + profile = await getDiscordMemberProfile(discordId); + } return (
- +
diff --git a/app/admin/form/page.tsx b/app/admin/form/page.tsx index ca87caa..bb0b246 100644 --- a/app/admin/form/page.tsx +++ b/app/admin/form/page.tsx @@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"; import { Plus } from "lucide-react"; import { redirect } from "next/navigation"; import { DeleteFormButton } from "@/components/delete-buttons"; -import { AdminShell } from "@/components/admin-shell"; +import { AdminShellServer } from "@/components/admin-shell-server"; export const dynamic = "force-dynamic"; @@ -23,7 +23,7 @@ export default async function AdminFormsPage() { }); return ( - +
@@ -100,6 +100,6 @@ export default async function AdminFormsPage() {
- + ); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index ed19583..a1ab8e8 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,4 +1,4 @@ -import { AdminShell } from "@/components/admin-shell"; +import { AdminShellServer } from "@/components/admin-shell-server"; import Link from "next/link"; import { Card, @@ -10,7 +10,7 @@ import { ClipboardList, Server } from "lucide-react"; export default function AdminPage() { return ( - +

Dashboard

@@ -51,6 +51,6 @@ export default function AdminPage() {
-
+ ); } diff --git a/app/form/client.tsx b/app/form/client.tsx index 83bbe33..d443626 100644 --- a/app/form/client.tsx +++ b/app/form/client.tsx @@ -27,43 +27,25 @@ import { FormControl, FormField, FormItem, - FormLabel, FormMessage, } from "@/components/ui/form"; - - +import { DropdownMenuAvatar, DiscordMemberProfile } from "@/components/dropdown-menu-avatar"; export function FormLayoutClient({ userName, userImage, + profile, }: { userName: string; userImage: string; + profile?: DiscordMemberProfile | null; }) { - const [imgError, setImgError] = useState(false); - return ( -
- {userImage && !imgError && ( - {userName} setImgError(true)} - unoptimized - /> - )} - {userName} - -
+ ); } diff --git a/app/form/layout.tsx b/app/form/layout.tsx index f5c5f0a..a3c6c52 100644 --- a/app/form/layout.tsx +++ b/app/form/layout.tsx @@ -4,6 +4,7 @@ import { FormLayoutClient } from "./client"; import LoginDialog from "@/components/login-dialog"; import { Metadata } from "next"; import { ThemeToggle } from "@/components/theme-toggle"; +import { getDiscordMemberProfile } from "@/app/actions/discord"; import Link from 'next/link'; const siteUrl = process.env.BASE_URL; @@ -35,6 +36,12 @@ export default async function FormLayout({ children: React.ReactNode; }) { const session = await getServerSession(authOptions); + const discordId = (session?.user as any)?.discordId; + let profile = null; + + if (discordId) { + profile = await getDiscordMemberProfile(discordId); + } return (
@@ -49,6 +56,7 @@ export default async function FormLayout({ )} diff --git a/app/form/page.tsx b/app/form/page.tsx index 61df50e..2bfb590 100644 --- a/app/form/page.tsx +++ b/app/form/page.tsx @@ -7,6 +7,9 @@ import { CardDescription, } from "@/components/ui/card"; import { HtmlDisplay } from "@/components/html-display"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getGuildMemberRoles } from "@/app/actions/discord"; export const dynamic = "force-dynamic"; @@ -16,14 +19,48 @@ export default async function FormsListingPage() { orderBy: (f, { desc }) => [desc(f.createdAt)], }); + const session = await getServerSession(authOptions); + const discordId = (session?.user as { discordId?: string })?.discordId; + + let userRoles: string[] = []; + if (discordId) { + try { + userRoles = await getGuildMemberRoles(discordId); + } catch (e) { + console.error(e); + } + } + + const visibleForms = formsList.filter((form) => { + const allowedRoles = form.allowedRoles || []; + const deniedRoles = form.deniedRoles || []; + + if (allowedRoles.length === 0 && deniedRoles.length === 0) return true; + + // If user is not logged in but there are role restrictions, hide it. + if (!discordId) return false; + + let isAllowed = allowedRoles.length === 0; + if (allowedRoles.length > 0) { + isAllowed = userRoles.some(r => allowedRoles.includes(r)); + } + + let isDenied = false; + if (deniedRoles.length > 0) { + isDenied = userRoles.some(r => deniedRoles.includes(r)); + } + + return isAllowed && !isDenied; + }); + return (

Form ทั้งหมด

- {formsList.length === 0 ? ( + {visibleForms.length === 0 ? (

ตอนนี้ยังไม่เปิดรับสัก Form

) : (
- {formsList.map((form) => ( + {visibleForms.map((form) => ( diff --git a/components/admin-shell-server.tsx b/components/admin-shell-server.tsx new file mode 100644 index 0000000..dcdd8a5 --- /dev/null +++ b/components/admin-shell-server.tsx @@ -0,0 +1,24 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getDiscordMemberProfile } from "@/app/actions/discord"; +import { AdminShell } from "@/components/admin-shell"; + +export async function AdminShellServer({ children }: { children: React.ReactNode }) { + const session = await getServerSession(authOptions); + const discordId = (session?.user as any)?.discordId; + let profile = null; + + if (discordId) { + profile = await getDiscordMemberProfile(discordId); + } + + return ( + + {children} + + ); +} diff --git a/components/admin-shell.tsx b/components/admin-shell.tsx index c13ce3a..557cd94 100644 --- a/components/admin-shell.tsx +++ b/components/admin-shell.tsx @@ -16,21 +16,70 @@ import { SidebarInset, SidebarTrigger, } from "@/components/ui/sidebar"; -import { ClipboardList, Server, LayoutDashboard } from "lucide-react"; +import { ClipboardList, Server, LayoutDashboard, LucideIcon } from "lucide-react"; import { ThemeToggle } from "@/components/theme-toggle"; +import { DropdownMenuAvatar, DiscordMemberProfile } from "@/components/dropdown-menu-avatar"; -export function AdminShell({ children }: { children: React.ReactNode }) { +interface NavItem { + href: string; + label: string; + icon: LucideIcon; + isActive: (pathname: string) => boolean; +} + +const NAV_ITEMS: NavItem[] = [ + { + href: "/admin", + label: "Dashboard", + icon: LayoutDashboard, + isActive: (pathname) => pathname === "/admin", + }, + { + href: "/admin/form", + label: "Form", + icon: ClipboardList, + isActive: (pathname) => pathname === "/admin/form", + }, + { + href: "/admin/backend", + label: "Backend", + icon: Server, + isActive: (pathname) => pathname.startsWith("/admin/backend"), + }, +]; + +export function AdminShell({ + children, + userName, + userImage, + profile, +}: { + children: React.ReactNode; + userName?: string; + userImage?: string; + profile?: DiscordMemberProfile | null; +}) { const pathname = usePathname(); return (
- - - Admin - - + +
+ Admin +
+ {(userName || profile) && ( + + )} + +
+
@@ -38,41 +87,16 @@ export function AdminShell({ children }: { children: React.ReactNode }) { Navigation - - - - - Dashboard - - - - - - - - - Form - - - - - - - - - Backend - - - + {NAV_ITEMS.map(({ href, label, icon: Icon, isActive }) => ( + + + + + {label} + + + + ))} @@ -88,4 +112,4 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
); -} +} \ No newline at end of file diff --git a/components/dropdown-menu-avatar.tsx b/components/dropdown-menu-avatar.tsx new file mode 100644 index 0000000..f79b847 --- /dev/null +++ b/components/dropdown-menu-avatar.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import Image from "next/image"; +import { LogOut } from "lucide-react"; +import { signOut } from "next-auth/react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { DiscordRole } from "@/app/actions/discord"; + +export interface DiscordMemberProfile { + id: string; + username: string; + globalName: string | null; + avatarUrl: string | null; + bannerUrl: string | null; + accentColor: number | null; + roles: DiscordRole[]; +} + +const toHex = (n: number) => `#${n.toString(16).padStart(6, "0")}`; +const DEFAULT_ROLE_COLOR = "#99aab5"; + +function RolePill({ role }: { role: DiscordRole }) { + const colorHex = role.color === 0 ? DEFAULT_ROLE_COLOR : toHex(role.color); + return ( +
+
+ + {role.name} + +
+ ); +} + +export function DropdownMenuAvatar({ + userName, + userImage, + profile, + showName = true, +}: { + userName: string; + userImage: string; + profile: DiscordMemberProfile | null; + showName?: boolean; +}) { + const [imgError, setImgError] = useState(false); + + const displayAvatar = profile?.avatarUrl || userImage; + const displayName = profile?.globalName || userName; + const username = profile?.username || userName; + + const bannerStyle = useMemo( + () => ({ + height: 100, + backgroundColor: profile?.accentColor + ? toHex(profile.accentColor) + : "hsl(var(--muted))", + backgroundImage: profile?.bannerUrl + ? `url(${profile.bannerUrl})` + : "none", + backgroundSize: "cover" as const, + backgroundPosition: "center" as const, + }), + [profile?.accentColor, profile?.bannerUrl] + ); + + return ( + + + + + + + {/* Banner Section */} +
+ + {/* Body Area */} +
+ {/* Avatar - Absolute Positioned to overlap banner and body */} +
+
+
+
+ {!imgError && displayAvatar && ( + {displayName} setImgError(true)} + unoptimized + /> + )} +
+
+
+
+ + {/* Profile Info Section */} +
+
+ {displayName} +
+
+ {username} +
+ {/* Roles Section */} + {profile && profile.roles.length > 0 && ( +
+
+ Roles / บทบาท +
+
+ {profile.roles.map((role) => ( + + ))} +
+
+ )} +
+
+ + {/* Footer / Sign Out */} +
+ signOut({ callbackUrl: "/" })} + className="text-destructive" + > + + ออกจากระบบ + +
+ + + ); +} \ No newline at end of file