Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
974 lines
34 KiB
Svelte
974 lines
34 KiB
Svelte
<script lang="ts">
|
||
import AchievementIcon from './AchievementIcon.svelte';
|
||
import { onMount } from 'svelte';
|
||
import {
|
||
X, Trophy, Shield, Heart, Image as ImageIcon, Send, Edit3, Check,
|
||
Clock, Swords, Flame, Pickaxe, Box, LoaderCircle, Award, Star, MessageSquare,
|
||
UserPlus, UserMinus, UserCheck, Wallet, MapPin, Users, Calendar, Palette, LayoutGrid, Skull, LogIn, Sparkles
|
||
} from '@lucide/svelte';
|
||
import Avatar from './Avatar.svelte';
|
||
import Modal from './Modal.svelte';
|
||
import { activeAccount, app, toast } from '../lib/store.svelte';
|
||
import { invoke } from '../lib/tauri';
|
||
import type { UserProfileView, UserPost } from '../lib/types';
|
||
|
||
let {
|
||
uuid,
|
||
onclose
|
||
}: {
|
||
uuid: string;
|
||
onclose: () => void;
|
||
} = $props();
|
||
|
||
let profile = $state<UserProfileView | null>(null);
|
||
let loading = $state(true);
|
||
let activeTab = $state<'overview' | 'feed' | 'progression' | 'achievements' | 'stats'>('overview');
|
||
|
||
// Edit Profile State
|
||
let editing = $state(false);
|
||
let editBio = $state('');
|
||
let editBanner = $state('');
|
||
let editAccent = $state('');
|
||
let relationBusy = $state(false);
|
||
const swatches = ['#8b6cff', '#22d3ee', '#34d399', '#fbbf24', '#fb7185', '#f472b6', '#60a5fa'];
|
||
let savingProfile = $state(false);
|
||
|
||
// New Post State
|
||
let newPostContent = $state('');
|
||
let newPostImageUrl = $state('');
|
||
let posting = $state(false);
|
||
|
||
const isMe = $derived(activeAccount()?.uuid === uuid);
|
||
|
||
async function loadProfile(showSpinner = true) {
|
||
loading = showSpinner;
|
||
try {
|
||
profile = await invoke<UserProfileView>('get_user_profile', { uuid });
|
||
if (profile) {
|
||
editBio = profile.bio || '';
|
||
editBanner = profile.banner_url || '';
|
||
editAccent = profile.accent_color || '';
|
||
}
|
||
} catch (e: any) {
|
||
toast(e?.message ?? 'Failed to load player profile', 'error');
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
async function handleSaveProfile() {
|
||
savingProfile = true;
|
||
try {
|
||
profile = await invoke<UserProfileView>('update_my_profile', {
|
||
bio: editBio.trim(),
|
||
bannerUrl: editBanner.trim() || null,
|
||
accentColor: editAccent
|
||
});
|
||
editing = false;
|
||
toast('Profile updated successfully!', 'ok');
|
||
} catch (e: any) {
|
||
toast(e?.message ?? 'Failed to save profile', 'error');
|
||
} finally {
|
||
savingProfile = false;
|
||
}
|
||
}
|
||
|
||
async function handleCreatePost() {
|
||
if (!newPostContent.trim()) return;
|
||
posting = true;
|
||
try {
|
||
const post = await invoke<UserPost>('create_user_post', {
|
||
content: newPostContent.trim(),
|
||
imageUrl: newPostImageUrl.trim() || null
|
||
});
|
||
if (profile) {
|
||
profile.posts = [post, ...profile.posts];
|
||
}
|
||
newPostContent = '';
|
||
newPostImageUrl = '';
|
||
toast('Post published!', 'ok');
|
||
} catch (e: any) {
|
||
toast(e?.message ?? 'Failed to create post', 'error');
|
||
} finally {
|
||
posting = false;
|
||
}
|
||
}
|
||
|
||
async function handleLikePost(post: UserPost) {
|
||
if (post.liked_by_me) return;
|
||
try {
|
||
const liked = await invoke<boolean>('like_user_post', { postId: post.id });
|
||
if (liked) post.likes_count += 1;
|
||
post.liked_by_me = true;
|
||
} catch (e: any) {
|
||
toast(e?.message ?? 'Failed to update like', 'error');
|
||
}
|
||
}
|
||
|
||
/** Friend actions right from the profile. */
|
||
async function relate(action: 'add' | 'accept' | 'decline' | 'remove') {
|
||
if (!profile) return;
|
||
relationBusy = true;
|
||
try {
|
||
if (action === 'add') await invoke('send_friend_request', { username: profile.username, friendUsername: profile.username });
|
||
else if (action === 'accept' || action === 'decline') await invoke('respond_friend_request', { targetUuid: profile.uuid, friendUuid: profile.uuid, accept: action === 'accept' });
|
||
else await invoke('remove_friend', { targetUuid: profile.uuid, friendUuid: profile.uuid });
|
||
toast(
|
||
action === 'add' ? 'Friend request sent' : action === 'accept' ? `You and ${profile.username} are friends` : action === 'decline' ? 'Request declined' : `${profile.username} removed from friends`,
|
||
'ok'
|
||
);
|
||
await loadProfile(false);
|
||
} catch (e: any) {
|
||
toast(e?.message ?? String(e), 'error');
|
||
} finally {
|
||
relationBusy = false;
|
||
}
|
||
}
|
||
|
||
function message() {
|
||
if (!profile) return;
|
||
app.chatWith = profile.uuid;
|
||
app.view = 'social';
|
||
onclose();
|
||
}
|
||
|
||
const kd = $derived.by(() => {
|
||
const k = Number(profile?.stats?.player_kills ?? 0);
|
||
const d = Number(profile?.stats?.deaths ?? 0);
|
||
return d === 0 ? (k ? `${k}.0` : '–') : (k / d).toFixed(2);
|
||
});
|
||
const money = (n: number) => `$${Math.round(n).toLocaleString()}`;
|
||
const activityIcon: Record<string, any> = { join: LogIn, death: Skull, kill: Swords, advancement: Award, achievement: Trophy };
|
||
const ago = (iso?: string | null) => {
|
||
if (!iso) return 'never';
|
||
const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||
if (secs < 90) return 'just now';
|
||
if (secs < 5400) return `${Math.round(secs / 60)}m ago`;
|
||
if (secs < 129600) return `${Math.round(secs / 3600)}h ago`;
|
||
return `${Math.round(secs / 86400)}d ago`;
|
||
};
|
||
|
||
function formatPlaytime(secs: number): string {
|
||
if (!secs || secs < 60) return `${secs || 0}s`;
|
||
const hours = Math.floor(secs / 3600);
|
||
const mins = Math.floor((secs % 3600) / 60);
|
||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||
}
|
||
|
||
onMount(() => {
|
||
loadProfile();
|
||
});
|
||
</script>
|
||
|
||
<div class="profile-backdrop" role="presentation" onclick={(e) => e.target === e.currentTarget && onclose()}>
|
||
<div class="profile-modal glass" role="dialog" aria-modal="true" aria-label="Player Profile" style:--pa={profile?.accent_color || 'var(--accent)'}>
|
||
<button class="ghost icon close-btn" onclick={onclose} aria-label="Close Profile">
|
||
<X size={18} />
|
||
</button>
|
||
|
||
{#if loading || !profile}
|
||
<div class="center-state"><LoaderCircle class="spin" size={32} /></div>
|
||
{:else}
|
||
<!-- Banner & Profile Header -->
|
||
<div
|
||
class="profile-banner"
|
||
style:background-image={profile.banner_url ? `linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.8)), url(${profile.banner_url})` : undefined}
|
||
>
|
||
<div class="header-overlay">
|
||
<div class="user-main">
|
||
<div class="avatar-ring">
|
||
<Avatar uuid={profile.uuid} name={profile.username} size={4.2} />
|
||
</div>
|
||
<div class="user-titles">
|
||
<div class="user-row">
|
||
<h2>{profile.username}</h2>
|
||
{#if profile.level_info?.title}
|
||
<span class="badge title-badge"><Award size={12} /> {profile.level_info.title}</span>
|
||
{/if}
|
||
</div>
|
||
<div class="meta-row tiny muted">
|
||
<span class="presence" class:on={profile.online}></span>
|
||
<span>{profile.online ? 'Online now' : `Seen ${ago(profile.last_seen)}`}</span>
|
||
<span>•</span>
|
||
<span>{profile.friends_count} Friends</span>
|
||
<span>•</span>
|
||
<span class="global-lvl-tag">Level {profile.level_info?.level ?? 1}</span>
|
||
</div>
|
||
{#if profile.guild || profile.rank}
|
||
<div class="chips-row">
|
||
{#if profile.guild}<button class="chip guild" onclick={() => { app.view = 'guilds'; onclose(); }} title="{profile.guild.role} of {profile.guild.name}"><Shield size={11} /> [{profile.guild.tag}] {profile.guild.name}</button>{/if}
|
||
{#if profile.rank}<span class="chip rank" title="Reported by {profile.rank.server_name}"><Sparkles size={11} /> {profile.rank.display}</span>{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
{#if isMe}
|
||
<button class="ghost sm edit-btn" onclick={() => (editing = !editing)}>
|
||
<Edit3 size={14} /> {editing ? 'Cancel' : 'Edit Profile'}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
{#if !isMe && profile.relationship}
|
||
<div class="relation-bar">
|
||
{#if profile.relationship === 'accepted'}
|
||
<button class="sm primary" onclick={message}><MessageSquare size={14} /> Message</button>
|
||
<button class="sm ghost" disabled={relationBusy} onclick={() => relate('remove')}><UserMinus size={14} /> Remove friend</button>
|
||
{:else if profile.relationship === 'pending_incoming'}
|
||
<span class="tiny muted">{profile.username} wants to be friends</span>
|
||
<button class="sm primary" disabled={relationBusy} onclick={() => relate('accept')}><UserCheck size={14} /> Accept</button>
|
||
<button class="sm ghost" disabled={relationBusy} onclick={() => relate('decline')}>Decline</button>
|
||
{:else if profile.relationship === 'pending_outgoing'}
|
||
<span class="tiny muted">Friend request sent</span>
|
||
<button class="sm ghost" disabled={relationBusy} onclick={() => relate('remove')}>Cancel request</button>
|
||
{:else}
|
||
<button class="sm primary" disabled={relationBusy} onclick={() => relate('add')}><UserPlus size={14} /> Add friend</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Bio / Edit Form -->
|
||
<div class="bio-section">
|
||
{#if editing}
|
||
<div class="edit-profile-box glass">
|
||
<label>
|
||
<span class="tiny bold">Bio</span>
|
||
<textarea bind:value={editBio} placeholder="Tell other players about yourself..." rows="2"></textarea>
|
||
</label>
|
||
<label>
|
||
<span class="tiny bold">Banner Image URL</span>
|
||
<input type="url" bind:value={editBanner} placeholder="https://example.com/banner.jpg" />
|
||
</label>
|
||
<div class="accent-edit">
|
||
<span class="tiny bold"><Palette size={12} /> Accent colour</span>
|
||
<div class="swatches">
|
||
{#each swatches as c}
|
||
<button type="button" class="swatch" class:on={editAccent === c} style:background={c} aria-label="Use {c}" onclick={() => (editAccent = c)}></button>
|
||
{/each}
|
||
<input type="color" value={editAccent || '#8b6cff'} oninput={(e) => (editAccent = e.currentTarget.value)} aria-label="Custom colour" />
|
||
<button type="button" class="ghost sm" onclick={() => (editAccent = '')}>Default</button>
|
||
</div>
|
||
</div>
|
||
<div class="edit-actions">
|
||
<button class="ghost sm" onclick={() => (editing = false)}>Cancel</button>
|
||
<button class="primary sm" onclick={handleSaveProfile} disabled={savingProfile}>
|
||
{#if savingProfile}<LoaderCircle size={13} class="spin" />{:else}<Check size={13} />{/if}
|
||
Save
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{:else if profile.bio}
|
||
<p class="bio-text">{profile.bio}</p>
|
||
{/if}
|
||
|
||
{#if profile.badges && profile.badges.length > 0}
|
||
<div class="badges-list">
|
||
{#each profile.badges as badge}
|
||
<span class="badge custom-badge"><Star size={11} /> {badge}</span>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Segmented Profile Tabs -->
|
||
<div class="profile-tabs">
|
||
<button class:active={activeTab === 'overview'} onclick={() => (activeTab = 'overview')}>
|
||
<LayoutGrid size={14} /> Overview
|
||
</button>
|
||
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
|
||
<MessageSquare size={14} /> Posts & Feed
|
||
</button>
|
||
<button class:active={activeTab === 'progression'} onclick={() => (activeTab = 'progression')}>
|
||
<Award size={14} /> Levels & Ranks
|
||
</button>
|
||
<button class:active={activeTab === 'achievements'} onclick={() => (activeTab = 'achievements')}>
|
||
<Trophy size={14} /> Achievements ({profile.achievements_count ?? profile.achievements?.length ?? 0})
|
||
</button>
|
||
<button class:active={activeTab === 'stats'} onclick={() => (activeTab = 'stats')}>
|
||
<Swords size={14} /> Stats
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Tab Content Area -->
|
||
<div class="modal-tab-content">
|
||
{#if activeTab === 'overview'}
|
||
<div class="overview">
|
||
<div class="facts">
|
||
<div class="fact glass"><Star size={15} class="accent" /><span class="tiny muted">Level</span><strong>{profile.level_info?.level ?? 1}</strong></div>
|
||
<div class="fact glass"><Clock size={15} class="muted" /><span class="tiny muted">Playtime</span><strong>{formatPlaytime(profile.stats?.playtime_secs ?? 0)}</strong></div>
|
||
<div class="fact glass"><Swords size={15} class="accent" /><span class="tiny muted">K/D</span><strong>{kd}</strong></div>
|
||
<div class="fact glass"><Wallet size={15} class="success" /><span class="tiny muted">Wealth</span><strong>{money(profile.wealth ?? 0)}</strong></div>
|
||
<div class="fact glass"><MapPin size={15} class="warn" /><span class="tiny muted">Favourite server</span><strong class="small-val">{profile.favorite_server ?? '–'}</strong></div>
|
||
<div class="fact glass"><Calendar size={15} class="muted" /><span class="tiny muted">Joined</span><strong class="small-val">{new Date(profile.created_at).toLocaleDateString()}</strong></div>
|
||
</div>
|
||
|
||
{#if profile.featured_achievement}
|
||
<div class="featured glass"><Trophy size={15} class="warn" /><span class="tiny muted">Featured</span><strong>{profile.featured_achievement.title}</strong></div>
|
||
{/if}
|
||
|
||
{#if profile.mutual_friends && profile.mutual_friends.length}
|
||
<div>
|
||
<h4 class="subhead"><Users size={14} /> {profile.mutual_friends.length} mutual friend{profile.mutual_friends.length === 1 ? '' : 's'}</h4>
|
||
<div class="mutuals">
|
||
{#each profile.mutual_friends as m (m.uuid)}
|
||
<button class="mutual" onclick={() => (app.viewProfileUuid = m.uuid)}><Avatar uuid={m.uuid} name={m.username} size={1.6} /> {m.username}</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div>
|
||
<h4 class="subhead">Recent activity</h4>
|
||
{#if profile.recent_activity && profile.recent_activity.length}
|
||
<div class="activity">
|
||
{#each profile.recent_activity as a}
|
||
{@const Icon = activityIcon[a.kind] ?? Sparkles}
|
||
<div class="act"><span class="act-ic {a.kind}"><Icon size={12} /></span><span>{a.text}</span><span class="tiny muted">{ago(a.at)}</span></div>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<p class="muted tiny">Nothing yet. Activity shows up as they play.</p>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{:else if activeTab === 'feed'}
|
||
<div class="feed-container">
|
||
<!-- Create Post (if viewing self) -->
|
||
{#if isMe}
|
||
<div class="create-post-box glass">
|
||
<textarea
|
||
bind:value={newPostContent}
|
||
placeholder="Share a milestone, screenshot, or status update..."
|
||
rows="2"
|
||
class="post-input"
|
||
></textarea>
|
||
<div class="post-controls">
|
||
<div class="image-input-wrap">
|
||
<ImageIcon size={14} class="muted" />
|
||
<input
|
||
type="url"
|
||
placeholder="Photo URL (optional)"
|
||
bind:value={newPostImageUrl}
|
||
class="img-url-input"
|
||
/>
|
||
</div>
|
||
<button
|
||
class="primary sm"
|
||
onclick={handleCreatePost}
|
||
disabled={posting || !newPostContent.trim()}
|
||
>
|
||
{#if posting}<LoaderCircle size={13} class="spin" />{:else}<Send size={13} />{/if}
|
||
Post
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- User Posts List -->
|
||
{#if profile.posts.length === 0}
|
||
<div class="empty-state">
|
||
<p class="muted">No posts yet from {profile.username}.</p>
|
||
</div>
|
||
{:else}
|
||
<div class="posts-stream">
|
||
{#each profile.posts as post (post.id)}
|
||
<div class="post-card glass">
|
||
<div class="post-top">
|
||
<Avatar uuid={profile.uuid} name={profile.username} size={2} />
|
||
<div class="post-author-meta">
|
||
<span class="author-name">{profile.username}</span>
|
||
<span class="post-time tiny muted">{new Date(post.created_at).toLocaleString()}</span>
|
||
</div>
|
||
</div>
|
||
<p class="post-body">{post.content}</p>
|
||
{#if post.image_url}
|
||
<div class="post-photo-wrap">
|
||
<img src={post.image_url} alt="Post Attachment" class="post-photo" />
|
||
</div>
|
||
{/if}
|
||
<div class="post-footer">
|
||
<button
|
||
class="ghost sm like-btn"
|
||
class:liked={post.liked_by_me}
|
||
onclick={() => handleLikePost(post)}
|
||
>
|
||
<Heart size={14} fill={post.liked_by_me ? 'currentColor' : 'none'} />
|
||
<span>{post.likes_count}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else if activeTab === 'progression'}
|
||
<!-- Progression Tab -->
|
||
<div class="progression-view">
|
||
<!-- Global Level Card -->
|
||
<div class="prog-card glass">
|
||
<div class="prog-header">
|
||
<div>
|
||
<span class="tiny bold uppercase muted">Account Progression</span>
|
||
<h3>SCOPENET Global Level</h3>
|
||
</div>
|
||
<div class="level-pill">
|
||
Level {profile.level_info.level}
|
||
</div>
|
||
</div>
|
||
<div class="xp-bar-wrap">
|
||
<div class="xp-numbers tiny">
|
||
<span>{profile.level_info.current_xp.toLocaleString()} XP</span>
|
||
<span>{profile.level_info.next_level_xp.toLocaleString()} XP</span>
|
||
</div>
|
||
<div class="xp-bar">
|
||
<div class="xp-fill" style:width="{profile.level_info.progress_pct}%"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Server Levels List -->
|
||
{#if profile.server_levels && profile.server_levels.length > 0}
|
||
<h4 class="subhead">Server Specific Levels</h4>
|
||
<div class="servers-levels-grid">
|
||
{#each profile.server_levels as sl}
|
||
<div class="server-lvl-card glass">
|
||
<div class="sl-top">
|
||
<div>
|
||
<strong>{sl.server_name}</strong>
|
||
{#if sl.rank_title}
|
||
<span class="badge rank-badge">{sl.rank_title}</span>
|
||
{/if}
|
||
</div>
|
||
<span class="lvl-badge">Lv. {sl.level}</span>
|
||
</div>
|
||
<div class="xp-bar">
|
||
<div class="xp-fill" style:width="{sl.progress_pct}%"></div>
|
||
</div>
|
||
<span class="tiny muted">{sl.current_xp} / {sl.next_level_xp} XP</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<p class="muted tiny">No server levels recorded yet.</p>
|
||
{/if}
|
||
</div>
|
||
{:else if activeTab === 'achievements'}
|
||
<!-- Achievements Tab -->
|
||
<div class="achievements-view">
|
||
{#if profile.achievements.length === 0}
|
||
<div class="empty-state">
|
||
<Trophy size={32} class="muted" />
|
||
<p class="muted">No achievements unlocked yet.</p>
|
||
</div>
|
||
{:else}
|
||
<div class="ach-grid">
|
||
{#each profile.achievements as ach (ach.id)}
|
||
<div class="ach-item glass" class:frame-challenge={ach.frame_type === 'challenge'}>
|
||
<AchievementIcon {ach} size={2.2} />
|
||
<div class="ach-meta">
|
||
<span class="ach-name">{ach.title}</span>
|
||
<p class="ach-sub tiny muted">{ach.description}</p>
|
||
<span class="tiny xp-label">+{ach.xp_reward} XP</span>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else if activeTab === 'stats'}
|
||
<!-- Stats Tab -->
|
||
<div class="stats-view">
|
||
{#if profile.stats}
|
||
<div class="stats-cards-grid">
|
||
<div class="stat-mini-card glass">
|
||
<Clock size={16} class="muted" />
|
||
<span class="tiny muted">Playtime</span>
|
||
<strong>{formatPlaytime(profile.stats.playtime_secs)}</strong>
|
||
</div>
|
||
<div class="stat-mini-card glass">
|
||
<Swords size={16} class="accent" />
|
||
<span class="tiny muted">Player Kills</span>
|
||
<strong>{profile.stats.player_kills.toLocaleString()}</strong>
|
||
</div>
|
||
<div class="stat-mini-card glass">
|
||
<Flame size={16} class="warn" />
|
||
<span class="tiny muted">Mob Kills</span>
|
||
<strong>{profile.stats.mob_kills.toLocaleString()}</strong>
|
||
</div>
|
||
<div class="stat-mini-card glass">
|
||
<Pickaxe size={16} class="success" />
|
||
<span class="tiny muted">Blocks Mined</span>
|
||
<strong>{profile.stats.blocks_broken.toLocaleString()}</strong>
|
||
</div>
|
||
<div class="stat-mini-card glass">
|
||
<Box size={16} class="muted" />
|
||
<span class="tiny muted">Blocks Placed</span>
|
||
<strong>{profile.stats.blocks_placed.toLocaleString()}</strong>
|
||
</div>
|
||
<div class="stat-mini-card glass">
|
||
<Award size={16} class="accent" />
|
||
<span class="tiny muted">Server Joins</span>
|
||
<strong>{profile.stats.joins.toLocaleString()}</strong>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<div class="empty-state">
|
||
<p class="muted">No statistics recorded yet for this player.</p>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.profile-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 85;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 1.5rem;
|
||
background: color-mix(in srgb, var(--bg) 75%, transparent);
|
||
animation: fade 0.15s ease;
|
||
}
|
||
.profile-modal {
|
||
width: 100%;
|
||
max-width: 36rem;
|
||
max-height: calc(100vh - 4rem);
|
||
overflow-y: auto;
|
||
background: var(--surface);
|
||
border: 1px solid var(--line-strong);
|
||
border-radius: var(--radius);
|
||
position: relative;
|
||
box-shadow: 0 1rem 3rem -1rem rgba(0, 0, 0, 0.6);
|
||
}
|
||
.close-btn {
|
||
position: absolute;
|
||
top: 0.8rem;
|
||
right: 0.8rem;
|
||
z-index: 10;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
border-radius: 50%;
|
||
}
|
||
.profile-banner {
|
||
height: 9rem;
|
||
background-color: color-mix(in srgb, var(--accent) 25%, black);
|
||
background-size: cover;
|
||
background-position: center;
|
||
position: relative;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
}
|
||
.header-overlay {
|
||
width: 100%;
|
||
padding: 1rem 1.4rem;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-end;
|
||
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, transparent 100%);
|
||
}
|
||
.user-main {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 1rem;
|
||
}
|
||
.avatar-ring {
|
||
border: 3px solid var(--pa, var(--surface));
|
||
border-radius: 28%;
|
||
overflow: hidden;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||
background: var(--surface);
|
||
}
|
||
.user-titles {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
}
|
||
.user-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
}
|
||
.user-row h2 {
|
||
font-size: 1.3rem;
|
||
font-weight: 750;
|
||
margin: 0;
|
||
}
|
||
.title-badge {
|
||
background: color-mix(in srgb, #f59e0b 25%, transparent);
|
||
color: #f59e0b;
|
||
}
|
||
.meta-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
.global-lvl-tag {
|
||
color: var(--pa, var(--accent));
|
||
font-weight: 650;
|
||
}
|
||
.edit-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
}
|
||
.bio-section {
|
||
padding: 0.9rem 1.4rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.bio-text {
|
||
font-size: 0.88rem;
|
||
color: color-mix(in srgb, var(--text) 90%, transparent);
|
||
line-height: 1.4;
|
||
margin: 0;
|
||
}
|
||
.edit-profile-box {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
padding: 0.9rem;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.edit-profile-box textarea, .edit-profile-box input {
|
||
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||
border: 1px solid var(--line);
|
||
color: var(--text);
|
||
padding: 0.4rem 0.6rem;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.82rem;
|
||
}
|
||
.edit-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.4rem;
|
||
}
|
||
.badges-list {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.custom-badge {
|
||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||
font-size: 0.72rem;
|
||
}
|
||
.profile-tabs {
|
||
display: flex;
|
||
border-bottom: 1px solid var(--line);
|
||
background: color-mix(in srgb, var(--surface) 90%, black);
|
||
padding: 0 1rem;
|
||
gap: 0.3rem;
|
||
}
|
||
.profile-tabs button {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
background: transparent;
|
||
border: none;
|
||
border-bottom: 2px solid transparent;
|
||
padding: 0.65rem 0.9rem;
|
||
font-size: 0.82rem;
|
||
font-weight: 560;
|
||
color: var(--muted);
|
||
border-radius: 0;
|
||
transition: all 0.15s;
|
||
}
|
||
.profile-tabs button.active {
|
||
color: var(--text);
|
||
border-bottom-color: var(--pa, var(--accent));
|
||
}
|
||
.profile-tabs { overflow-x: auto; }
|
||
.profile-tabs button { white-space: nowrap; }
|
||
.presence { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 60%, transparent); }
|
||
.presence.on { background: #34d399; box-shadow: 0 0 0 3px color-mix(in srgb, #34d399 25%, transparent); }
|
||
.chips-row { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.25rem; }
|
||
.chip { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.7rem; font-weight: 650; padding: 0.15rem 0.55rem; border-radius: 99rem; background: rgba(0, 0, 0, 0.45); border: 1px solid color-mix(in srgb, var(--text) 18%, transparent); color: var(--text); }
|
||
.chip.guild { cursor: pointer; }
|
||
.chip.guild:hover { border-color: var(--pa, var(--accent)); }
|
||
.chip.rank { color: #fbbf24; }
|
||
.relation-bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.7rem 1.4rem 0; flex-wrap: wrap; }
|
||
.relation-bar .sm { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||
.accent-edit { display: flex; flex-direction: column; gap: 0.4rem; }
|
||
.swatches { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
||
.swatch { width: 1.4rem; height: 1.4rem; padding: 0; border-radius: 50%; border: 2px solid transparent; }
|
||
.swatch.on { border-color: var(--text); }
|
||
.swatches input[type='color'] { width: 1.8rem; height: 1.6rem; padding: 0; border: none; background: none; }
|
||
.overview { display: flex; flex-direction: column; gap: 1rem; }
|
||
.facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); gap: 0.7rem; }
|
||
.fact { display: flex; flex-direction: column; gap: 0.2rem; padding: 0.75rem; border-radius: var(--radius-sm); border: 1px solid var(--line); }
|
||
.fact strong { font-size: 1.1rem; }
|
||
.fact strong.small-val { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.featured { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.8rem; border-radius: var(--radius-sm); border: 1px solid var(--line); }
|
||
.mutuals { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.4rem; }
|
||
.mutual { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.2rem 0.6rem 0.2rem 0.25rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 7%, transparent); font-size: 0.78rem; }
|
||
.activity { display: flex; flex-direction: column; gap: 0.45rem; margin-top: 0.4rem; }
|
||
.act { display: grid; grid-template-columns: 1.6rem 1fr auto; gap: 0.6rem; align-items: center; font-size: 0.82rem; }
|
||
.act-ic { width: 1.6rem; height: 1.6rem; display: grid; place-items: center; border-radius: 0.45rem; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--muted); }
|
||
.act-ic.achievement, .act-ic.advancement { color: #fbbf24; }
|
||
.act-ic.death { color: #fb7185; }
|
||
.act-ic.join { color: #34d399; }
|
||
.subhead { display: flex; align-items: center; gap: 0.4rem; }
|
||
.modal-tab-content {
|
||
padding: 1.2rem 1.4rem;
|
||
}
|
||
/* Feed Tab */
|
||
.feed-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
}
|
||
.create-post-box {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
padding: 0.9rem;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--line);
|
||
}
|
||
.post-input {
|
||
background: color-mix(in srgb, var(--bg) 50%, transparent);
|
||
border: 1px solid var(--line);
|
||
color: var(--text);
|
||
padding: 0.5rem 0.7rem;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.85rem;
|
||
resize: vertical;
|
||
}
|
||
.post-controls {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
}
|
||
.image-input-wrap {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
background: color-mix(in srgb, var(--bg) 50%, transparent);
|
||
border: 1px solid var(--line);
|
||
padding: 0.25rem 0.6rem;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.img-url-input {
|
||
flex: 1;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text);
|
||
font-size: 0.8rem;
|
||
outline: none;
|
||
}
|
||
.posts-stream {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.9rem;
|
||
}
|
||
.post-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
padding: 1rem;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--line);
|
||
}
|
||
.post-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
}
|
||
.author-name {
|
||
font-size: 0.85rem;
|
||
font-weight: 650;
|
||
display: block;
|
||
}
|
||
.post-body {
|
||
font-size: 0.86rem;
|
||
line-height: 1.4;
|
||
margin: 0;
|
||
}
|
||
.post-photo-wrap {
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
max-height: 16rem;
|
||
}
|
||
.post-photo {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.post-footer {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
}
|
||
.like-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
}
|
||
.like-btn.liked {
|
||
color: #e11d48;
|
||
}
|
||
/* Progression */
|
||
.progression-view {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
}
|
||
.prog-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
padding: 1.1rem;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--line);
|
||
}
|
||
.prog-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.prog-header h3 {
|
||
margin: 0.1rem 0 0;
|
||
font-size: 1.1rem;
|
||
}
|
||
.level-pill {
|
||
padding: 0.3rem 0.7rem;
|
||
border-radius: 99rem;
|
||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||
color: var(--accent);
|
||
font-weight: 700;
|
||
font-size: 0.88rem;
|
||
}
|
||
.xp-bar-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
.xp-numbers {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
color: var(--muted);
|
||
}
|
||
.xp-bar {
|
||
width: 100%;
|
||
height: 0.45rem;
|
||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||
border-radius: 99rem;
|
||
overflow: hidden;
|
||
}
|
||
.xp-fill {
|
||
height: 100%;
|
||
background: var(--accent);
|
||
border-radius: 99rem;
|
||
}
|
||
.subhead {
|
||
font-size: 0.88rem;
|
||
font-weight: 700;
|
||
}
|
||
.servers-levels-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||
gap: 0.8rem;
|
||
}
|
||
.server-lvl-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
padding: 0.8rem;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--line);
|
||
}
|
||
.sl-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
}
|
||
.lvl-badge {
|
||
font-weight: 700;
|
||
color: var(--accent);
|
||
font-size: 0.82rem;
|
||
}
|
||
.rank-badge {
|
||
background: color-mix(in srgb, #f59e0b 20%, transparent);
|
||
color: #f59e0b;
|
||
font-size: 0.65rem;
|
||
}
|
||
/* Achievements */
|
||
.ach-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||
gap: 0.8rem;
|
||
}
|
||
.ach-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.7rem;
|
||
padding: 0.75rem;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--line);
|
||
}
|
||
.ach-meta {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
.ach-name {
|
||
font-size: 0.84rem;
|
||
font-weight: 700;
|
||
}
|
||
.ach-sub {
|
||
margin: 0.1rem 0;
|
||
line-height: 1.25;
|
||
}
|
||
.xp-label {
|
||
color: var(--accent);
|
||
font-weight: 700;
|
||
}
|
||
/* Stats Grid */
|
||
.stats-cards-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
|
||
gap: 0.8rem;
|
||
}
|
||
.stat-mini-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
padding: 0.8rem;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--line);
|
||
}
|
||
.stat-mini-card strong {
|
||
font-size: 1.1rem;
|
||
}
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
font-size: 0.72rem;
|
||
font-weight: 600;
|
||
padding: 0.16rem 0.5rem;
|
||
border-radius: 99rem;
|
||
}
|
||
.center-state, .empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.8rem;
|
||
padding: 2.5rem 1rem;
|
||
text-align: center;
|
||
}
|
||
</style>
|