988 lines
30 KiB
Svelte
988 lines
30 KiB
Svelte
<script lang="ts">
|
|
import { API_URL, WS_URL } from '$lib/api.js';
|
|
import { apiFetch } from '$lib/api.js';
|
|
import { goto } from '$app/navigation';
|
|
import { onMount } from 'svelte';
|
|
|
|
let profile: any = $state(null);
|
|
let loading = $state(true);
|
|
let wishlistText = $state('');
|
|
let wishlistSaving = $state(false);
|
|
let wishlistSaved = $state(false);
|
|
let wishlistError = $state('');
|
|
|
|
let friends: any[] = $state([]);
|
|
let proposals: any[] = $state([]);
|
|
let challenges: any[] = $state([]);
|
|
|
|
let confirmingRemove: Set<string> = $state(new Set());
|
|
let confirmingWithdraw: Set<string> = $state(new Set());
|
|
|
|
// Increments every second — passed to formatChallengeExpiry to force re-evaluation
|
|
let tick = $state(0);
|
|
|
|
const token = () => localStorage.getItem('token');
|
|
|
|
onMount(async () => {
|
|
if (!token()) { goto('/auth'); return; }
|
|
const res = await apiFetch(`${API_URL}/profile`);
|
|
if (res.status === 401) { goto('/auth'); return; }
|
|
profile = await res.json();
|
|
wishlistText = profile.trade_wishlist || '';
|
|
|
|
const friendsRes = await apiFetch(`${API_URL}/friends`);
|
|
if (friendsRes.ok) friends = await friendsRes.json();
|
|
|
|
const proposalsRes = await apiFetch(`${API_URL}/trade-proposals`);
|
|
if (proposalsRes.ok) proposals = await proposalsRes.json();
|
|
|
|
const challengesRes = await apiFetch(`${API_URL}/challenges`);
|
|
if (challengesRes.ok) challenges = await challengesRes.json();
|
|
|
|
loading = false;
|
|
|
|
const tickInterval = setInterval(() => { tick++; }, 1000);
|
|
const pollInterval = setInterval(async () => {
|
|
const [pRes, cRes] = await Promise.all([
|
|
apiFetch(`${API_URL}/trade-proposals`),
|
|
apiFetch(`${API_URL}/challenges`),
|
|
]);
|
|
if (pRes.ok) proposals = await pRes.json();
|
|
if (cRes.ok) challenges = await cRes.json();
|
|
}, 30_000);
|
|
|
|
return () => {
|
|
clearInterval(tickInterval);
|
|
clearInterval(pollInterval);
|
|
};
|
|
});
|
|
|
|
|
|
function formatExpiry(isoString: string) {
|
|
const d = new Date(isoString);
|
|
const diff = d.getTime() - Date.now();
|
|
if (diff < 0) return 'expired';
|
|
const hrs = Math.floor(diff / 3600000);
|
|
if (hrs < 1) return 'in < 1h';
|
|
if (hrs < 24) return `in ${hrs}h`;
|
|
return `in ${Math.floor(hrs / 24)}d`;
|
|
}
|
|
|
|
async function saveWishlist() {
|
|
wishlistSaving = true;
|
|
wishlistError = '';
|
|
wishlistSaved = false;
|
|
const res = await apiFetch(`${API_URL}/profile`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ trade_wishlist: wishlistText }),
|
|
});
|
|
wishlistSaving = false;
|
|
if (res.ok) {
|
|
wishlistSaved = true;
|
|
setTimeout(() => { wishlistSaved = false; }, 2500);
|
|
} else {
|
|
wishlistError = 'Failed to save.';
|
|
}
|
|
}
|
|
|
|
function formatChallengeExpiry(isoString: string, _tick?: number) {
|
|
const secs = Math.max(0, Math.floor((new Date(isoString).getTime() - Date.now()) / 1000));
|
|
if (secs <= 0) return 'expired';
|
|
if (secs < 60) return `${secs}s remaining`;
|
|
return `${Math.floor(secs / 60)}m ${secs % 60}s remaining`;
|
|
}
|
|
|
|
async function withdrawChallenge(challengeId: string) {
|
|
const res = await apiFetch(`${API_URL}/challenges/${challengeId}/decline`, { method: 'POST' });
|
|
if (res.ok) challenges = challenges.filter((c: any) => c.id !== challengeId);
|
|
}
|
|
|
|
async function removeFriend(friendshipId: string) {
|
|
await apiFetch(`${API_URL}/friendships/${friendshipId}`, { method: 'DELETE' });
|
|
friends = friends.filter((f: any) => f.friendship_id !== friendshipId);
|
|
}
|
|
|
|
function logout() {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('refresh_token');
|
|
goto('/auth');
|
|
}
|
|
|
|
let resendStatus = $state('');
|
|
|
|
async function resendVerification() {
|
|
resendStatus = 'sending';
|
|
await apiFetch(`${API_URL}/auth/resend-verification`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: profile.email }),
|
|
});
|
|
resendStatus = 'sent';
|
|
}
|
|
</script>
|
|
|
|
<main>
|
|
{#if loading}
|
|
<p class="status">Loading...</p>
|
|
{:else if profile}
|
|
<div class="profile">
|
|
|
|
<div class="profile-header">
|
|
<div class="avatar">{profile.username[0].toUpperCase()}</div>
|
|
<div class="profile-info">
|
|
<h1 class="username">{profile.username}</h1>
|
|
<p class="email">
|
|
{profile.email}
|
|
{#if !profile.email_verified}
|
|
<span class="unverified-badge">unverified</span>
|
|
{/if}
|
|
</p>
|
|
{#if !profile.email_verified}
|
|
<button class="resend-btn" onclick={resendVerification} disabled={resendStatus === 'sending' || resendStatus === 'sent'}>
|
|
{resendStatus === 'sent' ? 'Email sent' : resendStatus === 'sending' ? 'Sending...' : 'Resend verification email'}
|
|
</button>
|
|
{/if}
|
|
<p class="joined">Member since {new Date(profile.created_at).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
|
|
</div>
|
|
<button class="logout-btn" onclick={logout}>Log Out</button>
|
|
<a href="/reset-password" class="reset-link">Change password</a>
|
|
</div>
|
|
|
|
<div class="section-divider"></div>
|
|
|
|
<div class="shards-row">
|
|
<span class="shards-icon">◈</span>
|
|
<span class="shards-value">{profile.shards}</span>
|
|
<span class="shards-label">Shards</span>
|
|
<a href="/shatter" class="shards-link">shatter cards</a>
|
|
</div>
|
|
|
|
<div class="section-divider"></div>
|
|
|
|
<h2 class="section-title">Stats</h2>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<span class="stat-label">Wins</span>
|
|
<span class="stat-value wins">{profile.wins}</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-label">Losses</span>
|
|
<span class="stat-value losses">{profile.losses}</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-label">Games Played</span>
|
|
<span class="stat-value">{profile.wins + profile.losses}</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-label">Win Rate</span>
|
|
<span class="stat-value" class:good-wr={profile.win_rate >= 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}>
|
|
{profile.win_rate !== null ? `${profile.win_rate}%` : '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-divider"></div>
|
|
|
|
<div class="wishlist-group">
|
|
<h2 class="section-title">Trade Wishlist</h2>
|
|
<div class="wishlist-section">
|
|
<p class="wishlist-hint">Cards or types you're looking to trade for. Visible on your public profile.</p>
|
|
<textarea
|
|
class="wishlist-textarea"
|
|
bind:value={wishlistText}
|
|
placeholder="e.g. Looking for legendary locations, rare scientists..."
|
|
rows="3"
|
|
></textarea>
|
|
<div class="wishlist-actions">
|
|
<button class="save-btn" onclick={saveWishlist} disabled={wishlistSaving}>
|
|
{wishlistSaving ? 'Saving...' : 'Save'}
|
|
</button>
|
|
{#if wishlistSaved}<span class="wishlist-ok">Saved ✓</span>{/if}
|
|
<p class="wishlist-error" style="min-height: 1.2em">{wishlistError}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-divider"></div>
|
|
|
|
<h2 class="section-title">Highlights</h2>
|
|
<div class="highlights">
|
|
<div class="highlight-card">
|
|
<span class="highlight-label">Most Played Deck</span>
|
|
{#if profile.most_played_deck}
|
|
<span class="highlight-value">{profile.most_played_deck.name}</span>
|
|
<span class="highlight-sub">{profile.most_played_deck.times_played} games</span>
|
|
{:else}
|
|
<span class="no-data">No games played yet</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="highlight-card">
|
|
<span class="highlight-label">Most Played Card</span>
|
|
{#if profile.most_played_card}
|
|
<div class="card-preview">
|
|
{#if profile.most_played_card.image_link}
|
|
<img src={profile.most_played_card.image_link} alt={profile.most_played_card.name} class="card-thumb" />
|
|
{/if}
|
|
<div class="card-preview-info">
|
|
<span class="highlight-value">{profile.most_played_card.name}</span>
|
|
<span class="highlight-sub">{profile.most_played_card.times_played} times played</span>
|
|
<span class="highlight-sub">{profile.most_played_card.card_type} · {profile.most_played_card.card_rarity}</span>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<span class="no-data">No cards played yet</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-divider"></div>
|
|
|
|
<h2 class="section-title">Friends</h2>
|
|
{#if friends.length === 0}
|
|
<p class="no-friends">No friends yet.</p>
|
|
{:else}
|
|
<ul class="friends-list">
|
|
{#each friends as f (f.friendship_id)}
|
|
<li class="friend-item">
|
|
<a href="/profile/{f.username}" class="friend-name">{f.username}</a>
|
|
{#if confirmingRemove.has(f.friendship_id)}
|
|
<span class="confirm-label">Remove friend?</span>
|
|
<button class="confirm-yes-btn" onclick={() => removeFriend(f.friendship_id)}>Confirm</button>
|
|
<button class="confirm-no-btn" onclick={() => { confirmingRemove.delete(f.friendship_id); confirmingRemove = confirmingRemove; }}>Cancel</button>
|
|
{:else}
|
|
<button class="unfriend-btn" onclick={() => { confirmingRemove.add(f.friendship_id); confirmingRemove = confirmingRemove; }}>Remove</button>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
<div class="section-divider"></div>
|
|
|
|
<h2 class="section-title">Trade Proposals</h2>
|
|
|
|
{#if proposals.length === 0}
|
|
<p class="no-friends">No trade proposals.</p>
|
|
{:else}
|
|
{@const incoming = proposals.filter((p: any) => p.direction === 'incoming' && p.status === 'pending')}
|
|
{@const outgoing = proposals.filter((p: any) => p.direction === 'outgoing' && p.status === 'pending')}
|
|
{@const resolved = proposals.filter((p: any) => p.status !== 'pending')}
|
|
|
|
{#if incoming.length > 0}
|
|
<p class="proposal-subhead">Incoming</p>
|
|
{#each incoming as p (p.id)}
|
|
<div class="proposal-card">
|
|
<div class="proposal-meta">
|
|
<a href="/profile/{p.proposer_username}" target="_blank" class="friend-name">{p.proposer_username}</a>
|
|
<span class="proposal-status pending">Pending</span>
|
|
<span class="proposal-expires">{formatExpiry(p.expires_at)}</span>
|
|
</div>
|
|
<p class="proposal-desc">
|
|
{#if p.requested_cards.length > 0}Wants: <strong>{p.requested_cards.map((c: any) => c.name).join(', ')}</strong><br/>{/if}
|
|
{#if p.offered_cards.length > 0}Offering: {p.offered_cards.map((c: any) => c.name).join(', ')}{/if}
|
|
</p>
|
|
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if outgoing.length > 0}
|
|
<p class="proposal-subhead">Outgoing</p>
|
|
{#each outgoing as p (p.id)}
|
|
<div class="proposal-card">
|
|
<div class="proposal-meta">
|
|
<span class="proposal-to">To: <a href="/profile/{p.recipient_username}" target="_blank" class="friend-name">{p.recipient_username}</a></span>
|
|
<span class="proposal-status pending">Pending</span>
|
|
<span class="proposal-expires">{formatExpiry(p.expires_at)}</span>
|
|
</div>
|
|
<p class="proposal-desc">
|
|
{#if p.requested_cards.length > 0}Requesting: <strong>{p.requested_cards.map((c: any) => c.name).join(', ')}</strong><br/>{/if}
|
|
{#if p.offered_cards.length > 0}Offering: {p.offered_cards.map((c: any) => c.name).join(', ')}{/if}
|
|
</p>
|
|
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if resolved.length > 0}
|
|
<p class="proposal-subhead">History</p>
|
|
{#each resolved as p (p.id)}
|
|
<div class="proposal-card resolved">
|
|
<div class="proposal-meta">
|
|
<span class="proposal-to">{p.direction === 'incoming' ? `From: ${p.proposer_username}` : `To: ${p.recipient_username}`}</span>
|
|
<span class="proposal-status {p.status}">{p.status}</span>
|
|
</div>
|
|
<p class="proposal-desc">{p.requested_cards.length} requested · {p.offered_cards.length} offered</p>
|
|
<a href="/trade/proposal/{p.id}" class="see-proposal-btn">See Proposal</a>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
|
|
|
|
{/if}
|
|
|
|
{#if challenges.length > 0}
|
|
<div class="section-divider"></div>
|
|
|
|
<h2 class="section-title">Game Challenges</h2>
|
|
|
|
{@const pendingOut = challenges.filter((c: any) => c.direction === 'outgoing' && c.status === 'pending')}
|
|
{@const pendingIn = challenges.filter((c: any) => c.direction === 'incoming' && c.status === 'pending')}
|
|
{@const resolvedC = challenges.filter((c: any) => c.status !== 'pending')}
|
|
|
|
{#if pendingOut.length > 0}
|
|
<p class="proposal-subhead">Sent</p>
|
|
{#each pendingOut as c (c.id)}
|
|
<div class="proposal-card">
|
|
<div class="proposal-meta">
|
|
<span class="proposal-to">To: <a href="/profile/{c.challenged_username}" target="_blank" class="friend-name">{c.challenged_username}</a></span>
|
|
<span class="proposal-status pending">Awaiting response</span>
|
|
<span class="proposal-expires">{formatChallengeExpiry(c.expires_at, tick)}</span>
|
|
</div>
|
|
<p class="proposal-desc">Deck: <strong>{c.deck_name}</strong></p>
|
|
{#if confirmingWithdraw.has(c.id)}
|
|
<span class="confirm-label">Withdraw challenge?</span>
|
|
<button class="confirm-yes-btn" onclick={() => withdrawChallenge(c.id)}>Confirm</button>
|
|
<button class="confirm-no-btn" onclick={() => { confirmingWithdraw.delete(c.id); confirmingWithdraw = confirmingWithdraw; }}>Cancel</button>
|
|
{:else}
|
|
<button class="withdraw-btn" onclick={() => { confirmingWithdraw.add(c.id); confirmingWithdraw = confirmingWithdraw; }}>Withdraw</button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if pendingIn.length > 0}
|
|
<p class="proposal-subhead">Incoming</p>
|
|
{#each pendingIn as c (c.id)}
|
|
<div class="proposal-card">
|
|
<div class="proposal-meta">
|
|
<a href="/profile/{c.challenger_username}" target="_blank" class="friend-name">{c.challenger_username}</a>
|
|
<span class="proposal-status pending">Pending</span>
|
|
<span class="proposal-expires">{formatChallengeExpiry(c.expires_at, tick)}</span>
|
|
</div>
|
|
<p class="proposal-desc">Their deck: <strong>{c.deck_name}</strong></p>
|
|
<p class="proposal-desc">Check your notification bell to accept.</p>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if resolvedC.length > 0}
|
|
<p class="proposal-subhead">History</p>
|
|
{#each resolvedC as c (c.id)}
|
|
<div class="proposal-card resolved">
|
|
<div class="proposal-meta">
|
|
<span class="proposal-to">
|
|
{c.direction === 'outgoing' ? `To: ${c.challenged_username}` : `From: ${c.challenger_username}`}
|
|
</span>
|
|
<span class="proposal-status {c.status}">{c.status}</span>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
{/if}
|
|
|
|
</div>
|
|
{/if}
|
|
</main>
|
|
|
|
<style>
|
|
main {
|
|
height: 100vh;
|
|
overflow-y: auto;
|
|
background: var(--color-bg);
|
|
padding: 2rem;
|
|
}
|
|
|
|
.profile {
|
|
max-width: 700px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
padding-bottom: 5rem;
|
|
}
|
|
|
|
.profile-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.avatar {
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: var(--radius-full);
|
|
background: var(--color-surface-raised);
|
|
border: 2px solid var(--color-bronze);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-2xl);
|
|
font-weight: 700;
|
|
color: var(--color-gold);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.profile-info {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
}
|
|
|
|
.username {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-xl);
|
|
font-weight: 700;
|
|
color: var(--color-gold);
|
|
margin: 0;
|
|
}
|
|
|
|
.email {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-md);
|
|
color: rgba(240, 180, 80, 0.5);
|
|
margin: 0;
|
|
}
|
|
|
|
.unverified-badge {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-xs);
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
color: #c87830;
|
|
border: 1px solid rgba(200, 120, 48, 0.4);
|
|
border-radius: 3px;
|
|
padding: 1px 5px;
|
|
vertical-align: middle;
|
|
margin-left: 6px;
|
|
}
|
|
|
|
.resend-btn {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.5);
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
transition: color 0.15s;
|
|
text-align: left;
|
|
}
|
|
|
|
.resend-btn:hover:not(:disabled) { color: rgba(240, 180, 80, 0.8); }
|
|
.resend-btn:disabled { cursor: default; opacity: 0.6; }
|
|
|
|
.joined {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.35);
|
|
margin: 0;
|
|
}
|
|
|
|
.logout-btn {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--btn-font-md);
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
background: none;
|
|
border: 1px solid rgba(180, 60, 60, 0.4);
|
|
border-radius: var(--radius-sm);
|
|
color: rgba(200, 80, 80, 0.7);
|
|
padding: var(--btn-padding-md);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.logout-btn:hover {
|
|
border-color: var(--color-error);
|
|
color: #e05050;
|
|
background: rgba(180, 40, 40, 0.1);
|
|
}
|
|
|
|
.reset-link {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.4);
|
|
text-decoration: underline;
|
|
align-self: flex-start;
|
|
transition: color 0.15s;
|
|
}
|
|
|
|
.reset-link:hover { color: rgba(240, 180, 80, 0.7); }
|
|
|
|
.shards-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.shards-link {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-xs);
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
color: rgba(126, 207, 207, 0.6);
|
|
border: 1px solid rgba(126, 207, 207, 0.3);
|
|
border-radius: var(--radius-sm);
|
|
padding: 3px 8px;
|
|
text-decoration: none;
|
|
margin-top: 4px;
|
|
margin-left: 0.5rem;
|
|
transition: color 0.15s, border-color 0.15s;
|
|
}
|
|
.shards-link:hover { color: var(--color-cyan); border-color: rgba(126, 207, 207, 0.7); }
|
|
|
|
.shards-icon {
|
|
font-size: var(--text-xl);
|
|
color: var(--color-cyan);
|
|
position: relative;
|
|
top: -0.1em;
|
|
animation: shard-pulse 3s ease-in-out infinite;
|
|
}
|
|
|
|
.shards-value {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-2xl);
|
|
font-weight: 700;
|
|
color: var(--color-cyan);
|
|
}
|
|
|
|
.shards-label {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-sm);
|
|
font-weight: 700;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: rgba(126, 207, 207, 0.5);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.section-divider {
|
|
height: 1px;
|
|
background: var(--color-border-dim);
|
|
}
|
|
|
|
.section-title {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-base);
|
|
font-weight: 700;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--color-gold-faint);
|
|
margin: 0;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 12px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border-dim);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.stat-label {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-xs);
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--color-gold-faint);
|
|
}
|
|
|
|
.stat-value {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-2xl);
|
|
font-weight: 700;
|
|
color: var(--color-gold);
|
|
}
|
|
|
|
.wins { color: var(--color-success); }
|
|
.losses { color: var(--color-error); }
|
|
.good-wr { color: var(--color-success); }
|
|
.bad-wr { color: var(--color-error); }
|
|
|
|
.highlights {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 12px;
|
|
}
|
|
|
|
.highlight-card {
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border-dim);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.highlight-label {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-xs);
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--color-gold-faint);
|
|
}
|
|
|
|
.highlight-value {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-md);
|
|
font-weight: 700;
|
|
color: var(--color-gold);
|
|
}
|
|
|
|
.highlight-sub {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.45);
|
|
}
|
|
|
|
.card-preview {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: flex-start;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.card-thumb {
|
|
width: 48px;
|
|
height: 48px;
|
|
object-fit: cover;
|
|
object-position: top;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid var(--color-border-subtle);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.card-preview-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
}
|
|
|
|
.wishlist-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
}
|
|
|
|
.wishlist-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.wishlist-hint {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.35);
|
|
margin: 0;
|
|
}
|
|
|
|
.wishlist-textarea {
|
|
width: 100%;
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border-subtle);
|
|
border-radius: var(--radius-md);
|
|
color: var(--color-gold);
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-md);
|
|
padding: 0.6rem 0.75rem;
|
|
resize: vertical;
|
|
box-sizing: border-box;
|
|
transition: border-color 0.15s;
|
|
outline: none;
|
|
}
|
|
|
|
.wishlist-textarea:focus { border-color: var(--color-bronze); }
|
|
.wishlist-textarea::placeholder { color: rgba(240, 180, 80, 0.25); }
|
|
|
|
.wishlist-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.save-btn {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--btn-font-md);
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
background: var(--color-surface-raised);
|
|
border: 1px solid var(--color-bronze);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-btn-text);
|
|
padding: var(--btn-padding-md);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.save-btn:hover:not(:disabled) { background: #4d3010; }
|
|
.save-btn:disabled { opacity: 0.5; cursor: default; }
|
|
|
|
.wishlist-ok {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
color: var(--color-success);
|
|
}
|
|
|
|
.wishlist-error {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
color: var(--color-error);
|
|
margin: 0;
|
|
}
|
|
|
|
.no-friends {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.25);
|
|
margin: 0;
|
|
}
|
|
|
|
.friends-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.friend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 0.5rem 0.75rem;
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border-dim);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
|
|
.friend-name {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-base);
|
|
font-weight: 700;
|
|
color: var(--color-gold);
|
|
text-decoration: none;
|
|
flex: 1;
|
|
transition: color 0.15s;
|
|
}
|
|
|
|
.friend-name:hover { color: var(--color-bronze); }
|
|
|
|
.unfriend-btn {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--btn-font-md);
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
background: rgba(180, 40, 40, 0.8);
|
|
border: none;
|
|
border-radius: var(--radius-md);
|
|
color: #fff;
|
|
padding: var(--btn-padding-md);
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.unfriend-btn:hover { background: rgba(210, 50, 50, 0.9); }
|
|
|
|
.no-data {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.25);
|
|
}
|
|
|
|
/* ── Trade Proposals ── */
|
|
.proposal-subhead {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-xs);
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: rgba(240, 180, 80, 0.3);
|
|
margin: 0.5rem 0 0;
|
|
}
|
|
|
|
.proposal-card {
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border-dim);
|
|
border-radius: var(--radius-lg);
|
|
padding: 0.85rem 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.45rem;
|
|
}
|
|
|
|
.proposal-card.resolved { opacity: 0.5; }
|
|
|
|
.proposal-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.proposal-to {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
color: rgba(240, 180, 80, 0.5);
|
|
}
|
|
|
|
.proposal-status {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--text-xs);
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
border-radius: 3px;
|
|
padding: 1px 5px;
|
|
border: 1px solid;
|
|
}
|
|
|
|
.proposal-status.pending { color: var(--color-bronze); border-color: rgba(200, 134, 26, 0.4); }
|
|
.proposal-status.accepted { color: var(--color-success); border-color: rgba(106, 170, 106, 0.4); }
|
|
.proposal-status.declined { color: var(--color-error); border-color: rgba(200, 64, 64, 0.4); }
|
|
.proposal-status.expired { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
|
|
.proposal-status.withdrawn { color: rgba(240, 180, 80, 0.3); border-color: var(--color-border-dim); }
|
|
|
|
.proposal-expires {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-sm);
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.3);
|
|
margin-left: auto;
|
|
}
|
|
|
|
.proposal-desc {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
color: rgba(240, 180, 80, 0.65);
|
|
margin: 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.proposal-desc strong { color: var(--color-gold); font-style: normal; font-weight: 600; }
|
|
|
|
.see-proposal-btn {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--btn-font-md);
|
|
font-weight: 700;
|
|
letter-spacing: 0.07em;
|
|
text-transform: uppercase;
|
|
background: var(--color-surface-raised);
|
|
border: 1px solid var(--color-border-subtle);
|
|
border-radius: var(--radius-md);
|
|
color: var(--color-gold);
|
|
padding: var(--btn-padding-md);
|
|
text-decoration: none;
|
|
display: inline-block;
|
|
transition: all 0.15s;
|
|
margin-top: 0.25rem;
|
|
align-self: flex-start;
|
|
}
|
|
.see-proposal-btn:hover { border-color: var(--color-bronze); background: #4d3010; }
|
|
|
|
.withdraw-btn {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--btn-font-sm);
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
background: none;
|
|
border: 1px solid rgba(180, 60, 60, 0.4);
|
|
border-radius: var(--radius-sm);
|
|
color: rgba(200, 80, 80, 0.6);
|
|
padding: var(--btn-padding-sm);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.withdraw-btn:hover { border-color: var(--color-error); color: #e05050; }
|
|
|
|
.status {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-md);
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.5);
|
|
text-align: center;
|
|
margin-top: 4rem;
|
|
}
|
|
|
|
.confirm-label {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: var(--text-base);
|
|
font-style: italic;
|
|
color: rgba(200, 80, 80, 0.8);
|
|
}
|
|
|
|
.confirm-yes-btn {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--btn-font-sm);
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
background: rgba(180, 40, 40, 0.8);
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
color: #fff;
|
|
padding: var(--btn-padding-sm);
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.confirm-yes-btn:hover { background: rgba(210, 50, 50, 0.9); }
|
|
|
|
.confirm-no-btn {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: var(--btn-font-sm);
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
background: none;
|
|
border: 1px solid var(--color-border-subtle);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-gold-faint);
|
|
padding: var(--btn-padding-sm);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.confirm-no-btn:hover { border-color: rgba(107, 76, 30, 0.7); color: rgba(240, 180, 80, 0.7); }
|
|
|
|
@media (max-width: 640px) {
|
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
|
.highlights { grid-template-columns: 1fr; }
|
|
.profile-header { flex-wrap: wrap; }
|
|
}
|
|
</style> |