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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user