Introduce components/surface-card.tsx (Card + CardBody) mirroring the .content-card / SectionCard token set, and use it on the (site) pages that still hand-rolled card markup: me, search, and verify. This puts every public page on one of the shared card components (ContentCard, SectionCard, or SurfaceCard) for consistent radius/shadow/border.
734 lines
22 KiB
TypeScript
734 lines
22 KiB
TypeScript
import { and, asc, count, desc, eq, gt, or } from "drizzle-orm";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import type { CSSProperties, ReactNode } from "react";
|
|
import { claimReferral } from "@/actions/referral";
|
|
|
|
import { Reveal } from "@/components/motion-reveal";
|
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|
import { SurfaceCard } from "@/components/surface-card";
|
|
import { auth } from "@/lib/auth";
|
|
import {
|
|
db,
|
|
MessengerFriendships,
|
|
MessengerOffline,
|
|
Rooms,
|
|
User,
|
|
UserReferrals,
|
|
UsersBadges,
|
|
UsersSettings,
|
|
} from "@/lib/db";
|
|
import { avatarImageUrl } from "@/lib/format";
|
|
import { resolveHotelName } from "@/lib/hotel-name";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
import CopyReferralButton from "./CopyReferralButton";
|
|
|
|
export const metadata = { title: "My dashboard" };
|
|
|
|
type SearchParams = Promise<{ claimed?: string; error?: string }>;
|
|
|
|
function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties {
|
|
const accent =
|
|
tone === "error"
|
|
? "var(--color-danger)"
|
|
: tone === "warning"
|
|
? "var(--color-accent)"
|
|
: "var(--color-primary)";
|
|
return {
|
|
margin: 0,
|
|
padding: "0.85rem 1rem",
|
|
borderRadius: "var(--radius-md)",
|
|
border: `1px solid ${accent}`,
|
|
color: "var(--color-text-readable, var(--color-text))",
|
|
fontSize: "0.9rem",
|
|
fontWeight: 600,
|
|
background: "var(--color-surface)",
|
|
borderLeft: `4px solid ${accent}`,
|
|
};
|
|
}
|
|
|
|
const ERROR_MESSAGES: Record<string, string> = {
|
|
not_enough: "You do not have enough referrals to claim your reward yet.",
|
|
no_referrals:
|
|
"You haven't referred anyone yet — share your link to get started.",
|
|
bad_config:
|
|
"The referral reward is not configured correctly. Please contact a staff member.",
|
|
error: "Something went wrong while claiming your reward. Please try again.",
|
|
};
|
|
|
|
function getErrorMessage(error: string): string {
|
|
return ERROR_MESSAGES[error] ?? ERROR_MESSAGES.error;
|
|
}
|
|
|
|
export default async function MePage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: SearchParams;
|
|
}) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) redirect("/login");
|
|
|
|
const userId = Number(session.user.id);
|
|
if (!Number.isFinite(userId) || userId <= 0) redirect("/login");
|
|
|
|
const { claimed, error } = await searchParams;
|
|
|
|
let content: ReactNode;
|
|
try {
|
|
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
|
|
|
const [
|
|
userRows,
|
|
hotelName,
|
|
neededRaw,
|
|
rewardAmountRaw,
|
|
currencySettings,
|
|
alertRaw,
|
|
recentRooms,
|
|
badges,
|
|
userSettingsRows,
|
|
friendCountRows,
|
|
unreadCountRows,
|
|
referralsRows,
|
|
friends,
|
|
] = await Promise.all([
|
|
db
|
|
.select({
|
|
id: User.id,
|
|
username: User.username,
|
|
look: User.look,
|
|
motto: User.motto,
|
|
rank: User.rank,
|
|
credits: User.credits,
|
|
accountCreated: User.accountCreated,
|
|
lastLogin: User.lastLogin,
|
|
lastOnline: User.lastOnline,
|
|
})
|
|
.from(User)
|
|
.where(eq(User.id, userId))
|
|
.limit(1),
|
|
resolveHotelName(),
|
|
siteSettings.get("referrals_needed", "5"),
|
|
siteSettings.get("referral_reward_amount", "30"),
|
|
siteSettings.getMany([
|
|
"referral_reward_currency_type",
|
|
"referral_reward_currency",
|
|
]),
|
|
siteSettings.get("hotel_alert_banner", ""),
|
|
db
|
|
.select({
|
|
id: Rooms.id,
|
|
name: Rooms.name,
|
|
ownerName: Rooms.ownerName,
|
|
users: Rooms.users,
|
|
usersMax: Rooms.usersMax,
|
|
})
|
|
.from(Rooms)
|
|
.orderBy(desc(Rooms.id))
|
|
.limit(6)
|
|
.catch(() => []),
|
|
db
|
|
.select({
|
|
badgeCode: UsersBadges.badgeCode,
|
|
slotId: UsersBadges.slotId,
|
|
})
|
|
.from(UsersBadges)
|
|
.where(and(eq(UsersBadges.userId, userId), gt(UsersBadges.slotId, 0)))
|
|
.orderBy(asc(UsersBadges.slotId))
|
|
.catch(() => []),
|
|
db
|
|
.select({
|
|
achievementScore: UsersSettings.achievementScore,
|
|
respectsReceived: UsersSettings.respectsReceived,
|
|
})
|
|
.from(UsersSettings)
|
|
.where(eq(UsersSettings.userId, userId))
|
|
.limit(1)
|
|
.catch(() => []),
|
|
db
|
|
.select({ value: count() })
|
|
.from(MessengerFriendships)
|
|
.where(
|
|
or(
|
|
eq(MessengerFriendships.userOneId, userId),
|
|
eq(MessengerFriendships.userTwoId, userId),
|
|
),
|
|
)
|
|
.catch(() => [{ value: 0 }]),
|
|
db
|
|
.select({ value: count() })
|
|
.from(MessengerOffline)
|
|
.where(eq(MessengerOffline.userId, userId))
|
|
.catch(() => [{ value: 0 }]),
|
|
db
|
|
.select({ referralsTotal: UserReferrals.referralsTotal })
|
|
.from(UserReferrals)
|
|
.where(eq(UserReferrals.userId, userId))
|
|
.orderBy(desc(UserReferrals.id))
|
|
.limit(1)
|
|
.catch(() => []),
|
|
db
|
|
.select({
|
|
id: User.id,
|
|
username: User.username,
|
|
look: User.look,
|
|
motto: User.motto,
|
|
online: User.online,
|
|
})
|
|
.from(MessengerFriendships)
|
|
.innerJoin(
|
|
User,
|
|
or(
|
|
and(
|
|
eq(MessengerFriendships.userOneId, userId),
|
|
eq(User.id, MessengerFriendships.userTwoId),
|
|
),
|
|
and(
|
|
eq(MessengerFriendships.userTwoId, userId),
|
|
eq(User.id, MessengerFriendships.userOneId),
|
|
),
|
|
),
|
|
)
|
|
.where(
|
|
or(
|
|
eq(MessengerFriendships.userOneId, userId),
|
|
eq(MessengerFriendships.userTwoId, userId),
|
|
),
|
|
)
|
|
.catch(() => []),
|
|
]);
|
|
|
|
const user = userRows[0] ?? null;
|
|
if (!user) throw new Error("user-not-found");
|
|
|
|
const userSettings = userSettingsRows[0] ?? null;
|
|
const friendCount = Number(friendCountRows[0]?.value ?? 0);
|
|
const unreadCount = Number(unreadCountRows[0]?.value ?? 0);
|
|
|
|
const needed = Number.parseInt(neededRaw ?? "5", 10) || 5;
|
|
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
|
|
const rewardCurrency = (
|
|
currencySettings.referral_reward_currency_type ??
|
|
currencySettings.referral_reward_currency ??
|
|
"diamonds"
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
|
|
const referralTotal = referralsRows[0]
|
|
? Number(referralsRows[0].referralsTotal)
|
|
: 0;
|
|
const canClaim = referralTotal >= needed;
|
|
const remaining = Math.max(0, needed - referralTotal);
|
|
|
|
const onlineFriends = friends.filter((f) => f.online === "1");
|
|
|
|
const registered = new Date(user.accountCreated * 1000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
const lastLoginDate = user.lastLogin
|
|
? new Date(user.lastLogin * 1000).toISOString().slice(0, 10)
|
|
: "Never";
|
|
const lastOnlineDate = user.lastOnline
|
|
? new Date(user.lastOnline * 1000).toISOString().slice(0, 10)
|
|
: "Never";
|
|
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
|
|
|
|
const avatarFull = avatarImageUrl(user.look, {
|
|
direction: 2,
|
|
});
|
|
|
|
content = (
|
|
<div className="flex flex-col gap-8 pb-8">
|
|
{claimed ? (
|
|
<div role="status" style={feedbackStyle("success")}>
|
|
Woah! You have successfully claimed your reward — keep up the good
|
|
work!
|
|
</div>
|
|
) : null}
|
|
{error ? (
|
|
<div role="alert" style={feedbackStyle("error")}>
|
|
{getErrorMessage(error)}
|
|
</div>
|
|
) : null}
|
|
|
|
<Reveal>
|
|
<div
|
|
className="relative overflow-hidden rounded-2xl border p-6 flex items-start gap-6"
|
|
style={{
|
|
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)), var(--color-surface))`,
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
|
}}
|
|
>
|
|
<div
|
|
className="absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10"
|
|
style={{ background: "var(--color-primary)" }}
|
|
/>
|
|
<div className="relative shrink-0 flex flex-col items-center gap-3">
|
|
<div
|
|
className="absolute -inset-3 rounded-full opacity-[0.15] blur-[40px]"
|
|
style={{ background: "var(--color-primary)" }}
|
|
/>
|
|
<Image
|
|
src={avatarFull}
|
|
alt=""
|
|
width={120}
|
|
height={160}
|
|
className="object-contain drop-shadow-xl relative"
|
|
unoptimized
|
|
priority
|
|
/>
|
|
<Link
|
|
href="/client"
|
|
prefetch={false}
|
|
className="btn-shine flex items-center justify-center gap-1.5 rounded-xl border py-2 px-4 font-extrabold text-xs transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
|
style={{
|
|
background: "var(--color-primary)",
|
|
color: "var(--color-primary-foreground)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
|
boxShadow:
|
|
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
|
}}
|
|
>
|
|
<Image
|
|
src="/assets/images/EnterHubbly.png"
|
|
alt=""
|
|
width={16}
|
|
height={16}
|
|
unoptimized
|
|
/>
|
|
Enter {hotelName}
|
|
</Link>
|
|
</div>
|
|
<div className="flex-1 min-w-0 pt-1">
|
|
<div
|
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider mb-3 border"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
|
color: "var(--color-primary-readable, var(--color-primary))",
|
|
}}
|
|
>
|
|
Rank {user.rank}
|
|
</div>
|
|
<h1
|
|
className="text-2xl md:text-3xl font-black tracking-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{user.username}
|
|
</h1>
|
|
<p
|
|
className="text-sm mt-1"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{user.motto || "No motto"}
|
|
</p>
|
|
<div className="flex flex-wrap gap-x-6 gap-y-1 mt-4 text-xs">
|
|
<span style={{ color: "var(--color-text-muted)" }}>
|
|
Registered:{" "}
|
|
<strong style={{ color: "var(--color-text-readable)" }}>
|
|
{registered}
|
|
</strong>
|
|
</span>
|
|
<span style={{ color: "var(--color-text-muted)" }}>
|
|
Last online:{" "}
|
|
<strong style={{ color: "var(--color-text-readable)" }}>
|
|
{lastOnlineDate}
|
|
</strong>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Reveal>
|
|
|
|
{alertRaw ? (
|
|
<Reveal delay={0.05}>
|
|
<div
|
|
className="rounded-2xl border px-6 py-4 text-center"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-accent) 12%, transparent)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-accent) 25%, transparent)",
|
|
}}
|
|
>
|
|
<p
|
|
className="text-sm font-bold"
|
|
style={{ color: "var(--color-accent)" }}
|
|
>
|
|
{alertRaw}
|
|
</p>
|
|
</div>
|
|
</Reveal>
|
|
) : null}
|
|
|
|
<Reveal delay={0.1}>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
|
{[
|
|
{ value: user.credits.toLocaleString(), label: "Credits" },
|
|
{ value: `Rank ${user.rank}`, label: "Hotel rank" },
|
|
{
|
|
value: (userSettings?.achievementScore ?? 0).toLocaleString(),
|
|
label: "Achievement score",
|
|
},
|
|
{
|
|
value: (userSettings?.respectsReceived ?? 0).toLocaleString(),
|
|
label: "Respects",
|
|
},
|
|
{ value: friendCount.toLocaleString(), label: "Friends" },
|
|
{ value: lastLoginDate, label: "Last login" },
|
|
].map((s) => (
|
|
<SurfaceCard
|
|
key={s.label}
|
|
className="relative p-5 text-center transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
|
>
|
|
<div
|
|
className="text-xl md:text-2xl font-black tracking-tight mb-0.5"
|
|
style={{ color: "var(--color-primary)" }}
|
|
>
|
|
{s.value}
|
|
</div>
|
|
<div
|
|
className="text-[11px] font-bold uppercase tracking-widest"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{s.label}
|
|
</div>
|
|
</SurfaceCard>
|
|
))}
|
|
</div>
|
|
</Reveal>
|
|
|
|
<Reveal delay={0.15}>
|
|
<SurfaceCard className="p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2
|
|
className="text-lg font-black tracking-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
Online friends
|
|
</h2>
|
|
<p
|
|
className="text-xs mt-0.5"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{onlineFriends.length === 0
|
|
? "None of your friends are online right now"
|
|
: `${onlineFriends.length} of your ${friends.length} friends online`}
|
|
</p>
|
|
</div>
|
|
<Link
|
|
href="/friends"
|
|
className="text-xs font-bold hover:underline shrink-0"
|
|
style={{ color: "var(--color-primary)" }}
|
|
>
|
|
View all
|
|
</Link>
|
|
</div>
|
|
{onlineFriends.length === 0 ? (
|
|
<div className="text-center py-6">
|
|
<p
|
|
className="text-sm"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
No friends online right now. Check back later!
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
{onlineFriends.map((friend) => (
|
|
<Link
|
|
key={friend.id}
|
|
href={`/u/${friend.username}`}
|
|
className="flex items-center gap-3 p-3 rounded-xl transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
|
textDecoration: "none",
|
|
}}
|
|
>
|
|
<UserAvatarThumbnail
|
|
figure={friend.look}
|
|
alt=""
|
|
className="rounded-lg shrink-0"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p
|
|
className="text-sm font-extrabold truncate"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{friend.username}
|
|
</p>
|
|
<p
|
|
className="text-[10px] font-semibold truncate mt-0.5"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{friend.motto || "No motto"}
|
|
</p>
|
|
<span
|
|
className="inline-flex items-center gap-1 text-[10px] font-bold mt-1"
|
|
style={{ color: "var(--color-success)" }}
|
|
>
|
|
<span
|
|
className="w-1.5 h-1.5 rounded-full"
|
|
style={{ background: "var(--color-success)" }}
|
|
/>{" "}
|
|
Online
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
|
|
<Reveal delay={0.2}>
|
|
<SurfaceCard className="p-5">
|
|
<div className="mb-4">
|
|
<h2
|
|
className="text-lg font-black tracking-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
Refer a friend ({referralTotal}/{needed})
|
|
</h2>
|
|
<p
|
|
className="text-xs mt-0.5"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
Refer new users and earn in-game rewards
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-4">
|
|
<p
|
|
className="text-sm"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{rewardAmount > 0
|
|
? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!`
|
|
: `Invite ${needed} users through your referral link to claim a reward!`}
|
|
</p>
|
|
<div role="note" style={feedbackStyle("warning")}>
|
|
Boosting referrals by making your own accounts will lead to
|
|
removal of all progress, currency, inventory and a potential
|
|
ban.
|
|
</div>
|
|
<div>
|
|
<label
|
|
htmlFor="referral-link"
|
|
className="text-xs font-bold mb-1.5 block"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
Your referral link
|
|
</label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
<input
|
|
id="referral-link"
|
|
type="text"
|
|
readOnly
|
|
defaultValue={referralLink}
|
|
className="input-glow flex-1 min-w-0 rounded-xl border-2 px-4 py-2.5 text-sm font-medium"
|
|
style={{
|
|
backgroundColor: "var(--color-background)",
|
|
color: "var(--color-text-readable, var(--color-text))",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
|
}}
|
|
/>
|
|
<CopyReferralButton value={referralLink} />
|
|
</div>
|
|
</div>
|
|
{canClaim ? (
|
|
<form action={claimReferral}>
|
|
<button
|
|
type="submit"
|
|
className="btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
|
style={{
|
|
background: "var(--color-primary)",
|
|
color: "var(--color-primary-foreground)",
|
|
boxShadow:
|
|
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
|
}}
|
|
>
|
|
Claim your referral reward!
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled
|
|
className="w-full rounded-xl font-extrabold text-sm py-3.5 opacity-60 cursor-not-allowed"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
|
color: "var(--color-text-muted)",
|
|
}}
|
|
>
|
|
{`Refer ${remaining} more ${remaining === 1 ? "user" : "users"} to unlock your reward`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
|
|
{recentRooms.length > 0 && (
|
|
<Reveal delay={0.25}>
|
|
<SurfaceCard className="p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2
|
|
className="text-lg font-black tracking-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
Recent rooms
|
|
</h2>
|
|
<p
|
|
className="text-xs mt-0.5"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
Newest rooms in the hotel
|
|
</p>
|
|
</div>
|
|
<Link
|
|
href="/community"
|
|
className="text-xs font-bold hover:underline shrink-0"
|
|
style={{ color: "var(--color-primary)" }}
|
|
>
|
|
View all
|
|
</Link>
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
{recentRooms.map((room) => (
|
|
<Link
|
|
key={room.id}
|
|
href={`/room/${room.id}`}
|
|
className="block rounded-xl border p-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
|
}}
|
|
>
|
|
<p
|
|
className="text-sm font-extrabold truncate"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{room.name}
|
|
</p>
|
|
<p
|
|
className="text-[10px] font-semibold mt-0.5"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{room.ownerName} · {room.users}/{room.usersMax}
|
|
</p>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
)}
|
|
|
|
{badges.length > 0 && (
|
|
<Reveal delay={0.3}>
|
|
<SurfaceCard className="p-5">
|
|
<div className="mb-4">
|
|
<h2
|
|
className="text-lg font-black tracking-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
Badges
|
|
</h2>
|
|
<p
|
|
className="text-xs mt-0.5"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{badges.length} badge{badges.length !== 1 ? "s" : ""} equipped
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{badges.map((badge) => (
|
|
<Image
|
|
key={badge.badgeCode}
|
|
src={`${BADGE_IMG_BASE}/${badge.badgeCode}.gif`}
|
|
alt={badge.badgeCode}
|
|
width={40}
|
|
height={40}
|
|
className="rounded-md transition-transform hover:scale-110"
|
|
unoptimized
|
|
/>
|
|
))}
|
|
</div>
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
)}
|
|
|
|
<Reveal delay={0.35}>
|
|
<SurfaceCard className="p-5">
|
|
<h2
|
|
className="text-lg font-black tracking-tight mb-4"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
Quick links
|
|
</h2>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
{[
|
|
{ href: "/messages", label: "Messages", badge: unreadCount },
|
|
{ href: `/u/${user.username}`, label: "My profile", badge: 0 },
|
|
{ href: "/settings", label: "Settings", badge: 0 },
|
|
{ href: "/shop", label: "Shop", badge: 0 },
|
|
].map((link) => (
|
|
<Link
|
|
key={link.href}
|
|
href={link.href}
|
|
className="relative flex items-center justify-center rounded-xl border p-3 text-sm font-bold transition-all hover:-translate-y-0.5 hover:shadow-md"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 6%, transparent)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
|
color: "var(--color-text-readable)",
|
|
}}
|
|
>
|
|
{link.label}
|
|
{link.badge > 0 && (
|
|
<span
|
|
className="absolute -top-1.5 -right-1.5 flex min-w-[18px] h-[18px] items-center justify-center rounded-full px-1 text-[10px] font-bold leading-none"
|
|
style={{
|
|
background: "var(--color-danger)",
|
|
color: "white",
|
|
}}
|
|
>
|
|
{link.badge > 99 ? "99+" : link.badge}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
</div>
|
|
);
|
|
} catch {
|
|
content = (
|
|
<SurfaceCard className="p-6 text-center">
|
|
<p
|
|
className="text-sm font-bold"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
We couldn't load your dashboard right now. Please try again
|
|
shortly.
|
|
</p>
|
|
</SurfaceCard>
|
|
);
|
|
}
|
|
|
|
return <main>{content}</main>;
|
|
}
|