Player names open profiles across the launcher and admin panel

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-10-01 00:49:13 +00:00
1 parent f428bd210f
commit c37cde5cc8
13 files changed
+82 -24

No files matched your search

+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from './PlayerLink.svelte';
import { onMount } from 'svelte';
import {
Landmark, ArrowDownToLine, ArrowUpFromLine, Wallet, Lock, Server, History, Coins, LoaderCircle, RefreshCw, TrendingUp
@@ -213,7 +214,7 @@
</span>
<Avatar uuid={tx.actor_uuid} name={m?.name ?? '?'} size={1.7} />
<div class="who">
<strong>{m?.name ?? 'Former member'}</strong>
<strong>{#if m}<PlayerLink uuid={tx.actor_uuid} name={m.name} />{:else}Former member{/if}</strong>
<span class="tiny muted" title={tx.note}>{verbs[tx.kind] ?? tx.kind}{tx.note ? ` · ${tx.note}` : ''} · {ago(tx.created_at)}</span>
</div>
<span class="amt" class:out={isOut(tx.kind)}>{isOut(tx.kind) ? '−' : '+'}{money(tx.amount)}</span>
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { X, Send, UserPlus, Sparkles, MapPin, LoaderCircle, Shield, Clock, Swords, Star } from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import PlayerLink from './PlayerLink.svelte';
import { activeAccount, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { Guild, GuildMember, UserProfileView } from '../lib/types';
@@ -84,7 +85,7 @@
<div class="head">
<div class="ring"><Avatar {uuid} name={name} size={4} /></div>
<div class="who">
<h3>{name}</h3>
<h3><PlayerLink {uuid} {name} /></h3>
{#if profile.level_info?.title}<span class="title"><Sparkles size={12} /> {profile.level_info.title}</span>{/if}
<span class="presence"><i class:on={online}></i>{online ? 'Online now' : 'Offline'}</span>
</div>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { app } from '../lib/store.svelte';
// A player's name that opens their profile. Safe to place inside other clickable rows.
let { uuid, name = '', class: cls = '', children }: { uuid?: string | null; name?: string; class?: string; children?: Snippet } = $props();
const open = (e: Event) => { e.stopPropagation(); if (uuid) app.viewProfileUuid = uuid; };
</script>
{#if uuid}
<button type="button" class="plink {cls}" title="View {name || 'player'}'s profile" onclick={open}
onkeydown={(e) => e.stopPropagation()}>{#if children}{@render children()}{:else}{name}{/if}</button>
{:else}
<span class={cls}>{#if children}{@render children()}{:else}{name}{/if}</span>
{/if}
<style>
.plink { all: unset; cursor: pointer; font: inherit; color: inherit; border-radius: 4px; transition: color 0.12s ease; }
.plink:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.plink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import { onMount } from 'svelte';
import {
Shield, Map, Users, MessageSquare, Plus, Crown, UserCheck, Trash2,
@@ -83,7 +84,7 @@
}
}
type Invite = { id: number; guild_id: string; guild_name: string; guild_tag: string; icon_url: string; inviter: string };
type Invite = { id: number; guild_id: string; guild_name: string; guild_tag: string; icon_url: string; inviter: string; inviter_uuid?: string };
let invites = $state<Invite[]>([]);
async function loadInvites() {
try { invites = await invoke<Invite[]>('my_guild_invites'); } catch { invites = []; }
@@ -259,7 +260,7 @@
{#each invites as inv (inv.id)}
<div class="invite">
<span class="dot"><Shield size={15} /></span>
<span class="text"><strong>[{inv.guild_tag}] {inv.guild_name}</strong><span class="muted tiny">Invited by {inv.inviter}</span></span>
<span class="text"><strong>[{inv.guild_tag}] {inv.guild_name}</strong><span class="muted tiny">Invited by <PlayerLink uuid={inv.inviter_uuid} name={inv.inviter} /></span></span>
<button class="sm ghost" onclick={() => answerInvite(inv, false)}>Decline</button>
<button class="sm primary" onclick={() => answerInvite(inv, true)} disabled={!!myGuild} title={myGuild ? 'Leave your guild first' : ''}>Accept</button>
</div>
@@ -450,7 +451,7 @@
<div class="author-meta">
<Avatar uuid={p.author_uuid} name={p.author_name} size={1.8} />
<div class="author-info">
<span class="author-name">{p.author_name}</span>
<span class="author-name"><PlayerLink uuid={p.author_uuid} name={p.author_name} /></span>
<span class="post-date tiny muted">{new Date(p.created_at).toLocaleString()}</span>
</div>
</div>
+7 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import { onMount } from 'svelte';
import {
Users, UserPlus, MessageSquare, Gamepad2, Send, Check, X, Trash2,
@@ -301,7 +302,7 @@
<div class="card-left" role="button" tabindex="0" onclick={() => (app.viewProfileUuid = req.uuid)} onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = req.uuid)}>
<Avatar uuid={req.uuid} name={req.username} size={2.5} />
<div class="name-block">
<span class="user-title">{req.username}</span>
<span class="user-title"><PlayerLink uuid={req.uuid} name={req.username} /></span>
<span class="tiny muted">Wants to be friends</span>
</div>
</div>
@@ -326,7 +327,7 @@
<div class="card-left">
<Avatar uuid={req.uuid} name={req.username} size={2.5} />
<div class="name-block">
<span class="user-title">{req.username}</span>
<span class="user-title"><PlayerLink uuid={req.uuid} name={req.username} /></span>
<span class="tiny muted">Request pending...</span>
</div>
</div>
@@ -366,7 +367,7 @@
<span class="status-dot" class:online={friend.online}></span>
</div>
<div class="name-block">
<span class="user-title">{friend.username}</span>
<span class="user-title"><PlayerLink uuid={friend.uuid} name={friend.username} /></span>
{#if friend.online && friend.playing_on}
<span class="tiny playing-on">Playing on {friend.playing_on}</span>
{:else if friend.online}
@@ -508,7 +509,7 @@
<span class="status-dot" class:online={friend.online}></span>
</div>
<div class="conv-meta">
<span class="conv-name">{friend.username}</span>
<span class="conv-name"><PlayerLink uuid={friend.uuid} name={friend.username} /></span>
{#if friend.playing_on}
<span class="tiny muted">{friend.playing_on}</span>
{/if}
@@ -531,7 +532,7 @@
>
<Avatar uuid={activeChatFriend.uuid} name={activeChatFriend.username} size={2.2} />
<div>
<strong>{activeChatFriend.username}</strong>
<strong><PlayerLink uuid={activeChatFriend.uuid} name={activeChatFriend.username} /></strong>
<span class="tiny muted block">{activeChatFriend.online ? 'Active now' : 'Offline'}</span>
</div>
</div>
@@ -599,7 +600,7 @@
<div class="invite-top">
<Avatar uuid={inv.sender_uuid} name={inv.sender_name} size={2.5} />
<div class="invite-meta">
<span class="inviter-name"><strong>{inv.sender_name}</strong> invited you to play</span>
<span class="inviter-name"><strong><PlayerLink uuid={inv.sender_uuid} name={inv.sender_name} /></strong> invited you to play</span>
<span class="server-title">{inv.server_name}</span>
<span class="tiny muted server-addr">{inv.instance_name}</span>
</div>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import {
Trophy, Swords, Skull, Pickaxe, Clock, Box, MessageSquare, LogIn,
RefreshCw, LoaderCircle, Users, Server, Flame, Sparkles, ChevronDown, Check,
@@ -472,7 +473,7 @@
#{row.rank}
</span>
<Avatar uuid={row.uuid} name={row.username} size={2.2} />
<strong class="baltop-name">{row.username}</strong>
<strong class="baltop-name"><PlayerLink uuid={row.uuid} name={row.username} /></strong>
{#if isMe}<span class="badge you">You</span>{/if}
</div>
<span class="baltop-val highlight">
@@ -610,7 +611,7 @@
</span>
<div class="col-player row align-center gap-sm">
<Avatar name={row.name} uuid={row.uuid} size={1.8} />
<strong class="row-player-name">{row.name}</strong>
<strong class="row-player-name"><PlayerLink uuid={row.uuid} name={row.name} /></strong>
{#if isMe}<span class="badge you">You</span>{/if}
</div>
<strong class="col-main highlight">{statValue(row, sortField)}</strong>
+5 -5
View File
@@ -200,14 +200,14 @@ pub async fn send_invite(auth: AuthUser, Path(guild_id): Path<String>, State(sta
Ok(Json(create_invite(&state, &auth.uuid, &auth.username, &guild_id, &p.uuid).await?))
}
fn invite_json(row: (i64, String, String, String, String, String, String)) -> Value {
let (id, guild_id, name, tag, icon, inviter, at) = row;
json!({ "id": id, "guild_id": guild_id, "guild_name": name, "guild_tag": tag, "icon_url": icon, "inviter": inviter, "created_at": at })
fn invite_json(row: (i64, String, String, String, String, String, String, String)) -> Value {
let (id, guild_id, name, tag, icon, inviter, at, inviter_uuid) = row;
json!({ "id": id, "guild_id": guild_id, "guild_name": name, "guild_tag": tag, "icon_url": icon, "inviter": inviter, "inviter_uuid": inviter_uuid, "created_at": at })
}
async fn pending_for(state: &AppState, uuid: &str) -> AppResult<Vec<Value>> {
let rows: Vec<(i64, String, String, String, String, String, String)> = sqlx::query_as(
"SELECT i.id, i.guild_id, g.name, g.tag, COALESCE(g.icon_url, ''), i.inviter_name, i.created_at
let rows: Vec<(i64, String, String, String, String, String, String, String)> = sqlx::query_as(
"SELECT i.id, i.guild_id, g.name, g.tag, COALESCE(g.icon_url, ''), i.inviter_name, i.created_at, i.inviter_uuid
FROM guild_invites i JOIN guilds g ON g.id = i.guild_id WHERE i.target_uuid = ? AND i.status = 'pending' ORDER BY i.id DESC LIMIT 20",
)
.bind(uuid)
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { go } from '../lib/router.svelte';
// A player's name that opens their profile in the Players page.
let { uuid, name = '', children }: { uuid?: string | null; name?: string; children?: Snippet } = $props();
</script>
{#if uuid}
<a class="plink" href={`#/users/${uuid}`} title="View {name || 'player'}'s profile" onclick={(e) => { e.preventDefault(); go(`users/${uuid}`); }}>{#if children}{@render children()}{:else}{name}{/if}</a>
{:else}
{#if children}{@render children()}{:else}{name}{/if}
{/if}
<style>
.plink { color: inherit; text-decoration: none; border-radius: 4px; }
.plink:hover { color: var(--accent, #8b6cff); text-decoration: underline; text-underline-offset: 3px; }
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import { onMount } from 'svelte';
import { get } from '../lib/api';
type Entry = { id: number; source: string; server: string | null; uuid: string | null; name: string | null; kind: string; detail: string | null; created_at: string };
@@ -32,7 +33,7 @@
<thead><tr><th>Time</th><th>Source</th><th>Player</th><th>Action</th><th>Details</th></tr></thead>
<tbody>
{#each rows as row (row.source + ':' + row.id)}
<tr><td>{new Date(row.created_at).toLocaleString()}</td><td>{row.server ?? row.source}</td><td title={row.uuid ?? ''}>{row.name ?? 'Server'}</td><td>{row.kind.replaceAll('_', ' ')}</td><td>{row.detail ?? '—'}</td></tr>
<tr><td>{new Date(row.created_at).toLocaleString()}</td><td>{row.server ?? row.source}</td><td title={row.uuid ?? ''}>{#if row.uuid && row.name}<PlayerLink uuid={row.uuid} name={row.name} />{:else}{row.name ?? 'Server'}{/if}</td><td>{row.kind.replaceAll('_', ' ')}</td><td>{row.detail ?? '—'}</td></tr>
{:else}<tr><td colspan="5">{busy ? 'Loading…' : 'No matching activity yet.'}</td></tr>{/each}
</tbody>
</table>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import { onMount } from 'svelte';
import { Shield, Users, MapPin, Trash2, Search, ExternalLink, Flag } from '@lucide/svelte';
import Modal from '../components/Modal.svelte';
@@ -159,7 +160,7 @@
<code class="instance-code">{g.instance_id}</code>
</td>
<td>
<code class="uuid-code">{g.leader_uuid.slice(0, 8)}...</code>
<PlayerLink uuid={g.leader_uuid} name={g.leader_uuid.slice(0, 8) + '…'} />
</td>
<td>
<span class="count-pill">
@@ -228,7 +229,7 @@
.guild-desc { margin: 2px 0 0; font-size: 0.78rem; color: var(--muted); }
.tag-badge { font-family: monospace; font-weight: 700; color: #a5b4fc; background: rgba(99,102,241,0.12); padding: 2px 8px; border-radius: 4px; border: 1px solid rgba(99,102,241,0.25); }
.instance-code, .uuid-code { font-size: 0.8rem; color: var(--muted); background: rgba(255,255,255,0.04); padding: 2px 6px; border-radius: 4px; }
.instance-code { font-size: 0.8rem; color: var(--muted); background: rgba(255,255,255,0.04); padding: 2px 6px; border-radius: 4px; }
.count-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; color: var(--text-2); }
.claims-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; color: #4ade80; }
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import { onMount } from 'svelte';
import { Sparkles, Trophy, Plus, Pencil, Trash2, Award, Shield, Crown } from '@lucide/svelte';
import Modal from '../components/Modal.svelte';
@@ -192,7 +193,7 @@
<div class="lb-item">
<span class="lb-rank">#{i + 1}</span>
<div class="lb-info">
<strong>{(p as any).username ?? `UUID: ${p.uuid.slice(0, 8)}…`}</strong>
<strong><PlayerLink uuid={p.uuid} name={(p as any).username ?? `UUID: ${p.uuid.slice(0, 8)}…`} /></strong>
<span>{Number((p as any).global_xp ?? p.current_xp ?? 0).toLocaleString()} XP</span>
</div>
<span class="lb-lvl">Lvl {(p as any).global_level ?? p.level}</span>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import {
ArrowLeft, Map as MapIcon, Settings2, KeyRound, Trash2, Users, Gauge, Clock, UserRound, LogIn, LogOut, Skull, Award, Sword, MessageSquare,
CircleDot, Ban, Server,
@@ -173,7 +174,7 @@
{#each d.leaderboard as row, i (row.uuid)}
<tr>
<td class="rank" class:top={i < 3 && sort !== 'last_seen'}>{i + 1}</td>
<td><span class="row who"><Avatar name={row.name} uuid={row.uuid} size={24} />{row.name}</span></td>
<td><span class="row who"><Avatar name={row.name} uuid={row.uuid} size={24} /><PlayerLink uuid={row.uuid} name={row.name} /></span></td>
{#each columns as c}<td class="num" class:sorted={sort === c.id}>{cell(row, c.id)}</td>{/each}
<td class="muted small nowrap">{d.online_players.some((p) => p.uuid === row.uuid) ? 'Online' : timeAgo(row.last_seen)}</td>
</tr>
@@ -192,7 +193,7 @@
{#if d.online_players.length}
<div class="online">
{#each d.online_players as p (p.uuid)}
<div class="row op"><Avatar name={p.name} uuid={p.uuid} size={26} /><span class="grow">{p.name}</span><span class="muted tiny">{timeAgo(p.joined_at).replace(' ago', '')}</span></div>
<div class="row op"><Avatar name={p.name} uuid={p.uuid} size={26} /><span class="grow"><PlayerLink uuid={p.uuid} name={p.name} /></span><span class="muted tiny">{timeAgo(p.joined_at).replace(' ago', '')}</span></div>
{/each}
</div>
{:else}
+10
View File
@@ -5,6 +5,7 @@
import PlayerPanel from '../components/PlayerPanel.svelte';
import { del, duration, get, patch, post, put, timeAgo } from '../lib/api';
import { session } from '../lib/session.svelte';
import { route, go } from '../lib/router.svelte';
import { toast, toastError } from '../lib/toast.svelte';
import type { Cape, Group, User } from '../lib/types';
@@ -38,6 +39,15 @@
}
const lastSeen = (u: User) => [u.last_login, u.last_seen_ingame].filter(Boolean).sort().at(-1) ?? null;
$effect(refresh);
// #/users/<uuid> (a player's name anywhere in the panel) opens their profile.
$effect(() => {
const target = route.params[0];
if (target && users.length) {
const found = users.find((x) => x.uuid === target);
if (found) openView(found);
}
});
$effect(() => { if (!viewOpen && route.params[0]) go('users'); });
const pending = $derived(users.filter((u) => u.status === 'pending'));
const shown = $derived(users.filter((u) => !filter || u.username.toLowerCase().includes(filter.toLowerCase()) || u.groups.some((g) => g.toLowerCase().includes(filter.toLowerCase()))));