feat: complete in-game commands, GUIs, admin panel fixes, and launcher enhancements

This commit is contained in:
scoped committed 2026-09-29 12:14:16 -04:00
1 parent a9ff92e1d1
commit 752f0cc43d
34 files changed
+4170 -181

No files matched your search

+238 -7
View File
@@ -2,15 +2,16 @@
import { onMount } from 'svelte';
import {
Users, UserPlus, MessageSquare, Gamepad2, Send, Check, X, Trash2,
Clock, ExternalLink, LoaderCircle, RefreshCw, Circle, UserX, Sparkles
Clock, ExternalLink, LoaderCircle, RefreshCw, Circle, UserX, Sparkles,
Search, Trophy, Shield
} from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import Modal from '../components/Modal.svelte';
import { activeAccount, app, instances, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { FriendInfo, DirectMessage, GameInvite } from '../lib/types';
import type { FriendInfo, DirectMessage, GameInvite, MemberProfile } from '../lib/types';
let activeSection = $state<'friends' | 'messages' | 'invites'>('friends');
let activeSection = $state<'friends' | 'members' | 'messages' | 'invites'>('friends');
let friendsFilter = $state<'all' | 'online' | 'pending'>('all');
let loading = $state(false);
@@ -25,6 +26,12 @@
let addFriendUsername = $state('');
let sendingFriendReq = $state(false);
// Members Directory / Search
let memberSearchQuery = $state('');
let membersList = $state<MemberProfile[]>([]);
let loadingMembers = $state(false);
let addingFriendUuid = $state<string | null>(null);
// Send Game Invite Modal
let inviteModalOpen = $state(false);
let inviteTargetFriend = $state<FriendInfo | null>(null);
@@ -47,14 +54,43 @@
}
}
async function searchMembers() {
loadingMembers = true;
try {
membersList = await invoke<MemberProfile[]>('search_members', { query: memberSearchQuery.trim() });
} catch (e: any) {
toast(e?.message ?? 'Failed to search members', 'error');
} finally {
loadingMembers = false;
}
}
async function handleAddMemberAsFriend(username: string, uuid: string) {
addingFriendUuid = uuid;
try {
await invoke('send_friend_request', { username, friendUsername: username });
toast(`Friend request sent to ${username}!`, 'ok');
await searchMembers();
await loadData();
} catch (e: any) {
toast(e?.message ?? 'Failed to send friend request', 'error');
} finally {
addingFriendUuid = null;
}
}
async function handleSendFriendRequest() {
if (!addFriendUsername.trim()) return;
sendingFriendReq = true;
const target = addFriendUsername.trim();
try {
await invoke('send_friend_request', { friendUsername: addFriendUsername.trim() });
toast(`Friend request sent to ${addFriendUsername.trim()}!`, 'ok');
await invoke('send_friend_request', { username: target, friendUsername: target });
toast(`Friend request sent to ${target}!`, 'ok');
addFriendUsername = '';
await loadData();
if (activeSection === 'members') {
await searchMembers();
}
} catch (e: any) {
toast(e?.message ?? 'Failed to send friend request', 'error');
} finally {
@@ -64,7 +100,7 @@
async function handleRespondRequest(friendUuid: string, accept: boolean) {
try {
await invoke('respond_friend_request', { friendUuid, accept });
await invoke('respond_friend_request', { targetUuid: friendUuid, friendUuid, accept });
toast(accept ? 'Friend request accepted!' : 'Request declined.', 'ok');
await loadData();
} catch (e: any) {
@@ -74,7 +110,7 @@
async function handleRemoveFriend(friendUuid: string) {
try {
await invoke('remove_friend', { friendUuid });
await invoke('remove_friend', { targetUuid: friendUuid, friendUuid });
friends = friends.filter((f) => f.uuid !== friendUuid);
if (activeChatFriend?.uuid === friendUuid) {
activeChatFriend = null;
@@ -191,6 +227,9 @@
<button class:active={activeSection === 'friends'} onclick={() => (activeSection = 'friends')}>
<Users size={15} /> Friends ({acceptedFriends.length})
</button>
<button class:active={activeSection === 'members'} onclick={() => { activeSection = 'members'; if (!membersList.length) searchMembers(); }}>
<Sparkles size={15} /> Find Players
</button>
<button class:active={activeSection === 'messages'} onclick={() => (activeSection = 'messages')}>
<MessageSquare size={15} /> Direct Messages
</button>
@@ -346,6 +385,100 @@
</div>
{/if}
</div>
{:else if activeSection === 'members'}
<!-- Members Directory Section -->
<div class="members-layout">
<div class="members-search-bar glass">
<Search size={18} class="muted" />
<input
type="text"
placeholder="Search all SCOPENET network players by username..."
bind:value={memberSearchQuery}
onkeydown={(e) => e.key === 'Enter' && searchMembers()}
class="members-search-input"
/>
<button
class="primary sm"
onclick={searchMembers}
disabled={loadingMembers}
>
{#if loadingMembers}<LoaderCircle size={14} class="spin" />{:else}<Search size={14} /> Search{/if}
</button>
</div>
{#if loadingMembers && !membersList.length}
<div class="center-state"><LoaderCircle class="spin" size={32} /></div>
{:else if membersList.length === 0}
<div class="empty-state glass">
<Users size={36} class="muted" />
<p class="muted">No players found matching "{memberSearchQuery}". Try another username!</p>
</div>
{:else}
<div class="members-grid">
{#each membersList as member (member.uuid)}
{@const isMe = member.uuid === activeAccount()?.uuid}
<div class="member-card glass" class:is-me={isMe}>
<div class="member-card-top">
<div class="avatar-wrap" role="button" tabindex="0" onclick={() => (app.viewProfileUuid = member.uuid)} onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = member.uuid)}>
<Avatar uuid={member.uuid} name={member.username} size={2.8} />
<span class="status-dot" class:online={member.online}></span>
</div>
<div class="member-meta">
<div class="name-row">
<strong class="member-name" onclick={() => (app.viewProfileUuid = member.uuid)}>{member.username}</strong>
{#if isMe}
<span class="badge me-tag">You</span>
{/if}
</div>
<div class="member-sub-info">
<span class="badge level-pill">Lvl {member.global_level}</span>
{#if member.title}
<span class="badge title-pill">{member.title}</span>
{/if}
</div>
<span class="online-status tiny muted">
{member.online ? 'Online on Network' : member.last_seen ? `Active ${new Date(member.last_seen).toLocaleDateString()}` : 'Offline'}
</span>
</div>
</div>
<div class="member-card-actions">
{#if isMe}
<button class="ghost sm w-full" onclick={() => (app.viewProfileUuid = member.uuid)}>
View My Profile
</button>
{:else if member.is_friend}
<button class="secondary sm" onclick={() => {
const friend = friends.find(f => f.uuid === member.uuid) || { uuid: member.uuid, username: member.username, status: 'accepted', is_online: member.online, playing_on: null };
selectChatFriend(friend as FriendInfo);
}}>
<MessageSquare size={13} /> Message
</button>
<button class="ghost sm icon" onclick={() => (app.viewProfileUuid = member.uuid)} title="View Profile">
<ExternalLink size={13} />
</button>
{:else}
<button
class="primary sm"
onclick={() => handleAddMemberAsFriend(member.username, member.uuid)}
disabled={addingFriendUuid === member.uuid}
>
{#if addingFriendUuid === member.uuid}
<LoaderCircle size={13} class="spin" />
{:else}
<UserPlus size={13} /> Add Friend
{/if}
</button>
<button class="ghost sm icon" onclick={() => (app.viewProfileUuid = member.uuid)} title="View Profile">
<ExternalLink size={13} />
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeSection === 'messages'}
<!-- Direct Messages Split View -->
<div class="messages-split glass">
@@ -910,4 +1043,102 @@
text-align: center;
border-radius: var(--radius);
}
.members-layout {
display: flex;
flex-direction: column;
gap: 1rem;
}
.members-search-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
border-radius: var(--radius);
}
.members-search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-size: 0.95rem;
}
.members-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.member-card {
padding: 1.1rem;
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 0.9rem;
transition: transform 0.15s, border-color 0.15s;
}
.member-card:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
}
.member-card-top {
display: flex;
align-items: center;
gap: 0.85rem;
}
.avatar-wrap {
position: relative;
cursor: pointer;
}
.member-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.name-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.member-name {
font-size: 1rem;
font-weight: 700;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-name:hover {
color: var(--accent);
}
.member-sub-info {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.level-pill {
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
font-weight: 700;
}
.title-pill {
background: color-mix(in srgb, #f59e0b 20%, transparent);
color: #f59e0b;
}
.me-tag {
background: color-mix(in srgb, var(--line) 40%, transparent);
color: var(--muted);
}
.member-card-actions {
display: flex;
align-items: center;
gap: 0.45rem;
border-top: 1px solid color-mix(in srgb, var(--line) 40%, transparent);
padding-top: 0.75rem;
}
.w-full {
width: 100%;
}
</style>