- New src/lib/site-icons.ts: base64 data URIs for the 13 tiny classic icons actually referenced in markup (100–2000 bytes), replacing extra requests with inline payloads. home.png, dynamic flags and currency sets stay on the filesystem. - Default favicon is now served server-side as a base64 SVG data URI (memoized), while a DB-configured custom favicon still takes priority. - Icons render through <Image unoptimized>, so data URIs pass through untouched on all affected pages (home, login, register, settings, navigation, auth top bar, client loading).
630 lines
19 KiB
TypeScript
630 lines
19 KiB
TypeScript
import { count, desc, eq } from "drizzle-orm";
|
|
import type { Metadata } from "next";
|
|
import { headers } from "next/headers";
|
|
import Image from "next/image";
|
|
import { redirect } from "next/navigation";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { PublicLoadError } from "@/components/public/load-error";
|
|
import { publicReadFailure } from "@/lib/services/public-read";
|
|
import {
|
|
ICON_ARTICLE,
|
|
ICON_CAMERA,
|
|
ICON_CATALOG,
|
|
ICON_FRIENDS,
|
|
ICON_LIGHTHOUSE,
|
|
ICON_NAV_GOODY,
|
|
ICON_NAV_ME,
|
|
} from "@/lib/site-icons";
|
|
|
|
export const metadata: Metadata = {
|
|
title: "Home",
|
|
description:
|
|
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
|
openGraph: {
|
|
title: "Home",
|
|
description:
|
|
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
|
type: "website",
|
|
},
|
|
};
|
|
|
|
import { AmbientOrbs } from "@/components/ambient-orbs";
|
|
import { AnimatedCounter } from "@/components/animated-counter";
|
|
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
|
import { Clock } from "@/components/clock";
|
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
import Link from "@/components/link";
|
|
import { LiveOnlineCount } from "@/components/live-online-count";
|
|
import { LiveOnlineCounter } from "@/components/live-online-counter";
|
|
import { Reveal } from "@/components/motion-reveal";
|
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|
import { SurfaceCard } from "@/components/surface-card";
|
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
|
import { TypewriterText } from "@/components/typewriter-text";
|
|
import { auth } from "@/lib/auth";
|
|
import { cached } from "@/lib/cache";
|
|
import { CameraWeb, db, Rooms, User } from "@/lib/db";
|
|
import { formatDate } from "@/lib/format-date";
|
|
import { resolveHotelName } from "@/lib/hotel-name";
|
|
import { captchaConfig } from "@/lib/services/captcha";
|
|
import { getNewsList } from "@/lib/services/news-list";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
async function getHotelData() {
|
|
const [hotelName, logo] = await Promise.all([
|
|
resolveHotelName(),
|
|
siteSettings.get("cms_logo", ""),
|
|
]);
|
|
|
|
const [online, users, rooms, articles, recentUsers, recentPhotos] =
|
|
await Promise.all([
|
|
cached("online_count", 10_000, () =>
|
|
db
|
|
.select({ total: count() })
|
|
.from(User)
|
|
.where(eq(User.online, "1"))
|
|
.then((rows) => rows[0]?.total ?? 0),
|
|
).catch(publicReadFailure("home.online")),
|
|
cached("total_users", 300_000, () =>
|
|
db
|
|
.select({ total: count() })
|
|
.from(User)
|
|
.then((rows) => rows[0]?.total ?? 0),
|
|
).catch(publicReadFailure("home.users")),
|
|
cached("total_rooms", 300_000, () =>
|
|
db
|
|
.select({ total: count() })
|
|
.from(Rooms)
|
|
.then((rows) => rows[0]?.total ?? 0),
|
|
).catch(publicReadFailure("home.rooms")),
|
|
getNewsList(4, { throwOnError: true }).catch(
|
|
publicReadFailure("home.news"),
|
|
),
|
|
cached("home_online_users", 15_000, () =>
|
|
db
|
|
.select({ username: User.username, look: User.look })
|
|
.from(User)
|
|
.where(eq(User.online, "1"))
|
|
.limit(12),
|
|
).catch(publicReadFailure("home.recent-users")),
|
|
cached("home_recent_photos", 60_000, () =>
|
|
db
|
|
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
|
.from(CameraWeb)
|
|
.orderBy(desc(CameraWeb.timestamp))
|
|
.limit(4),
|
|
).catch(publicReadFailure("home.photos")),
|
|
]);
|
|
|
|
return {
|
|
hotelName,
|
|
online,
|
|
users,
|
|
rooms,
|
|
articles,
|
|
recentUsers,
|
|
recentPhotos,
|
|
logo,
|
|
};
|
|
}
|
|
|
|
export default async function Home() {
|
|
const session = await auth();
|
|
if (session?.user?.id) redirect("/me");
|
|
|
|
const tp = await getTranslations("pages.home");
|
|
const te = await getTranslations("publicData");
|
|
const th = await getTranslations("header");
|
|
const {
|
|
hotelName,
|
|
online,
|
|
users,
|
|
rooms,
|
|
articles,
|
|
recentUsers,
|
|
recentPhotos,
|
|
logo,
|
|
} = await getHotelData();
|
|
|
|
const captcha = await captchaConfig();
|
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
|
|
|
const features = [
|
|
{
|
|
title: tp("featuresAvatar"),
|
|
desc: tp("featuresAvatarDesc"),
|
|
icon: ICON_FRIENDS,
|
|
},
|
|
{
|
|
title: tp("featuresFurniture"),
|
|
desc: tp("featuresFurnitureDesc"),
|
|
icon: ICON_CATALOG,
|
|
},
|
|
{
|
|
title: tp("featuresCommunity"),
|
|
desc: tp("featuresCommunityDesc"),
|
|
icon: ICON_NAV_GOODY,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 px-1 sm:px-0 md:gap-10 pb-12 md:pb-16">
|
|
{/* ── Top Bar ── */}
|
|
<Reveal>
|
|
<div
|
|
className="relative flex items-center justify-between rounded-xl border px-3 py-2.5 sm:px-5 sm:py-3 card-hairline"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-surface) 60%, transparent)",
|
|
backdropFilter: "blur(14px)",
|
|
WebkitBackdropFilter: "blur(14px)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
boxShadow:
|
|
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 sm:gap-3">
|
|
{logo ? (
|
|
<Image
|
|
src={logo}
|
|
alt={hotelName}
|
|
width={100}
|
|
height={28}
|
|
className="shrink-0 object-contain"
|
|
unoptimized
|
|
style={{ maxHeight: 28, width: "auto", height: "auto" }}
|
|
/>
|
|
) : (
|
|
<Image
|
|
src={ICON_LIGHTHOUSE}
|
|
alt={hotelName}
|
|
width={28}
|
|
height={28}
|
|
className="shrink-0"
|
|
unoptimized
|
|
/>
|
|
)}
|
|
<span
|
|
className="hidden sm:inline text-sm font-extrabold uppercase tracking-wide"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{hotelName}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="flex items-center gap-1 sm:gap-1.5 rounded-lg px-1.5 py-1 sm:px-2"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-text-muted) 4%, transparent)",
|
|
}}
|
|
>
|
|
<LanguageSwitcher />
|
|
<div
|
|
className="hidden sm:block w-px h-5"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
|
}}
|
|
/>
|
|
<Clock />
|
|
<div
|
|
className="w-px h-5"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
|
}}
|
|
/>
|
|
<ThemeSwitcher />
|
|
</div>
|
|
</div>
|
|
</Reveal>
|
|
|
|
{/* ── Hero ── */}
|
|
<Reveal>
|
|
<div
|
|
className="relative overflow-hidden rounded-xl border"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
boxShadow:
|
|
"0 4px 24px rgba(0,0,0,0.08), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
|
|
}}
|
|
>
|
|
<Image
|
|
src="/assets/images/next_header.jpg"
|
|
alt=""
|
|
fill
|
|
priority
|
|
sizes="100vw"
|
|
className="ken-burns"
|
|
style={{ objectFit: "cover", objectPosition: "center 20%" }}
|
|
/>
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
background:
|
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 92%, transparent) 0%, color-mix(in srgb, var(--color-surface) 45%, transparent) 50%, color-mix(in srgb, var(--color-surface) 12%, transparent) 100%)",
|
|
}}
|
|
/>
|
|
<AmbientOrbs />
|
|
<div
|
|
className="relative flex flex-col items-center gap-5 px-4 py-10 text-center sm:gap-8 sm:px-6 sm:py-12 md:px-12 md:py-20"
|
|
style={{ zIndex: 2 }}
|
|
>
|
|
<div
|
|
className="brand-halo left-1/2 top-4 h-48 w-48 -translate-x-1/2 sm:top-6 sm:h-64 sm:w-64"
|
|
style={{ zIndex: -1 }}
|
|
/>
|
|
<div
|
|
className="inline-flex items-center gap-2 px-3 py-1 sm:px-4 sm:py-1.5 rounded-full text-[10px] sm:text-xs font-bold uppercase tracking-widest mb-1 border backdrop-blur-sm"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
|
color: "var(--color-primary-readable, var(--color-primary))",
|
|
}}
|
|
>
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
|
</span>
|
|
{online === null ? (
|
|
<span>{te("unavailable")}</span>
|
|
) : (
|
|
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
|
)}
|
|
</div>
|
|
<h1
|
|
className="text-2xl font-black leading-[1.05] tracking-tight mb-2 sm:text-3xl md:text-5xl lg:text-6xl"
|
|
style={{
|
|
fontFamily: "var(--font-nunito)",
|
|
textShadow:
|
|
"0 1px 0 color-mix(in srgb, #ffffff 35%, transparent), 0 2px 14px color-mix(in srgb, #000 28%, transparent)",
|
|
}}
|
|
>
|
|
<span style={{ color: "var(--color-text-readable)" }}>
|
|
{tp("welcome", { hotelName: "" })}
|
|
</span>{" "}
|
|
<span className="gradient-text">
|
|
<TypewriterText text={hotelName} />
|
|
</span>
|
|
</h1>
|
|
<p
|
|
className="text-sm leading-relaxed max-w-2xl mb-5 sm:text-base md:text-lg md:mb-8"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{tp("welcomeBody")}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2.5 sm:gap-3 justify-center">
|
|
<Link
|
|
href="/register"
|
|
className="btn-shine inline-flex items-center gap-2 sm:gap-2.5 font-extrabold text-xs sm:text-sm px-5 sm:px-8 py-3 sm:py-4 rounded-xl transition-all duration-300 ease-out hover:scale-[1.03] 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)",
|
|
}}
|
|
>
|
|
<Image
|
|
src="/assets/images/EnterHubbly.png"
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
unoptimized
|
|
/>
|
|
{tp("register")}
|
|
</Link>
|
|
<Link
|
|
href="/login"
|
|
className="inline-flex items-center gap-2 font-bold text-xs sm:text-sm px-5 sm:px-8 py-3 sm:py-4 rounded-xl border-2 transition-all duration-300 ease-out hover:scale-[1.03] active:scale-95 backdrop-blur-sm"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
|
|
color: "var(--color-text-readable)",
|
|
background:
|
|
"color-mix(in srgb, var(--color-surface) 50%, transparent)",
|
|
}}
|
|
>
|
|
{th("login")}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Reveal>
|
|
|
|
{/* ── Features ── */}
|
|
<Reveal>
|
|
<div className="flex flex-col gap-4">
|
|
<div>
|
|
<span className="eyebrow">{tp("sectionFeaturesLabel")}</span>
|
|
<h2 className="section-title mt-2 text-xl sm:text-2xl">
|
|
{tp("sectionFeaturesTitle")}
|
|
</h2>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
|
|
{features.map((f) => (
|
|
<SurfaceCard
|
|
key={f.title}
|
|
className="card-glow relative p-4 sm:p-5 text-center hover:-translate-y-1"
|
|
>
|
|
<div
|
|
className="mx-auto mb-3 flex h-11 w-11 sm:h-12 sm:w-12 items-center justify-center rounded-xl"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
|
boxShadow:
|
|
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
|
}}
|
|
>
|
|
<Image
|
|
src={f.icon}
|
|
alt=""
|
|
width={24}
|
|
height={24}
|
|
unoptimized
|
|
/>
|
|
</div>
|
|
<h3
|
|
className="text-sm sm:text-base font-extrabold mb-1"
|
|
style={{
|
|
color: "var(--color-text-readable)",
|
|
fontFamily: "var(--font-nunito)",
|
|
}}
|
|
>
|
|
{f.title}
|
|
</h3>
|
|
<p
|
|
className="text-xs sm:text-sm leading-relaxed"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{f.desc}
|
|
</p>
|
|
</SurfaceCard>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Reveal>
|
|
|
|
{/* ── Stats + Login ── */}
|
|
<Reveal>
|
|
<div className="flex flex-col gap-4">
|
|
<div>
|
|
<span className="eyebrow">{tp("sectionStatsLabel")}</span>
|
|
<h2 className="section-title mt-2 text-xl sm:text-2xl">
|
|
{tp("sectionStatsTitle")}
|
|
</h2>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-4">
|
|
{[
|
|
{
|
|
value: users,
|
|
label: tp("statsCitizens"),
|
|
icon: ICON_FRIENDS,
|
|
live: false,
|
|
},
|
|
{
|
|
value: rooms,
|
|
label: tp("statsRooms"),
|
|
icon: ICON_CATALOG,
|
|
live: false,
|
|
},
|
|
{
|
|
value: online,
|
|
label: tp("statsOnline"),
|
|
icon: ICON_NAV_ME,
|
|
live: true,
|
|
},
|
|
{
|
|
value: articles?.length ?? null,
|
|
label: tp("statsArticles"),
|
|
icon: ICON_ARTICLE,
|
|
live: false,
|
|
},
|
|
].map((s) => (
|
|
<SurfaceCard
|
|
key={s.label}
|
|
className="card-glow relative p-3 sm:p-5 text-center hover:-translate-y-1"
|
|
>
|
|
<div className="mb-0.5 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl">
|
|
{s.value === null ? (
|
|
<span className="text-base">{te("unavailable")}</span>
|
|
) : s.live ? (
|
|
<LiveOnlineCounter initial={s.value} />
|
|
) : (
|
|
<AnimatedCounter value={s.value} />
|
|
)}
|
|
</div>
|
|
<div
|
|
className="text-[10px] sm:text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-1 sm:gap-1.5"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
<Image
|
|
src={s.icon}
|
|
alt=""
|
|
width={14}
|
|
height={14}
|
|
unoptimized
|
|
/>
|
|
{s.label}
|
|
</div>
|
|
</SurfaceCard>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Reveal>
|
|
|
|
{/* ── Login + Online users ── */}
|
|
<div>
|
|
<span className="eyebrow">{tp("sectionCommunityLabel")}</span>
|
|
<h2 className="section-title mt-2 text-xl sm:text-2xl">
|
|
{tp("sectionCommunityTitle")}
|
|
</h2>
|
|
</div>
|
|
<div className="flex flex-col gap-4 md:flex-row">
|
|
<Reveal className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
|
|
<SurfaceCard
|
|
title={th("login")}
|
|
icon={ICON_NAV_ME}
|
|
bodyClassName="p-3.5 sm:p-4"
|
|
>
|
|
<HomeLoginForm
|
|
captcha={{
|
|
provider: captcha.provider,
|
|
siteKey: captcha.siteKey || undefined,
|
|
field: captcha.field || undefined,
|
|
}}
|
|
nonce={nonce}
|
|
/>
|
|
</SurfaceCard>
|
|
|
|
{recentUsers === null ? (
|
|
<SurfaceCard title={tp("recentUsers")} bodyClassName="p-4">
|
|
<PublicLoadError href="/" />
|
|
</SurfaceCard>
|
|
) : (
|
|
recentUsers.length > 0 && (
|
|
<SurfaceCard
|
|
title={tp("recentUsers")}
|
|
icon={ICON_NAV_GOODY}
|
|
actionHref="/community"
|
|
actionLabel={tp("allUsers")}
|
|
bodyClassName="p-3.5 sm:p-4"
|
|
>
|
|
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
|
{recentUsers.map((u) => (
|
|
<div
|
|
key={u.username}
|
|
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-colors hover:bg-[color-mix(in_srgb,var(--color-primary)_5%,transparent)]"
|
|
>
|
|
<UserAvatarThumbnail
|
|
figure={u.look}
|
|
alt={u.username}
|
|
options={{ direction: 2 }}
|
|
className="rounded-md"
|
|
/>
|
|
<span
|
|
className="max-w-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{u.username}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SurfaceCard>
|
|
)
|
|
)}
|
|
</Reveal>
|
|
|
|
{/* News */}
|
|
<Reveal delay={0.15} className="flex w-full flex-col gap-4 md:w-2/3">
|
|
<SurfaceCard
|
|
title={tp("latestNews")}
|
|
icon={ICON_ARTICLE}
|
|
actionHref="/news"
|
|
actionLabel={tp("allNews")}
|
|
>
|
|
{articles === null ? (
|
|
<PublicLoadError href="/" />
|
|
) : articles.length > 0 ? (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
|
{articles.slice(0, 4).map((a) => (
|
|
<Link
|
|
key={a.slug}
|
|
href={`/news/${a.slug}`}
|
|
className="group relative w-full overflow-hidden rounded-xl transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
|
style={{
|
|
height: "clamp(160px, 20vw, 200px)",
|
|
backgroundColor: "var(--color-background)",
|
|
border:
|
|
"1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
|
}}
|
|
>
|
|
<div className="relative h-full w-full overflow-hidden rounded-xl">
|
|
<Image
|
|
src={a.image}
|
|
alt={a.title}
|
|
fill
|
|
sizes="(max-width: 640px) 100vw, 400px"
|
|
className="rounded-xl object-cover transition-all duration-300 group-hover:scale-105"
|
|
/>
|
|
<div className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full bg-foreground/50 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100">
|
|
<span className="text-sm font-bold text-primary-foreground">
|
|
→
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="absolute bottom-0 left-0 w-full p-2.5 sm:p-3"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 50%, transparent 100%)",
|
|
}}
|
|
>
|
|
<h3 className="truncate text-sm sm:text-base font-bold text-primary-foreground text-shadow-sm">
|
|
{a.title}
|
|
</h3>
|
|
<p
|
|
className="mt-0.5 text-[10px] sm:text-[11px] font-semibold opacity-80"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{formatDate(a.createdAt, "date", "")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p
|
|
className="py-8 text-center text-sm"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{tp("noArticles")}
|
|
</p>
|
|
)}
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
</div>
|
|
|
|
{/* ── Recent photos ── */}
|
|
{recentPhotos === null ? (
|
|
<SurfaceCard title={tp("recentPhotos")} bodyClassName="p-4">
|
|
<PublicLoadError href="/" />
|
|
</SurfaceCard>
|
|
) : (
|
|
recentPhotos.length > 0 && (
|
|
<Reveal>
|
|
<SurfaceCard title={tp("recentPhotos")} icon={ICON_CAMERA}>
|
|
<div className="mx-auto grid max-w-md grid-cols-4 gap-2">
|
|
{recentPhotos.slice(0, 4).map((p) => (
|
|
<Link
|
|
key={p.id}
|
|
href="/photos"
|
|
className="block aspect-square overflow-hidden rounded-xl border transition-all duration-200 hover:shadow-md hover:scale-[1.02]"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
}}
|
|
>
|
|
<Image
|
|
src={p.url}
|
|
alt=""
|
|
width={200}
|
|
height={200}
|
|
className="h-full w-full object-cover"
|
|
unoptimized
|
|
/>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|