Files
wiki-tcg/frontend/src/routes/profile/+page.svelte
T
2026-04-01 18:31:33 +02:00

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>