The weeb for the next gen discord boat - Wamellow wamellow.com
bot discord
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

add leaderboard loading state (pain)

Luna d36a8f2d a887ba47

+384 -241
+43
app/leaderboard/[guildId]/api.ts
··· 1 + import { ApiV1GuildsGetResponse, ApiV1GuildsModulesLeaderboardGetResponse, ApiV1GuildsTopmembersGetResponse, ApiV1GuildsTopmembersPaginationGetResponse } from "@/typings"; 2 + 3 + export async function getGuild(guildId: string): Promise<ApiV1GuildsGetResponse> { 4 + const res = await fetch(`${process.env.NEXT_PUBLIC_API}/guilds/${guildId}`, { 5 + headers: { Authorization: process.env.API_SECRET as string }, 6 + next: { revalidate: 60 * 60 } 7 + }); 8 + 9 + const guild = await res.json(); 10 + return guild; 11 + } 12 + 13 + export async function getDesign(guildId: string): Promise<ApiV1GuildsModulesLeaderboardGetResponse> { 14 + const res = await fetch(`${process.env.NEXT_PUBLIC_API}/guilds/${guildId}/modules/leaderboard`, { 15 + headers: { Authorization: process.env.API_SECRET as string }, 16 + next: { revalidate: 60 * 60 } 17 + }); 18 + 19 + const design = await res.json(); 20 + return design; 21 + } 22 + 23 + export async function getTopMembers(guildId: string, options: { page: number, type: string }): Promise<ApiV1GuildsTopmembersGetResponse[]> { 24 + if (options.type !== "messages" && options.type !== "voiceminutes" && options.type !== "invites") return []; 25 + 26 + const res = await fetch(`${process.env.NEXT_PUBLIC_API}/guilds/${guildId}/top-members?type=${options.type}&page=${options.page - 1}`, { 27 + headers: { Authorization: process.env.API_SECRET as string }, 28 + next: { revalidate: 60 } 29 + }); 30 + 31 + const members = await res.json(); 32 + return members; 33 + } 34 + 35 + export async function getPagination(guildId: string): Promise<ApiV1GuildsTopmembersPaginationGetResponse> { 36 + const res = await fetch(`${process.env.NEXT_PUBLIC_API}/guilds/${guildId}/top-members/pagination`, { 37 + headers: { Authorization: process.env.API_SECRET as string }, 38 + next: { revalidate: 60 * 60 } 39 + }); 40 + 41 + const pagination = await res.json(); 42 + return pagination; 43 + }
+135
app/leaderboard/[guildId]/layout.tsx
··· 1 + import { Metadata } from "next"; 2 + import Image from "next/image"; 3 + import { HiUsers } from "react-icons/hi"; 4 + 5 + import ImageReduceMotion from "@/components/image-reduce-motion"; 6 + import { ListTab } from "@/components/list"; 7 + import decimalToRgb from "@/utils/decimalToRgb"; 8 + import { getCanonicalUrl } from "@/utils/urls"; 9 + 10 + import { getDesign, getGuild, getPagination } from "./api"; 11 + import Side from "./side.component"; 12 + 13 + interface LeaderboardProps { 14 + params: { guildId: string }, 15 + children: React.ReactNode; 16 + } 17 + 18 + export const revalidate = 60 * 60; 19 + 20 + export const generateMetadata = async ({ 21 + params 22 + }: LeaderboardProps): Promise<Metadata> => { 23 + const guild = await getGuild(params.guildId); 24 + 25 + const title = `${guild?.name || "Unknown"}'s Leaderboard`; 26 + const description = `Effortlessly discover the most active chatters, voice timers, and acknowledge top inviters. Explore the vibrant community dynamics of the ${guild?.name || "unknown"} discord server right from your web browser.`; 27 + const url = getCanonicalUrl("leaderboard", params.guildId); 28 + 29 + return { 30 + title, 31 + description, 32 + alternates: { 33 + canonical: url 34 + }, 35 + openGraph: { 36 + title, 37 + description, 38 + url, 39 + type: "website", 40 + images: guild?.icon ? `https://cdn.discordapp.com/icons/${guild?.id}/${guild?.icon}.webp?size=256` : "/discord.png" 41 + }, 42 + twitter: { 43 + card: "summary", 44 + title, 45 + description 46 + }, 47 + robots: guild.name ? "index, follow" : "noindex" 48 + }; 49 + }; 50 + 51 + export default async function RootLayout({ 52 + params, 53 + children 54 + }: LeaderboardProps 55 + ) { 56 + 57 + const guildPromise = getGuild(params.guildId); 58 + const designPromise = getDesign(params.guildId); 59 + const paginationPromise = getPagination(params.guildId); 60 + 61 + const [guild, design, pagination] = await Promise.all([guildPromise, designPromise, paginationPromise]).catch(() => []); 62 + 63 + const backgroundRgb = decimalToRgb(design?.backgroundColor || 0); 64 + const intl = new Intl.NumberFormat("en", { notation: "standard" }); 65 + 66 + return ( 67 + <div className="w-full"> 68 + 69 + {design?.backgroundColor && 70 + <style> 71 + {` 72 + :root { 73 + --background-rgb: rgb(${backgroundRgb.r}, ${backgroundRgb.g}, ${backgroundRgb.b}); 74 + } 75 + `} 76 + </style> 77 + } 78 + 79 + <div className="relative mb-12 w-full"> 80 + <div className="h-32 md:h-64 overflow-hidden rounded-xl" style={{ background: `url(${design?.banner})`, backgroundRepeat: "no-repeat", backgroundSize: "cover" }}> 81 + {!design?.banner && 82 + <Image src="/paint.jpg" width={3840 / 2} height={2160 / 2} alt="" /> 83 + } 84 + </div> 85 + 86 + <div style={{ backgroundColor: "var(--background-rgb)" }} className="text-lg flex gap-5 items-center absolute bottom-[-44px] md:bottom-[-34px] left-[-6px] md:left-10 py-4 px-5 rounded-tr-3xl md:rounded-3xl"> 87 + <ImageReduceMotion url={`https://cdn.discordapp.com/icons/${guild?.id}/${guild?.icon}`} size={128} alt="Server icon" className="rounded-full h-14 w-14 ring-offset-[var(--background-rgb)] ring-2 ring-offset-2 ring-violet-400/40" /> 88 + <div className="flex flex-col gap-1"> 89 + <div className="text-2xl dark:text-neutral-200 text-neutral-800 font-medium">{guild?.name || "Unknown Server"}</div> 90 + <div className="text-sm font-semibold flex items-center gap-1"> <HiUsers /> {intl.format(guild?.memberCount || 0)}</div> 91 + </div> 92 + </div> 93 + </div> 94 + 95 + <ListTab 96 + tabs={[ 97 + { 98 + name: "Messages", 99 + value: "" 100 + }, 101 + { 102 + name: "Voicetime", 103 + value: "voiceminutes" 104 + }, 105 + { 106 + name: "Invites", 107 + value: "invites" 108 + } 109 + ]} 110 + url={`/leaderboard/${params.guildId}`} 111 + searchParamName="type" 112 + disabled={!guild} 113 + > 114 + {/* {searchParams.type === "voiceminutes" ? pagination.voiceminutes : intl.format(pagination[searchParams.type] || 0)} {searchParams.type} */} 115 + </ListTab> 116 + 117 + <div className="md:flex"> 118 + 119 + <div itemScope itemType="https://schema.org/ItemList" className="md:w-3/4 md:mr-6"> 120 + <h2 itemProp="name" className="display-hidden sr-only">Top 10 users in {guild?.name}</h2> 121 + <link itemProp="itemListOrder" href="https://schema.org/ItemListOrderDescending" /> 122 + 123 + {children} 124 + 125 + </div> 126 + 127 + <div className="md:w-1/4 mt-8 md:mt-0"> 128 + <Side guildId={params.guildId} design={design} pagination={pagination} /> 129 + </div> 130 + 131 + </div> 132 + 133 + </div> 134 + ); 135 + }
+21
app/leaderboard/[guildId]/loading.tsx
··· 1 + import { Skeleton } from "@nextui-org/react"; 2 + 3 + export default function Loading() { 4 + return new Array(20).fill(null).map((_, i) => ( 5 + <div 6 + key={"leaderboardLoading-" + i} 7 + className="mb-4 rounded-md p-3 flex items-center dark:bg-wamellow bg-wamellow-100 w-full" 8 + > 9 + <Skeleton className="rounded-full w-12 h-12 mr-3" /> 10 + 11 + <div className="flex flex-col gap-2 mt-0.5"> 12 + <Skeleton className="h-5 w-28 rounded-full" /> 13 + <Skeleton className="h-3 w-20 rounded-full" /> 14 + </div> 15 + 16 + <Skeleton className="ml-auto h-8 w-14 rounded-lg" /> 17 + 18 + <Skeleton className="rounded-full w-12 h-12 ml-3" /> 19 + </div> 20 + )); 21 + }
+89 -195
app/leaderboard/[guildId]/page.tsx
··· 1 1 import { CircularProgress } from "@nextui-org/react"; 2 - import { Metadata } from "next"; 3 - import Image from "next/image"; 4 - import { HiHome, HiUsers } from "react-icons/hi"; 2 + import { HiHome } from "react-icons/hi"; 5 3 6 4 import ImageReduceMotion from "@/components/image-reduce-motion"; 7 - import { ListTab } from "@/components/list"; 8 5 import { ScreenMessage } from "@/components/screen-message"; 9 - import { ApiV1GuildsGetResponse, ApiV1GuildsModulesLeaderboardGetResponse, ApiV1GuildsTopmembersGetResponse } from "@/typings"; 10 6 import cn from "@/utils/cn"; 11 - import decimalToRgb from "@/utils/decimalToRgb"; 12 - import { getCanonicalUrl } from "@/utils/urls"; 13 7 8 + import { getDesign, getGuild, getPagination, getTopMembers } from "./api"; 14 9 import Pagination from "./pagination.component"; 15 - import SideComponent from "./side.component"; 16 10 17 - interface LeaderboardProps { params: { guildId: string }, searchParams: { page: string, type: "messages" | "voiceminutes" | "invites" } } 18 - 19 - export const revalidate = 60 * 60; 20 - 21 - async function getGuild(guildId: string): Promise<ApiV1GuildsGetResponse> { 22 - const res = await fetch(`${process.env.NEXT_PUBLIC_API}/guilds/${guildId}`, { 23 - headers: { Authorization: process.env.API_SECRET as string }, 24 - next: { revalidate: 60 * 60 } 25 - }); 26 - 27 - const guild = await res.json(); 28 - return guild; 29 - } 30 - 31 - async function getDesign(guildId: string): Promise<ApiV1GuildsModulesLeaderboardGetResponse> { 32 - const res = await fetch(`${process.env.NEXT_PUBLIC_API}/guilds/${guildId}/modules/leaderboard`, { 33 - headers: { Authorization: process.env.API_SECRET as string }, 34 - next: { revalidate: 60 * 60 } 35 - }); 36 - 37 - const design = await res.json(); 38 - return design; 11 + interface LeaderboardProps { 12 + params: { guildId: string }, 13 + searchParams: { page: string, type: "messages" | "voiceminutes" | "invites" }, 39 14 } 40 15 41 - async function getTopMembers(guildId: string, options: { page: number, type: string }): Promise<ApiV1GuildsTopmembersGetResponse[]> { 42 - if (options.type && options.type !== "voiceminutes" && options.type !== "invites") return []; 43 - 44 - const res = await fetch(`${process.env.NEXT_PUBLIC_API}/guilds/${guildId}/top-members?type=${options.type || "messages"}&page=${options.page - 1}`, { 45 - headers: { Authorization: process.env.API_SECRET as string }, 46 - next: { revalidate: 60 } 47 - }); 48 - 49 - const members = await res.json(); 50 - return members; 51 - } 52 - 53 - async function getPagination(guildId: string, options: { type: string }): Promise<{ pages: number; members: number }> { 54 - const res = await fetch(`${process.env.NEXT_PUBLIC_API}/guilds/${guildId}/top-members/pagination?type=${options.type || "messages"}`, { 55 - headers: { Authorization: process.env.API_SECRET as string }, 56 - next: { revalidate: 60 * 60 } 57 - }); 58 - 59 - const pagination = await res.json(); 60 - return pagination; 61 - } 62 - 63 - export const generateMetadata = async ({ 64 - params 65 - }: LeaderboardProps): Promise<Metadata> => { 66 - const guild = await getGuild(params.guildId); 67 - 68 - const title = `${guild?.name || "Unknown"}'s Leaderboard`; 69 - const description = `Effortlessly discover the most active chatters, voice timers, and acknowledge top inviters. Explore the vibrant community dynamics of the ${guild?.name || "unknown"} discord server right from your web browser.`; 70 - const url = getCanonicalUrl("leaderboard", params.guildId); 71 - 72 - return { 73 - title, 74 - description, 75 - alternates: { 76 - canonical: url 77 - }, 78 - openGraph: { 79 - title, 80 - description, 81 - url, 82 - type: "website", 83 - images: guild?.icon ? `https://cdn.discordapp.com/icons/${guild?.id}/${guild?.icon}.webp?size=256` : "/discord.png" 84 - }, 85 - twitter: { 86 - card: "summary", 87 - title, 88 - description 89 - }, 90 - robots: guild.name ? "index, follow" : "noindex" 91 - }; 92 - }; 16 + export const revalidate = 60 * 60; 93 17 94 18 export default async function Home({ params, searchParams }: LeaderboardProps) { 19 + if (searchParams) searchParams.type ||= "messages"; 20 + 95 21 const guildPromise = getGuild(params.guildId); 96 22 const membersPromise = getTopMembers(params.guildId, { page: parseInt(searchParams.page || "1"), type: searchParams.type }); 97 23 const designPromise = getDesign(params.guildId); 98 - const paginationPromise = getPagination(params.guildId, { type: searchParams.type }); 24 + const paginationPromise = getPagination(params.guildId); 99 25 100 26 const [guild, members, design, pagination] = await Promise.all([guildPromise, membersPromise, designPromise, paginationPromise]).catch(() => []); 101 27 102 - const backgroundRgb = decimalToRgb(design?.backgroundColor || 0); 103 - const intl = new Intl.NumberFormat("en", { notation: "standard" }); 104 - 105 - const candisplay = guild?.id && Array.isArray(members) && (!searchParams.type || searchParams.type === "voiceminutes" || searchParams.type === "invites"); 106 - 107 - return ( 108 - <div className="w-full"> 28 + let error = ""; 29 + if ("message" in guild) error = guild.message as string; 30 + if ("message" in members) error = members.message as string; 31 + if ("message" in design) error = design.message as string; 32 + if ("message" in pagination) error = pagination.message as string; 109 33 110 - {design?.backgroundColor && 111 - <style> 112 - {` 113 - :root { 114 - --background-rgb: rgb(${backgroundRgb.r}, ${backgroundRgb.g}, ${backgroundRgb.b}); 115 - } 116 - `} 117 - </style> 118 - } 119 - 120 - <div className="relative mb-12 w-full"> 121 - <div className="h-32 md:h-64 overflow-hidden rounded-xl" style={{ background: `url(${design?.banner})`, backgroundRepeat: "no-repeat", backgroundSize: "cover" }}> 122 - {!design?.banner && 123 - <Image src="/paint.jpg" width={3840 / 2} height={2160 / 2} alt="" /> 124 - } 125 - </div> 126 - 127 - <div style={{ backgroundColor: "var(--background-rgb)" }} className="text-lg flex gap-5 items-center absolute bottom-[-44px] md:bottom-[-34px] left-[-6px] md:left-10 py-4 px-5 rounded-tr-3xl md:rounded-3xl"> 128 - <ImageReduceMotion url={`https://cdn.discordapp.com/icons/${guild?.id}/${guild?.icon}`} size={128} alt="Server icon" className="rounded-full h-14 w-14 ring-offset-[var(--background-rgb)] ring-2 ring-offset-2 ring-violet-400/40" /> 129 - <div className="flex flex-col gap-1"> 130 - <div className="text-2xl dark:text-neutral-200 text-neutral-800 font-medium">{guild?.name || "Unknown Server"}</div> 131 - <div className="text-sm font-semibold flex items-center gap-1"> <HiUsers /> {intl.format(guild?.memberCount || 0)}</div> 132 - </div> 133 - </div> 134 - </div> 135 - 136 - <ListTab 137 - tabs={[ 138 - { 139 - name: "Messages", 140 - value: "" 141 - }, 142 - { 143 - name: "Voicetime", 144 - value: "voiceminutes" 145 - }, 146 - { 147 - name: "Invites", 148 - value: "invites" 149 - } 150 - ]} 151 - url={`/leaderboard/${params.guildId}`} 152 - searchParamName="type" 153 - disabled={!guild} 34 + if (error) { 35 + return ( 36 + <ScreenMessage 37 + title="Something went wrong.." 38 + description={error} 39 + href="/" 40 + button="Go back home" 41 + icon={<HiHome />} 42 + top="0rem" 154 43 /> 44 + ); 45 + } 155 46 156 - <div className="md:flex"> 47 + const candisplay = guild?.name && (searchParams.type === "messages" || searchParams.type === "voiceminutes" || searchParams.type === "invites") && pagination[searchParams.type].pages >= parseInt(searchParams.page || "0"); 157 48 158 - <div itemScope itemType="https://schema.org/ItemList" className="md:w-3/4 md:mr-6"> 49 + const intl = new Intl.NumberFormat("en", { notation: "standard" }); 159 50 160 - <h2 itemProp="name" className="display-hidden sr-only">Top 10 users in {guild?.name}</h2> 161 - <link itemProp="itemListOrder" href="https://schema.org/ItemListOrderDescending" /> 51 + if (!candisplay) { 52 + return ( 53 + <ScreenMessage 54 + title="Nothing to see here.." 55 + description="Seems like you got a little lost, huh?" 56 + href="/" 57 + button="Go back home" 58 + icon={<HiHome />} 59 + top="0rem" 60 + /> 61 + ); 62 + } 162 63 163 - {candisplay ? 164 - members.sort((a, b) => (b?.activity?.[searchParams.type] ?? 0) - (a?.activity?.[searchParams.type] ?? 0)).map((member, i) => 165 - <div 166 - key={"leaderboard-" + searchParams.type + member.id + i} 167 - className={cn("mb-4 rounded-md p-3 flex items-center", design?.backgroundColor ? "dark:bg-wamellow/60 bg-wamellow-100/60" : "dark:bg-wamellow bg-wamellow-100")} 168 - > 169 - <ImageReduceMotion url={`https://cdn.discordapp.com/avatars/${member.id}/${member.avatar}`} size={128} alt={`${member.username}'s profile picture`} className="rounded-full h-12 w-12 mr-3" /> 170 - <div> 171 - <div className="text-xl font-medium dark:text-neutral-200 text-neutral-800">{member.globalName || member.username || "Unknown user"}</div> 172 - <div className="text-sm dark:text-neutral-300 text-neutral-700">@{member.username}</div> 173 - </div> 64 + if (!members.length) { 65 + return ( 66 + <ScreenMessage 67 + title="None seems to be here.." 68 + description={"No members could be found on page " + searchParams.page || "1"} 69 + top="0rem" 70 + /> 71 + ); 72 + } 174 73 175 - <div className="ml-auto flex text-xl font-medium dark:text-neutral-200 text-neutral-800"> 176 - <span>{searchParams.type === "voiceminutes" ? member.activity?.formattedVoicetime : intl.format(member.activity?.[searchParams.type || "messages"])}</span> 177 - 178 - <svg 179 - xmlns="http://www.w3.org/2000/svg" 180 - height="0.9em" 181 - viewBox={searchParams.type === "invites" ? "0 0 640 512" : "0 0 448 512"} 182 - className={cn("ml-1 relative", searchParams.type === "voiceminutes" && "ml-2")} 183 - style={{ top: searchParams.type === "messages" ? 0 : 4 }} 184 - fill="#d4d4d4" 185 - > 186 - {(searchParams.type === "messages" || !searchParams.type) && <path d="M448 296c0 66.3-53.7 120-120 120h-8c-17.7 0-32-14.3-32-32s14.3-32 32-32h8c30.9 0 56-25.1 56-56v-8H320c-35.3 0-64-28.7-64-64V160c0-35.3 28.7-64 64-64h64c35.3 0 64 28.7 64 64v32 32 72zm-256 0c0 66.3-53.7 120-120 120H64c-17.7 0-32-14.3-32-32s14.3-32 32-32h8c30.9 0 56-25.1 56-56v-8H64c-35.3 0-64-28.7-64-64V160c0-35.3 28.7-64 64-64h64c35.3 0 64 28.7 64 64v32 32 72z" />} 187 - {searchParams.type === "voiceminutes" && <path d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM412.6 181.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5z" />} 188 - {searchParams.type === "invites" && <path d="M579.8 267.7c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114L422.3 334.8c-31.5 31.5-82.5 31.5-114 0c-27.9-27.9-31.5-71.8-8.6-103.8l1.1-1.6c10.3-14.4 6.9-34.4-7.4-44.6s-34.4-6.9-44.6 7.4l-1.1 1.6C206.5 251.2 213 330 263 380c56.5 56.5 148 56.5 204.5 0L579.8 267.7zM60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5L217.7 177.2c31.5-31.5 82.5-31.5 114 0c27.9 27.9 31.5 71.8 8.6 103.9l-1.1 1.6c-10.3 14.4-6.9 34.4 7.4 44.6s34.4 6.9 44.6-7.4l1.1-1.6C433.5 260.8 427 182 377 132c-56.5-56.5-148-56.5-204.5 0L60.2 244.3z" />} 189 - </svg> 74 + return ( 75 + <> 76 + {members.sort((a, b) => (b.activity[searchParams.type] ?? 0) - (a.activity[searchParams.type] ?? 0)).map((member, i) => 77 + <div 78 + key={"leaderboard-" + searchParams.type + member.id + i} 79 + className={cn("mb-4 rounded-md p-3 flex items-center", design?.backgroundColor ? "dark:bg-wamellow/60 bg-wamellow-100/60" : "dark:bg-wamellow bg-wamellow-100")} 80 + > 81 + <ImageReduceMotion url={`https://cdn.discordapp.com/avatars/${member.id}/${member.avatar}`} size={128} alt={`${member.username}'s profile picture`} className="rounded-full h-12 w-12 mr-3" /> 82 + <div> 83 + <div className="text-xl font-medium dark:text-neutral-200 text-neutral-800">{member.globalName || member.username || "Unknown user"}</div> 84 + <div className="text-sm dark:text-neutral-300 text-neutral-700">@{member.username}</div> 85 + </div> 190 86 191 - </div> 87 + <div className="ml-auto flex text-xl font-medium dark:text-neutral-200 text-neutral-800"> 88 + <span>{searchParams.type === "voiceminutes" ? member.activity?.formattedVoicetime : intl.format(member.activity?.[searchParams.type || "messages"])}</span> 192 89 193 - <CircularProgress 194 - className="ml-4" 195 - aria-label="progress" 196 - size="lg" 197 - color="secondary" 198 - classNames={{ 199 - svg: "drop-shadow-md" 200 - }} 201 - value={(member.activity[searchParams.type || "messages"] * 100) / members[i - 1]?.activity[searchParams.type || "messages"] || 100} 202 - showValueLabel={true} 203 - /> 90 + <svg 91 + xmlns="http://www.w3.org/2000/svg" 92 + height="0.9em" 93 + viewBox={searchParams.type === "invites" ? "0 0 640 512" : "0 0 448 512"} 94 + className={cn("ml-1 relative", searchParams.type === "voiceminutes" && "ml-2")} 95 + style={{ top: searchParams.type === "messages" ? 0 : 4 }} 96 + fill="#d4d4d4" 97 + > 98 + {(searchParams.type === "messages" || !searchParams.type) && <path d="M448 296c0 66.3-53.7 120-120 120h-8c-17.7 0-32-14.3-32-32s14.3-32 32-32h8c30.9 0 56-25.1 56-56v-8H320c-35.3 0-64-28.7-64-64V160c0-35.3 28.7-64 64-64h64c35.3 0 64 28.7 64 64v32 32 72zm-256 0c0 66.3-53.7 120-120 120H64c-17.7 0-32-14.3-32-32s14.3-32 32-32h8c30.9 0 56-25.1 56-56v-8H64c-35.3 0-64-28.7-64-64V160c0-35.3 28.7-64 64-64h64c35.3 0 64 28.7 64 64v32 32 72z" />} 99 + {searchParams.type === "voiceminutes" && <path d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM412.6 181.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5z" />} 100 + {searchParams.type === "invites" && <path d="M579.8 267.7c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114L422.3 334.8c-31.5 31.5-82.5 31.5-114 0c-27.9-27.9-31.5-71.8-8.6-103.8l1.1-1.6c10.3-14.4 6.9-34.4-7.4-44.6s-34.4-6.9-44.6 7.4l-1.1 1.6C206.5 251.2 213 330 263 380c56.5 56.5 148 56.5 204.5 0L579.8 267.7zM60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5L217.7 177.2c31.5-31.5 82.5-31.5 114 0c27.9 27.9 31.5 71.8 8.6 103.9l-1.1 1.6c-10.3 14.4-6.9 34.4 7.4 44.6s34.4 6.9 44.6-7.4l1.1-1.6C433.5 260.8 427 182 377 132c-56.5-56.5-148-56.5-204.5 0L60.2 244.3z" />} 101 + </svg> 204 102 205 - </div> 206 - ) 207 - : 208 - <ScreenMessage 209 - title="Nothing to see here.." 210 - description="Seems like you got a little lost, huh?" 211 - href="/" 212 - button="Go back home" 213 - top="0rem" 214 - icon={<HiHome />} 215 - /> 216 - } 103 + </div> 217 104 218 - {guild?.id && <Pagination key={searchParams.type} guildId={params.guildId} searchParams={searchParams} data={pagination} />} 219 - </div> 105 + <CircularProgress 106 + className="ml-4" 107 + aria-label="progress" 108 + size="lg" 109 + color="secondary" 110 + classNames={{ 111 + svg: "drop-shadow-md" 112 + }} 113 + value={(member.activity[searchParams.type || "messages"] * 100) / members[i - 1]?.activity[searchParams.type || "messages"] || 100} 114 + showValueLabel={true} 115 + /> 220 116 221 - <div className="md:w-1/4 mt-8 md:mt-0"> 222 - <SideComponent guildId={params.guildId} design={design} /> 223 117 </div> 224 - 225 - </div> 118 + )} 226 119 227 - </div> 120 + <Pagination key={searchParams.type} guildId={params.guildId} searchParams={searchParams} pages={pagination[searchParams.type].pages} /> 121 + </> 228 122 ); 229 123 }
+4 -4
app/leaderboard/[guildId]/pagination.component.tsx
··· 11 11 { 12 12 guildId, 13 13 searchParams, 14 - data 14 + pages 15 15 }: { 16 16 guildId: string; 17 17 searchParams: { page: string, type: string }; 18 - data: { pages: number; members: number }; 18 + pages: number; 19 19 } 20 20 ) { 21 21 const user = userStore((s) => s); ··· 36 36 classNames={{ prev: "bg-wamellow", item: "bg-wamellow", next: "bg-wamellow" }} 37 37 color="secondary" 38 38 showControls 39 - total={data.pages} 39 + total={pages} 40 40 size="lg" 41 41 page={parseInt(searchParams.page || "0")} 42 42 onChange={(now) => { 43 - router.push(getCanonicalUrl("leaderboard", guildId, `?page=${now}${searchParams.type ? `&type=${searchParams.type}` : ""}`)); 43 + router.push(getCanonicalUrl("leaderboard", guildId, `?page=${now}${(searchParams.type && searchParams.type !== "messages") ? `&type=${searchParams.type}` : ""}`)); 44 44 }} 45 45 /> 46 46 );
+56 -24
app/leaderboard/[guildId]/side.component.tsx
··· 1 1 "use client"; 2 - import { Button } from "@nextui-org/react"; 2 + 3 + import { Accordion, AccordionItem, Button } from "@nextui-org/react"; 3 4 import Link from "next/link"; 4 5 import { useRouter } from "next/navigation"; 5 - import { FunctionComponent, useState } from "react"; 6 + import { useState } from "react"; 6 7 import { HiShare, HiTrash, HiViewGridAdd } from "react-icons/hi"; 7 8 8 9 import { webStore } from "@/common/webstore"; 9 10 import Ad from "@/components/ad"; 10 - import Badge from "@/components/badge"; 11 11 import { CopyToClipboardButton } from "@/components/copyToClipboard"; 12 12 import ErrorBanner from "@/components/Error"; 13 13 import Modal from "@/components/modal"; 14 - import { ApiV1GuildsModulesLeaderboardGetResponse } from "@/typings"; 14 + import { ApiV1GuildsModulesLeaderboardGetResponse, ApiV1GuildsTopmembersPaginationGetResponse } from "@/typings"; 15 15 import cn from "@/utils/cn"; 16 16 import { getCanonicalUrl } from "@/utils/urls"; 17 17 18 - const SideComponent: FunctionComponent<{ guildId: string, design: ApiV1GuildsModulesLeaderboardGetResponse }> = ({ guildId, design }) => { 18 + export default function Side({ 19 + guildId, 20 + design, 21 + pagination 22 + }: { 23 + guildId: string; 24 + design: ApiV1GuildsModulesLeaderboardGetResponse; 25 + pagination: ApiV1GuildsTopmembersPaginationGetResponse; 26 + }) { 19 27 const web = webStore((w) => w); 20 28 const router = useRouter(); 21 29 22 30 const [modal, setModal] = useState(false); 31 + const intl = new Intl.NumberFormat("en", { notation: "standard" }); 23 32 24 33 return ( 25 34 <div className="flex flex-col gap-3"> ··· 32 41 33 42 <Ad /> 34 43 35 - {web.devToolsEnabled && 36 - <div className="dark:text-neutral-300 text-neutral-700 py-2 rounded-md mt-2"> 37 - <span className="flex items-center gap-2 px-1"> 38 - <span className="text-xl font-medium dark:text-neutral-100 text-neutral-900">Admin tools</span> 39 - <Badge text="Developer" /> 40 - </span> 41 - <hr className="mt-2 mb-3 dark:border-wamellow-light border-wamellow-100-light" /> 44 + <Accordion 45 + selectionMode="multiple" 46 + defaultExpandedKeys={["1", "2", "3"]} 47 + disableAnimation={web.reduceMotions} 48 + > 42 49 43 - <div className="flex flex-col gap-3"> 50 + {web.devToolsEnabled ? 51 + <AccordionItem 52 + key="1" 53 + aria-label="admin tools" 54 + title="Admin tools" 55 + classNames={{ content: "flex flex-col gap-2 mb-2" }} 56 + > 44 57 <Button 45 58 className="w-full !justify-start" 46 59 onClick={() => setModal(true)} ··· 56 69 > 57 70 Dashboard 58 71 </Button> 59 - </div> 72 + </AccordionItem> 73 + : 74 + undefined as unknown as JSX.Element 75 + } 60 76 61 - </div> 62 - } 77 + <AccordionItem 78 + key="2" 79 + aria-label="how this works" 80 + title="How this works" 81 + classNames={{ content: "mb-2" }} 82 + > 83 + Users are sorted from most to least active for each category, updates once per minute. 84 + </AccordionItem> 63 85 64 - <div className="dark:text-neutral-300 text-neutral-700 py-2 rounded-md"> 65 - <span className="text-xl font-medium dark:text-neutral-100 text-neutral-900 px-1">How this works</span> 66 - <hr className="my-2 dark:border-wamellow-light border-wamellow-100-light" /> 86 + <AccordionItem 87 + key="3" 88 + aria-label="server activity" 89 + title="Server activity" 90 + classNames={{ content: "mb-2" }} 91 + > 92 + <div> 93 + <span className="font-semibold">{intl.format(pagination.messages.total)}</span> messages 94 + </div> 95 + <div> 96 + <span className="font-semibold">{pagination.voiceminutes.total}</span> in voice 97 + </div> 98 + <div> 99 + <span className="font-semibold"> {intl.format(pagination.invites.total)}</span> invites 100 + </div> 101 + </AccordionItem> 67 102 68 - <div className="px-1">Users are sorted from most to least active for each category, updates once per minute.</div> 69 - </div> 103 + </Accordion> 70 104 71 105 <Modal 72 106 title="Reset @everyone's stats" ··· 94 128 </div> 95 129 ); 96 130 97 - }; 98 - 99 - export default SideComponent; 131 + }
+19 -18
components/List.tsx
··· 1 1 "use client"; 2 + 2 3 import { usePathname, useRouter, useSearchParams } from "next/navigation"; 3 - import React, { FunctionComponent } from "react"; 4 + import React from "react"; 4 5 5 6 import cn from "@/utils/cn"; 6 7 import decimalToRgb from "@/utils/decimalToRgb"; ··· 14 15 url: string; 15 16 searchParamName?: string; 16 17 disabled: boolean; 18 + 19 + children?: React.ReactNode 17 20 } 18 21 19 - export const ListTab: FunctionComponent<ListProps> = ({ tabs, url, searchParamName, disabled }) => { 22 + export function ListTab({ tabs, url, searchParamName, disabled, children }: ListProps) { 20 23 const path = usePathname().split(`${url}/`)[1]; 21 24 const params = useSearchParams(); 22 25 const router = useRouter(); ··· 24 27 return ( 25 28 <div className="text-sm font-medium text-center border-b dark:border-wamellow-light border-wamellow-100-light mt-2 mb-6 overflow-x-scroll scrollbar-none"> 26 29 <ul className="flex"> 27 - {tabs.map((tab) => { 28 - 30 + {tabs.map((tab, i) => { 29 31 let isCurrent = false; 30 32 if (searchParamName) isCurrent = tab.value ? params.get(searchParamName) === tab.value : !tab.value && !params.get(searchParamName); 31 33 isCurrent ||= (!path && tab.value === "/") || path?.startsWith(tab.value !== "/" ? tab.value.slice(1) : tab.value); 32 34 33 35 return ( 34 - <li className="mr-2" key={tab.name}> 36 + <li className="mr-2" key={"tablist-" + url + tab.name + i}> 35 37 <button 36 38 className={cn( 37 39 "inline-block p-3 pb-2 border-b-2 border-transparent rounded-t-lg font-medium hover:text-violet-400 duration-200", ··· 40 42 )} 41 43 onClick={() => { 42 44 if (disabled) return; 45 + if (!searchParamName) return router.push(`${url}${tab.value}`); 43 46 44 - if (searchParamName) { 45 - const newparams = new URLSearchParams(); 47 + const newparams = new URLSearchParams(); 46 48 47 - if (tab.value) newparams.append(searchParamName, tab.value); 48 - else newparams.delete(searchParamName); 49 + if (tab.value) newparams.append(searchParamName, tab.value); 50 + else newparams.delete(searchParamName); 49 51 50 - router.push(`${url}?${newparams.toString()}`); 51 - } 52 - else router.push(`${url}${tab.value}`); 52 + router.push(`${url}?${newparams.toString()}`); 53 53 }} 54 54 > 55 55 <span dangerouslySetInnerHTML={{ __html: tab.name.replace(/ +/g, "&nbsp;") }} /> ··· 58 58 ); 59 59 60 60 })} 61 + {children && <li className="ml-auto">{children}</li>} 61 62 </ul> 62 - </div> 63 + </div > 63 64 ); 64 65 65 - }; 66 + } 66 67 67 68 interface FeatureProps { 68 69 items: { ··· 73 74 }[] 74 75 } 75 76 76 - export const ListFeature: FunctionComponent<FeatureProps> = ({ items }) => { 77 + export function ListFeature({ items }: FeatureProps) { 77 78 78 79 return ( 79 80 <div className="grid gap-6 grid-cols-2"> 80 - {items.map((item, index) => { 81 + {items.map((item, i) => { 81 82 82 83 const rgb = decimalToRgb(item.color); 83 84 84 85 return ( 85 86 <div 86 87 className="flex items-center gap-3" 87 - key={index} 88 + key={"featurelist-" + item.description.replace(/ +/g, "") + i} 88 89 > 89 90 <div className="rounded-full h-12 aspect-square p-[10px] svg-max" style={{ backgroundColor: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.2)`, color: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)` }}> 90 91 {item.icon} ··· 97 98 </div> 98 99 ); 99 100 100 - }; 101 + }
+17
typings.ts
··· 51 51 activity: ApiV1MeGetResponse["activity"] & { formattedVoicetime: string }; 52 52 } 53 53 54 + export interface ApiV1GuildsTopmembersPaginationGetResponse { 55 + messages: { 56 + pages: number; 57 + total: number; 58 + }; 59 + voiceminutes: { 60 + pages: number; 61 + total: string; 62 + }; 63 + invites: { 64 + pages: number; 65 + total: number; 66 + }; 67 + } 68 + 54 69 export interface ApiV1GuildsChannelsGetResponse { 55 70 name: string; 56 71 id: string; ··· 197 212 backgroundColor: number | null; 198 213 textColor: number | null; 199 214 accentColor: number | null; 215 + 216 + blacklistChannelIds: string[] 200 217 201 218 updating: ApiV1GuildsModulesLeaderboardUpdatingPostResponse[] 202 219 }