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:
13 files changed
+655
-13
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user