Richer player profiles, launcher command guide, integration docs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 18:29:07 +00:00
1 parent 745c2178ee
commit 9dc7f12fb0
13 files changed
+655 -13

No files matched your search

+169 -11
View File
@@ -3,7 +3,8 @@
import { onMount } from 'svelte';
import {
X, Trophy, Shield, Heart, Image as ImageIcon, Send, Edit3, Check,
Clock, Swords, Flame, Pickaxe, Box, LoaderCircle, Award, Star, MessageSquare
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';
@@ -21,12 +22,15 @@
let profile = $state<UserProfileView | null>(null);
let loading = $state(true);
let activeTab = $state<'feed' | 'progression' | 'achievements' | 'stats'>('feed');
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
@@ -36,13 +40,14 @@
const isMe = $derived(activeAccount()?.uuid === uuid);
async function loadProfile() {
loading = true;
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');
@@ -56,7 +61,8 @@
try {
profile = await invoke<UserProfileView>('update_my_profile', {
bio: editBio.trim(),
bannerUrl: editBanner.trim() || null
bannerUrl: editBanner.trim() || null,
accentColor: editAccent
});
editing = false;
toast('Profile updated successfully!', 'ok');
@@ -99,6 +105,49 @@
}
}
/** 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);
@@ -112,7 +161,7 @@
</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">
<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>
@@ -138,12 +187,19 @@
{/if}
</div>
<div class="meta-row tiny muted">
<span>Joined {new Date(profile.created_at).toLocaleDateString()}</span>
<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>
@@ -155,6 +211,24 @@
</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}
@@ -167,6 +241,16 @@
<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}>
@@ -190,6 +274,9 @@
<!-- 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>
@@ -206,7 +293,47 @@
<!-- Tab Content Area -->
<div class="modal-tab-content">
{#if activeTab === 'feed'}
{#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}
@@ -451,7 +578,7 @@
gap: 1rem;
}
.avatar-ring {
border: 3px solid var(--surface);
border: 3px solid var(--pa, var(--surface));
border-radius: 28%;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
@@ -482,7 +609,7 @@
gap: 0.4rem;
}
.global-lvl-tag {
color: var(--accent);
color: var(--pa, var(--accent));
font-weight: 650;
}
.edit-btn {
@@ -556,8 +683,39 @@
}
.profile-tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
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;
}