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

+2
View File
@@ -1129,6 +1129,7 @@ pub async fn update_my_profile(
banner_url: Option<String>,
custom_badge: Option<String>,
featured_achievement_id: Option<String>,
accent_color: Option<String>,
) -> Res<UserProfileView> {
let req = account_api(&state, reqwest::Method::PUT, "/profiles/me").await?;
let resp = req
@@ -1137,6 +1138,7 @@ pub async fn update_my_profile(
"banner_url": banner_url,
"custom_badge": custom_badge,
"featured_achievement_id": featured_achievement_id,
"accent_color": accent_color,
}))
.send()
.await
+3
View File
@@ -16,6 +16,7 @@
import Stats from './pages/Stats.svelte';
import Quests from './pages/Quests.svelte';
import Guilds from './pages/Guilds.svelte';
import Commands from './pages/Commands.svelte';
import Social from './pages/Social.svelte';
import PlayerProfileModal from './components/PlayerProfileModal.svelte';
import { app, init } from './lib/store.svelte';
@@ -55,6 +56,8 @@
<Guilds />
{:else if app.view === 'social'}
<Social />
{:else if app.view === 'commands'}
<Commands />
{:else}
<Home />
{/if}
+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;
}
+4 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Settings, Plus, Check, LogOut, Home, Trophy, Target, Shield, Users } from '@lucide/svelte';
import { Settings, Plus, Check, LogOut, Home, Trophy, Target, Shield, Users, Terminal } from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import { abs, activeAccount, app, instances, removeAccount, selectInstance, switchAccount } from '../lib/store.svelte';
@@ -39,6 +39,9 @@
<button class="ghost icon big" class:on={app.view === 'social'} onclick={() => (app.view = 'social')} aria-label="Friends & Social" title="Friends & Social">
<Users size={19} />
</button>
<button class="ghost icon big" class:on={app.view === 'commands'} onclick={() => (app.view = 'commands')} aria-label="Command guide" title="Command guide">
<Terminal size={19} />
</button>
<button class="ghost icon big" class:on={app.view === 'stats'} onclick={() => (app.view = 'stats')} aria-label="Stats & Leaderboards" title="Stats & Leaderboards">
<Trophy size={19} />
</button>
+3 -1
View File
@@ -27,9 +27,11 @@ export const app = $state({
active: null as string | null,
panelUrl: null as string | null,
offline: false,
view: 'home' as 'home' | 'settings' | 'stats' | 'quests' | 'guilds' | 'social',
view: 'home' as 'home' | 'settings' | 'stats' | 'quests' | 'guilds' | 'social' | 'commands',
settingsTab: 'account',
viewProfileUuid: null as string | null,
/** Open this friend's conversation when the Social page loads. */
chatWith: null as string | null,
selected: null as string | null,
launch: idle(),
launchingId: null as string | null,
+13
View File
@@ -324,6 +324,19 @@ export interface UserProfileView {
friends_count: number;
stats: PlayerStats | null;
created_at: string;
online?: boolean;
last_seen?: string | null;
guild?: { id: string; name: string; tag: string; role: string } | null;
rank?: { display: string; prefix: string; server_name: string } | null;
favorite_server?: string | null;
wealth?: number;
recent_activity?: { kind: string; text: string; at: string }[];
mutual_friends?: { uuid: string; username: string }[];
/** self | none | accepted | pending_outgoing | pending_incoming */
relationship?: string;
accent_color?: string | null;
discord_linked?: boolean;
featured_achievement?: Achievement | null;
}
export interface MemberProfile {
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import { Terminal, Search, Copy, Check } from '@lucide/svelte';
type Cmd = { usage: string; does: string; perm: string; aliases?: string[] };
type Group = { title: string; blurb: string; cmds: Cmd[] };
const groups: Group[] = [
{ title: 'Getting around', blurb: 'Teleports, homes and warps.', cmds: [
{ usage: '/spawn', does: 'Teleport to the server spawn.', perm: 'scopenet.command.spawn', aliases: ['hub', 'lobby'] },
{ usage: '/home [name]', does: 'Teleport to a home, or open the homes menu.', perm: 'scopenet.command.home', aliases: ['homes'] },
{ usage: '/sethome [name]', does: 'Save your current spot as a home.', perm: 'scopenet.command.sethome' },
{ usage: '/delhome <name>', does: 'Delete one of your homes.', perm: 'scopenet.command.delhome', aliases: ['rmhome'] },
{ usage: '/back', does: 'Return to where you were, or where you died.', perm: 'scopenet.command.back', aliases: ['return'] },
{ usage: '/warp [name]', does: 'Go to a server warp, or open the warps menu.', perm: 'scopenet.command.warp', aliases: ['warps'] },
{ usage: '/rtp', does: 'Jump to a random safe spot in the wild.', perm: 'scopenet.command.rtp', aliases: ['wild', 'randomtp'] },
{ usage: '/tpa <player>', does: 'Ask to teleport to another player.', perm: 'scopenet.command.tpa' },
{ usage: '/tpaccept', does: 'Accept a teleport request.', perm: 'scopenet.command.tpaccept', aliases: ['tpyes'] },
{ usage: '/tpdeny', does: 'Decline a teleport request.', perm: 'scopenet.command.tpdeny', aliases: ['tpno'] },
] },
{ title: 'Money & trading', blurb: 'Your balance, the shop, the market and player trades.', cmds: [
{ usage: '/balance', does: 'Check your balance.', perm: 'scopenet.command.balance', aliases: ['bal', 'money'] },
{ usage: '/pay <player> <amount>', does: 'Send money to a player.', perm: 'scopenet.command.pay' },
{ usage: '/baltop', does: 'See the richest players.', perm: 'scopenet.command.baltop', aliases: ['richest'] },
{ usage: '/shop', does: 'Open the server shop.', perm: 'scopenet.command.shop' },
{ usage: '/sell hand', does: 'Sell the item in your hand to the shop.', perm: 'scopenet.command.sell.hand' },
{ usage: '/sell', does: 'Open the selling menu.', perm: 'scopenet.command.sell' },
{ usage: '/market', does: 'Browse and buy from the player market.', perm: 'scopenet.command.market', aliases: ['ah', 'auction'] },
{ usage: '/market sell <price>', does: 'List the item in your hand on the market.', perm: 'scopenet.command.market.sell' },
{ usage: '/orders', does: 'View your active market orders.', perm: 'scopenet.command.orders' },
{ usage: '/trade <player>', does: 'Start a secure trade with another player.', perm: 'scopenet.command.trade' },
{ usage: '/transactions', does: 'See your recent money movements.', perm: 'scopenet.command.transactions' },
] },
{ title: 'Guilds & land', blurb: 'Team up, claim land and protect it.', cmds: [
{ usage: '/guild', does: 'Open your guild dashboard.', perm: 'scopenet.command.guild', aliases: ['g', 'clan'] },
{ usage: '/guild create <name> <tag>', does: 'Found a new guild.', perm: 'scopenet.command.guild.create' },
{ usage: '/guild leave', does: 'Leave your guild.', perm: 'scopenet.command.guild.leave' },
{ usage: '/guild members', does: 'List your guild members.', perm: 'scopenet.command.guild.members' },
{ usage: '/guild chat <message>', does: 'Talk to your guild only.', perm: 'scopenet.command.guild.chat', aliases: ['/guild c'] },
{ usage: '/guild sethome · /guild home', does: 'Set or visit the guild home.', perm: 'scopenet.command.guild.sethome' },
{ usage: '/guild map', does: 'Show nearby claims on a map.', perm: 'scopenet.command.guild.map' },
{ usage: '/claim', does: 'Claim the chunk you stand in for your guild.', perm: 'scopenet.command.claim' },
{ usage: '/unclaim', does: 'Give up the chunk you stand in.', perm: 'scopenet.command.unclaim' },
] },
{ title: 'Guild bank', blurb: 'Shared guild money. Officers and leaders can spend it.', cmds: [
{ usage: '/guild bank', does: 'Show the bank balance and recent activity.', perm: 'scopenet.command.guild.bank' },
{ usage: '/guild bank deposit <amount>', does: 'Put your own money into the bank.', perm: 'scopenet.command.guild.bank.deposit' },
{ usage: '/guild bank withdraw <amount>', does: 'Take money out (officers and leaders).', perm: 'scopenet.command.guild.bank.withdraw' },
{ usage: '/guild sell hand', does: 'Sell your held item and pay the bank.', perm: 'scopenet.command.guild.sell' },
{ usage: '/guild market', does: 'Buy and sell on the market as your guild.', perm: 'scopenet.command.guild.market' },
{ usage: '/guild pay <tag> <amount>', does: 'Pay another guild from your bank.', perm: 'scopenet.command.guild.pay' },
] },
{ title: 'Info & admin', blurb: 'Status and help.', cmds: [
{ usage: '/playtime', does: 'Check your total and session playtime.', perm: 'scopenet.command.playtime', aliases: ['ontime'] },
{ usage: '/scopenet help', does: 'List SCOPENET commands.', perm: 'scopenet.command.scopenet.help', aliases: ['sn'] },
{ usage: '/scopenet status', does: 'Panel connection, features and plugin integrations (admins).', perm: 'scopenet.command.scopenet.status' },
{ usage: '/scopenet reload', does: 'Reload the plugin config (admins).', perm: 'scopenet.command.scopenet.reload' },
] },
];
const tips = [
'Placeholders: use %scopenet_level%, %scopenet_guild%, %scopenet_balance% and more in scoreboards, tab and chat.',
'Some commands may be switched off or limited by the server. If one says you lack permission, ask an admin.',
'Everything here also works through your rank: servers using LuckPerms can grant each permission node to any group.',
];
let query = $state('');
let copied = $state('');
const q = $derived(query.trim().toLowerCase());
const shown = $derived(
groups
.map((g) => ({ ...g, cmds: g.cmds.filter((c) => !q || `${c.usage} ${c.does} ${c.perm} ${(c.aliases ?? []).join(' ')}`.toLowerCase().includes(q)) }))
.filter((g) => g.cmds.length)
);
async function copy(c: Cmd) {
const text = c.usage.split(' · ')[0];
try { await navigator.clipboard.writeText(text); } catch { /* clipboard may be unavailable */ }
copied = c.usage;
setTimeout(() => (copied = ''), 1200);
}
</script>
<div class="page">
<div class="head">
<div>
<h1><Terminal size={26} class="ic" /> Command guide</h1>
<p class="lead">Everything you can type in-game on SCOPENET servers. Click a command to copy it.</p>
</div>
<label class="search"><Search size={15} /><input bind:value={query} placeholder="Search commands" aria-label="Search commands" /></label>
</div>
{#each shown as g (g.title)}
<section class="group">
<div class="gh"><h2>{g.title}</h2><span class="muted tiny">{g.blurb}</span></div>
<div class="list">
{#each g.cmds as c (c.usage)}
<button class="cmd glass" onclick={() => copy(c)} title="Copy command">
<code class="usage">{c.usage}</code>
<span class="does">{c.does}</span>
<span class="meta">
{#each c.aliases ?? [] as a}<span class="alias">/{a.replace(/^\//, '')}</span>{/each}
<span class="perm" title="LuckPerms permission node">{c.perm}</span>
</span>
<span class="copy">{#if copied === c.usage}<Check size={14} />{:else}<Copy size={14} />{/if}</span>
</button>
{/each}
</div>
</section>
{:else}
<p class="muted">No commands match “{query}”.</p>
{/each}
<section class="tips glass">
<h2>Good to know</h2>
<ul>{#each tips as t}<li>{t}</li>{/each}</ul>
</section>
</div>
<style>
.page { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 1.6rem 2.2rem 2.5rem; gap: 1.2rem; }
.head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 1rem; }
h1 { font-size: 1.5rem; font-weight: 700; display: flex; align-items: center; gap: 0.6rem; margin: 0; }
:global(.ic) { color: var(--accent); }
.lead { color: var(--muted); font-size: 0.88rem; margin: 0.2rem 0 0; }
.search { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; border-radius: var(--radius-sm); border: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 80%, transparent); min-width: 14rem; }
.search input { background: transparent; border: none; outline: none; color: var(--text); flex: 1; font-size: 0.85rem; }
.group { display: flex; flex-direction: column; gap: 0.6rem; }
.gh { display: flex; align-items: baseline; gap: 0.7rem; flex-wrap: wrap; }
.gh h2 { font-size: 1.05rem; margin: 0; }
.list { display: grid; grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr)); gap: 0.6rem; }
.cmd { position: relative; display: flex; flex-direction: column; gap: 0.3rem; text-align: left; padding: 0.75rem 2.2rem 0.75rem 0.9rem; border-radius: var(--radius-sm); border: 1px solid var(--line); transition: border-color 0.15s, transform 0.15s; }
.cmd:hover { border-color: var(--accent); transform: translateY(-1px); }
.usage { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88rem; font-weight: 650; color: var(--accent); }
.does { font-size: 0.83rem; color: var(--text); line-height: 1.35; }
.meta { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.alias { font-size: 0.68rem; padding: 0.05rem 0.45rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--muted); }
.perm { font-family: ui-monospace, monospace; font-size: 0.66rem; color: var(--muted); opacity: 0.85; }
.copy { position: absolute; top: 0.7rem; right: 0.75rem; color: var(--muted); }
.tips { padding: 1rem 1.2rem; border-radius: var(--radius-sm); border: 1px solid var(--line); }
.tips h2 { font-size: 0.95rem; margin: 0 0 0.4rem; }
.tips ul { margin: 0; padding-left: 1.1rem; display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.83rem; color: var(--muted); }
</style>
+5
View File
@@ -47,6 +47,11 @@
]);
friends = fList;
invites = iList;
if (app.chatWith) {
const target = fList.find((f) => f.uuid === app.chatWith);
app.chatWith = null;
if (target) void selectChatFriend(target);
}
} catch (e: any) {
toast(e?.message ?? 'Failed to load social data', 'error');
} finally {