Add public profiles and cosmetic shop
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s
This commit is contained in:
43
app/cosmetics/page.tsx
Normal file
43
app/cosmetics/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { CosmeticsShop } from "@/components/CosmeticsShop";
|
||||
import { Footer, Header } from "@/components/Shell";
|
||||
import { getI18n } from "@/lib/i18n";
|
||||
import { accountProfile } from "@/lib/profile";
|
||||
import { profileCosmeticsState } from "@/lib/profile-cosmetics";
|
||||
import { user } from "@/lib/supabase";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const { t } = await getI18n();
|
||||
return { title: t("Profile cosmetics") };
|
||||
}
|
||||
|
||||
export default async function CosmeticsPage() {
|
||||
const account = await user();
|
||||
if (!account) redirect("/log-in?next=%2Fcosmetics");
|
||||
const [profile, shop] = await Promise.all([
|
||||
accountProfile(account),
|
||||
profileCosmeticsState(account.id),
|
||||
]);
|
||||
const { t } = await getI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="section page-content">
|
||||
<Link className="text-link" href="/dashboard">
|
||||
<ArrowLeft size={15} aria-hidden="true" /> {t("My account")}
|
||||
</Link>
|
||||
<CosmeticsShop
|
||||
accountId={account.id}
|
||||
avatarPath={profile.avatar_path}
|
||||
initialShop={shop}
|
||||
/>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,14 @@ export default async function DashboardPage() {
|
||||
.
|
||||
</p>
|
||||
<Dashboard />
|
||||
<div className="inline-actions dashboard-profile-actions">
|
||||
<Link className="button primary" href="/cosmetics">
|
||||
{t("Browse profile cosmetics")}
|
||||
</Link>
|
||||
<Link className="button" href={"/players/" + account.id}>
|
||||
{t("View public profile")}
|
||||
</Link>
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<section className="token-box">
|
||||
<h2>{t("Recent journeys")}</h2>
|
||||
|
||||
@@ -17,6 +17,7 @@ import "./mobile-game.css";
|
||||
import "./multiplayer.css";
|
||||
import "./support.css";
|
||||
import "./appearance.css";
|
||||
import "./profile-cosmetics.css";
|
||||
import "./community.css";
|
||||
import { themeInitScript } from "@/shared/theme";
|
||||
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
||||
|
||||
76
app/players/[id]/page.tsx
Normal file
76
app/players/[id]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Compass, MapPinned } from "lucide-react";
|
||||
import { Footer, Header } from "@/components/Shell";
|
||||
import { PlayerAvatar } from "@/components/PixelArt";
|
||||
import { Username } from "@/components/Username";
|
||||
import { getI18n } from "@/lib/i18n";
|
||||
import { publicProfile } from "@/lib/public-profile";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PlayerProfilePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const profile = await publicProfile(id);
|
||||
if (!profile) notFound();
|
||||
const { t, formatDate, formatNumber } = await getI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="section page-content public-profile-page">
|
||||
<Link className="text-link" href="/leaderboard">
|
||||
<ArrowLeft size={15} aria-hidden="true" /> {t("Back to leaderboard")}
|
||||
</Link>
|
||||
<section className="public-profile-hero">
|
||||
<PlayerAvatar
|
||||
id={profile.id}
|
||||
avatarPath={profile.avatar_path}
|
||||
effect={profile.profile_effect}
|
||||
className="public-profile-avatar"
|
||||
/>
|
||||
<div className="public-profile-copy">
|
||||
<span className="eyebrow">{t("MINEGEO EXPLORER")}</span>
|
||||
<h1>
|
||||
<Username name={profile.display_name} appearance={profile} />
|
||||
</h1>
|
||||
<p className="muted">
|
||||
{t("Exploring since {date}", {
|
||||
date: formatDate(profile.created_at),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="public-profile-about" aria-labelledby="profile-about">
|
||||
<h2 id="profile-about">{t("About this explorer")}</h2>
|
||||
<p className="public-profile-bio">
|
||||
{profile.bio || t("This explorer hasn’t added a description yet.")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="public-profile-stats" aria-label={t("Profile stats")}>
|
||||
<article className="public-profile-stat">
|
||||
<Compass size={19} aria-hidden="true" />
|
||||
<span>{t("Completed journeys")}</span>
|
||||
<strong>{formatNumber(profile.completedGames)}</strong>
|
||||
</article>
|
||||
<article className="public-profile-stat">
|
||||
<MapPinned size={19} aria-hidden="true" />
|
||||
<span>{t("Published maps")}</span>
|
||||
<strong>{formatNumber(profile.publishedMaps)}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<p className="public-profile-footnote">
|
||||
{t("Only public account details and published maps appear here.")}
|
||||
</p>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
409
app/profile-cosmetics.css
Normal file
409
app/profile-cosmetics.css
Normal file
@@ -0,0 +1,409 @@
|
||||
.profile-avatar-shell {
|
||||
display: inline-grid;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
line-height: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.profile-avatar-shell > .player-avatar {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.profile-avatar-shell[data-effect]::before,
|
||||
.profile-avatar-shell[data-effect]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.profile-avatar-shell[data-effect="redstone-pulse"]::before {
|
||||
inset: -4px;
|
||||
border: 2px solid #ef6556;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 8px #ef655688, inset 0 0 6px #ef655655;
|
||||
animation: redstone-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.profile-avatar-shell[data-effect="ender-orbit"]::before {
|
||||
inset: -5px;
|
||||
border: 1px solid #ad83fa;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 9px #8d62e877;
|
||||
transform: rotate(-24deg) scaleX(0.74);
|
||||
animation: ender-orbit 3.4s linear infinite;
|
||||
}
|
||||
|
||||
.profile-avatar-shell[data-effect="ender-orbit"]::after {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
top: -4px;
|
||||
right: 0;
|
||||
border-radius: 50%;
|
||||
background: #d3b7ff;
|
||||
box-shadow: 0 0 8px #a780ff;
|
||||
}
|
||||
|
||||
.profile-avatar-shell[data-effect="nether-flame"]::before {
|
||||
inset: -6px;
|
||||
border: 2px solid #ff8c43;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 0 9px #f4632d99, inset 0 -5px 10px #f4632d55;
|
||||
animation: nether-flicker 1.2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.profile-avatar-shell[data-effect="nether-flame"]::after {
|
||||
inset: -8px -3px;
|
||||
border-radius: 50%;
|
||||
border-top: 1px solid #ffd27c;
|
||||
border-bottom: 1px solid #f4632d99;
|
||||
filter: blur(1px);
|
||||
opacity: 0.7;
|
||||
animation: nether-flicker 1.7s ease-in-out infinite alternate-reverse;
|
||||
}
|
||||
|
||||
.profile-avatar-shell[data-effect="aurora-crown"]::before {
|
||||
inset: -6px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
#72f3d4,
|
||||
#75a9ff,
|
||||
#c89bff,
|
||||
#72f3d4
|
||||
);
|
||||
-webkit-mask: radial-gradient(
|
||||
farthest-side,
|
||||
transparent calc(100% - 2px),
|
||||
#000 calc(100% - 1px)
|
||||
);
|
||||
mask: radial-gradient(
|
||||
farthest-side,
|
||||
transparent calc(100% - 2px),
|
||||
#000 calc(100% - 1px)
|
||||
);
|
||||
filter: drop-shadow(0 0 5px #78e7d5aa);
|
||||
animation: aurora-turn 5s linear infinite;
|
||||
}
|
||||
|
||||
.profile-name-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dashboard-profile-actions {
|
||||
margin: 20px 0 34px;
|
||||
}
|
||||
|
||||
.profile-name-link:hover,
|
||||
.profile-name-link:focus-visible {
|
||||
color: var(--green);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.public-profile-page {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.public-profile-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
margin: 34px 0 28px;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
radial-gradient(ellipse at 12% 50%, #84c8a31b, transparent 55%),
|
||||
var(--panel);
|
||||
box-shadow: 4px 4px #03070b;
|
||||
}
|
||||
|
||||
.public-profile-avatar {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
box-shadow: 4px 4px #03070b;
|
||||
}
|
||||
|
||||
.public-profile-copy h1 {
|
||||
margin: 8px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.public-profile-copy p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.public-profile-about {
|
||||
margin: 28px 0;
|
||||
padding: 24px 28px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.public-profile-about h2 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 23px;
|
||||
}
|
||||
|
||||
.public-profile-bio {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.8;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.public-profile-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.public-profile-stat {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.public-profile-stat > svg {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.public-profile-stat > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.public-profile-stat > strong {
|
||||
grid-column: 2;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.public-profile-footnote {
|
||||
margin: 16px 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cosmetics-shop {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.cosmetics-shop-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 30px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cosmetics-shop-heading h1 {
|
||||
margin: 10px 0;
|
||||
font-size: clamp(38px, 6vw, 60px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cosmetics-shop-heading .lead {
|
||||
max-width: 610px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cosmetics-wallet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
flex: none;
|
||||
min-width: 190px;
|
||||
padding: 16px 19px;
|
||||
border: 1px solid #d8bd72;
|
||||
background: #241f14;
|
||||
box-shadow: 3px 3px #03070b;
|
||||
color: #f0d891;
|
||||
}
|
||||
|
||||
.cosmetics-wallet > span {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cosmetics-wallet small {
|
||||
color: #bcae84;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.cosmetics-wallet strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.cosmetics-earning-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 18px 0 28px;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cosmetics-earning-note svg {
|
||||
flex: none;
|
||||
color: #d8bd72;
|
||||
}
|
||||
|
||||
.cosmetics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.cosmetic-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 350px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-top: 3px solid #7b8c83;
|
||||
background: var(--panel);
|
||||
box-shadow: 3px 3px #03070b;
|
||||
}
|
||||
|
||||
.cosmetic-card[data-rarity="rare"] {
|
||||
border-top-color: #8984d8;
|
||||
}
|
||||
|
||||
.cosmetic-card[data-rarity="epic"] {
|
||||
border-top-color: #e08b55;
|
||||
}
|
||||
|
||||
.cosmetic-card[data-rarity="legendary"] {
|
||||
border-top-color: #79d4bd;
|
||||
}
|
||||
|
||||
.cosmetic-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.cosmetic-rarity {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.cosmetic-equipped {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--green);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.cosmetic-preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 128px;
|
||||
margin: 12px 0 14px;
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
radial-gradient(circle, #9caa7122 0, transparent 58%),
|
||||
#0b1117;
|
||||
}
|
||||
|
||||
.cosmetic-preview-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
box-shadow: 3px 3px #03070b;
|
||||
}
|
||||
|
||||
.cosmetic-card h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.cosmetic-card > p {
|
||||
flex: 1;
|
||||
margin: 0 0 18px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.cosmetic-card-action .button {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.cosmetics-real-money-note {
|
||||
margin: 24px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@keyframes redstone-pulse {
|
||||
0%, 100% { opacity: 0.65; box-shadow: 0 0 5px #ef655655; }
|
||||
50% { opacity: 1; box-shadow: 0 0 13px #ef6556bb, inset 0 0 6px #ef655655; }
|
||||
}
|
||||
|
||||
@keyframes ender-orbit {
|
||||
to { transform: rotate(336deg) scaleX(0.74); }
|
||||
}
|
||||
|
||||
@keyframes nether-flicker {
|
||||
from { opacity: 0.58; filter: saturate(0.9); }
|
||||
to { opacity: 1; filter: saturate(1.25); }
|
||||
}
|
||||
|
||||
@keyframes aurora-turn {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.profile-avatar-shell[data-effect]::before,
|
||||
.profile-avatar-shell[data-effect]::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.public-profile-hero {
|
||||
gap: 20px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.cosmetics-shop-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cosmetics-wallet {
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.public-profile-hero {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.public-profile-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export default async function Settings({
|
||||
accountId={account.id}
|
||||
email={account.email ?? ""}
|
||||
displayName={profile.display_name}
|
||||
bio={profile.bio}
|
||||
avatarPath={profile.avatar_path}
|
||||
appearance={profile}
|
||||
passwordUpdated={query.password === "updated"}
|
||||
|
||||
Reference in New Issue
Block a user