feat: add global & server leveling, quests, achievements, guilds land-claiming, social & toggleable configs

This commit is contained in:
SCOPEDD committed 2026-09-29 03:24:27 -04:00
1 parent 4b79f69c20
commit b4cef85b1f
40 files changed
+10261 -27

No files matched your search

@@ -0,0 +1,824 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
X, Trophy, Shield, Heart, Image as ImageIcon, Send, Edit3, Check,
Clock, Swords, Flame, Pickaxe, Box, LoaderCircle, Award, Star, MessageSquare
} from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import Modal from './Modal.svelte';
import { activeAccount, app, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { UserProfileView, UserPost } from '../lib/types';
let {
uuid,
onclose
}: {
uuid: string;
onclose: () => void;
} = $props();
let profile = $state<UserProfileView | null>(null);
let loading = $state(true);
let activeTab = $state<'feed' | 'progression' | 'achievements' | 'stats'>('feed');
// Edit Profile State
let editing = $state(false);
let editBio = $state('');
let editBanner = $state('');
let savingProfile = $state(false);
// New Post State
let newPostContent = $state('');
let newPostImageUrl = $state('');
let posting = $state(false);
const isMe = $derived(activeAccount()?.uuid === uuid);
async function loadProfile() {
loading = true;
try {
profile = await invoke<UserProfileView>('get_user_profile', { uuid });
if (profile) {
editBio = profile.bio || '';
editBanner = profile.banner_url || '';
}
} catch (e: any) {
toast(e?.message ?? 'Failed to load player profile', 'error');
} finally {
loading = false;
}
}
async function handleSaveProfile() {
savingProfile = true;
try {
profile = await invoke<UserProfileView>('update_my_profile', {
bio: editBio.trim() || null,
bannerUrl: editBanner.trim() || null
});
editing = false;
toast('Profile updated successfully!', 'ok');
} catch (e: any) {
toast(e?.message ?? 'Failed to save profile', 'error');
} finally {
savingProfile = false;
}
}
async function handleCreatePost() {
if (!newPostContent.trim()) return;
posting = true;
try {
const post = await invoke<UserPost>('create_user_post', {
content: newPostContent.trim(),
imageUrl: newPostImageUrl.trim() || null
});
if (profile) {
profile.posts = [post, ...profile.posts];
}
newPostContent = '';
newPostImageUrl = '';
toast('Post published!', 'ok');
} catch (e: any) {
toast(e?.message ?? 'Failed to create post', 'error');
} finally {
posting = false;
}
}
async function handleLikePost(post: UserPost) {
try {
const liked = await invoke<boolean>('like_user_post', { postId: post.id });
post.liked_by_me = liked;
post.likes_count = liked ? post.likes_count + 1 : Math.max(0, post.likes_count - 1);
} catch (e: any) {
toast(e?.message ?? 'Failed to update like', 'error');
}
}
function formatPlaytime(secs: number): string {
if (!secs || secs < 60) return `${secs || 0}s`;
const hours = Math.floor(secs / 3600);
const mins = Math.floor((secs % 3600) / 60);
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
onMount(() => {
loadProfile();
});
</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">
<button class="ghost icon close-btn" onclick={onclose} aria-label="Close Profile">
<X size={18} />
</button>
{#if loading || !profile}
<div class="center-state"><LoaderCircle class="spin" size={32} /></div>
{:else}
<!-- Banner & Profile Header -->
<div
class="profile-banner"
style:background-image={profile.banner_url ? `linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.8)), url(${profile.banner_url})` : undefined}
>
<div class="header-overlay">
<div class="user-main">
<div class="avatar-ring">
<Avatar uuid={profile.uuid} name={profile.username} size={4.2} />
</div>
<div class="user-titles">
<div class="user-row">
<h2>{profile.username}</h2>
{#if profile.global_level?.title}
<span class="badge title-badge"><Award size={12} /> {profile.global_level.title}</span>
{/if}
</div>
<div class="meta-row tiny muted">
<span>Joined {new Date(profile.created_at).toLocaleDateString()}</span>
<span>•</span>
<span>{profile.friends_count} Friends</span>
<span>•</span>
<span class="global-lvl-tag">Level {profile.global_level?.level ?? 1}</span>
</div>
</div>
</div>
{#if isMe}
<button class="ghost sm edit-btn" onclick={() => (editing = !editing)}>
<Edit3 size={14} /> {editing ? 'Cancel' : 'Edit Profile'}
</button>
{/if}
</div>
</div>
<!-- Bio / Edit Form -->
<div class="bio-section">
{#if editing}
<div class="edit-profile-box glass">
<label>
<span class="tiny bold">Bio</span>
<textarea bind:value={editBio} placeholder="Tell other players about yourself..." rows="2"></textarea>
</label>
<label>
<span class="tiny bold">Banner Image URL</span>
<input type="url" bind:value={editBanner} placeholder="https://example.com/banner.jpg" />
</label>
<div class="edit-actions">
<button class="ghost sm" onclick={() => (editing = false)}>Cancel</button>
<button class="primary sm" onclick={handleSaveProfile} disabled={savingProfile}>
{#if savingProfile}<LoaderCircle size={13} class="spin" />{:else}<Check size={13} />{/if}
Save
</button>
</div>
</div>
{:else if profile.bio}
<p class="bio-text">{profile.bio}</p>
{/if}
{#if profile.badges && profile.badges.length > 0}
<div class="badges-list">
{#each profile.badges as badge}
<span class="badge custom-badge"><Star size={11} /> {badge}</span>
{/each}
</div>
{/if}
</div>
<!-- Segmented Profile Tabs -->
<div class="profile-tabs">
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
<MessageSquare size={14} /> Posts & Feed
</button>
<button class:active={activeTab === 'progression'} onclick={() => (activeTab = 'progression')}>
<Award size={14} /> Levels & Ranks
</button>
<button class:active={activeTab === 'achievements'} onclick={() => (activeTab = 'achievements')}>
<Trophy size={14} /> Achievements ({profile.achievements.length})
</button>
<button class:active={activeTab === 'stats'} onclick={() => (activeTab = 'stats')}>
<Swords size={14} /> Stats
</button>
</div>
<!-- Tab Content Area -->
<div class="modal-tab-content">
{#if activeTab === 'feed'}
<div class="feed-container">
<!-- Create Post (if viewing self) -->
{#if isMe}
<div class="create-post-box glass">
<textarea
bind:value={newPostContent}
placeholder="Share a milestone, screenshot, or status update..."
rows="2"
class="post-input"
></textarea>
<div class="post-controls">
<div class="image-input-wrap">
<ImageIcon size={14} class="muted" />
<input
type="url"
placeholder="Photo URL (optional)"
bind:value={newPostImageUrl}
class="img-url-input"
/>
</div>
<button
class="primary sm"
onclick={handleCreatePost}
disabled={posting || !newPostContent.trim()}
>
{#if posting}<LoaderCircle size={13} class="spin" />{:else}<Send size={13} />{/if}
Post
</button>
</div>
</div>
{/if}
<!-- User Posts List -->
{#if profile.posts.length === 0}
<div class="empty-state">
<p class="muted">No posts yet from {profile.username}.</p>
</div>
{:else}
<div class="posts-stream">
{#each profile.posts as post (post.id)}
<div class="post-card glass">
<div class="post-top">
<Avatar uuid={profile.uuid} name={profile.username} size={2} />
<div class="post-author-meta">
<span class="author-name">{profile.username}</span>
<span class="post-time tiny muted">{new Date(post.created_at).toLocaleString()}</span>
</div>
</div>
<p class="post-body">{post.content}</p>
{#if post.image_url}
<div class="post-photo-wrap">
<img src={post.image_url} alt="Post Attachment" class="post-photo" />
</div>
{/if}
<div class="post-footer">
<button
class="ghost sm like-btn"
class:liked={post.liked_by_me}
onclick={() => handleLikePost(post)}
>
<Heart size={14} fill={post.liked_by_me ? 'currentColor' : 'none'} />
<span>{post.likes_count}</span>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'progression'}
<!-- Progression Tab -->
<div class="progression-view">
<!-- Global Level Card -->
<div class="prog-card glass">
<div class="prog-header">
<div>
<span class="tiny bold uppercase muted">Account Progression</span>
<h3>SCOPENET Global Level</h3>
</div>
<div class="level-pill">
Level {profile.global_level.level}
</div>
</div>
<div class="xp-bar-wrap">
<div class="xp-numbers tiny">
<span>{profile.global_level.current_xp.toLocaleString()} XP</span>
<span>{profile.global_level.next_level_xp.toLocaleString()} XP</span>
</div>
<div class="xp-bar">
<div class="xp-fill" style:width="{profile.global_level.progress_pct}%"></div>
</div>
</div>
</div>
<!-- Server Levels List -->
{#if profile.server_levels && profile.server_levels.length > 0}
<h4 class="subhead">Server Specific Levels</h4>
<div class="servers-levels-grid">
{#each profile.server_levels as sl}
<div class="server-lvl-card glass">
<div class="sl-top">
<div>
<strong>{sl.server_name}</strong>
{#if sl.rank_title}
<span class="badge rank-badge">{sl.rank_title}</span>
{/if}
</div>
<span class="lvl-badge">Lv. {sl.level}</span>
</div>
<div class="xp-bar">
<div class="xp-fill" style:width="{sl.progress_pct}%"></div>
</div>
<span class="tiny muted">{sl.current_xp} / {sl.next_level_xp} XP</span>
</div>
{/each}
</div>
{:else}
<p class="muted tiny">No server levels recorded yet.</p>
{/if}
</div>
{:else if activeTab === 'achievements'}
<!-- Achievements Tab -->
<div class="achievements-view">
{#if profile.achievements.length === 0}
<div class="empty-state">
<Trophy size={32} class="muted" />
<p class="muted">No achievements unlocked yet.</p>
</div>
{:else}
<div class="ach-grid">
{#each profile.achievements as ach (ach.id)}
<div class="ach-item glass" class:frame-challenge={ach.frame_type === 'challenge'}>
<div class="ach-icon-box" style:background={ach.icon_bg || 'rgba(0,0,0,0.5)'}>
<span>{ach.icon_item || '🏆'}</span>
</div>
<div class="ach-meta">
<span class="ach-name">{ach.title}</span>
<p class="ach-sub tiny muted">{ach.description}</p>
<span class="tiny xp-label">+{ach.xp_reward} XP</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'stats'}
<!-- Stats Tab -->
<div class="stats-view">
{#if profile.stats}
<div class="stats-cards-grid">
<div class="stat-mini-card glass">
<Clock size={16} class="muted" />
<span class="tiny muted">Playtime</span>
<strong>{formatPlaytime(profile.stats.playtime_secs)}</strong>
</div>
<div class="stat-mini-card glass">
<Swords size={16} class="accent" />
<span class="tiny muted">Player Kills</span>
<strong>{profile.stats.player_kills.toLocaleString()}</strong>
</div>
<div class="stat-mini-card glass">
<Flame size={16} class="warn" />
<span class="tiny muted">Mob Kills</span>
<strong>{profile.stats.mob_kills.toLocaleString()}</strong>
</div>
<div class="stat-mini-card glass">
<Pickaxe size={16} class="success" />
<span class="tiny muted">Blocks Mined</span>
<strong>{profile.stats.blocks_broken.toLocaleString()}</strong>
</div>
<div class="stat-mini-card glass">
<Box size={16} class="muted" />
<span class="tiny muted">Blocks Placed</span>
<strong>{profile.stats.blocks_placed.toLocaleString()}</strong>
</div>
<div class="stat-mini-card glass">
<Award size={16} class="accent" />
<span class="tiny muted">Server Joins</span>
<strong>{profile.stats.joins.toLocaleString()}</strong>
</div>
</div>
{:else}
<div class="empty-state">
<p class="muted">No statistics recorded yet for this player.</p>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
.profile-backdrop {
position: fixed;
inset: 0;
z-index: 85;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--bg) 75%, transparent);
animation: fade 0.15s ease;
}
.profile-modal {
width: 100%;
max-width: 36rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
position: relative;
box-shadow: 0 1rem 3rem -1rem rgba(0, 0, 0, 0.6);
}
.close-btn {
position: absolute;
top: 0.8rem;
right: 0.8rem;
z-index: 10;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
}
.profile-banner {
height: 9rem;
background-color: color-mix(in srgb, var(--accent) 25%, black);
background-size: cover;
background-position: center;
position: relative;
display: flex;
align-items: flex-end;
}
.header-overlay {
width: 100%;
padding: 1rem 1.4rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, transparent 100%);
}
.user-main {
display: flex;
align-items: flex-end;
gap: 1rem;
}
.avatar-ring {
border: 3px solid var(--surface);
border-radius: 28%;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
background: var(--surface);
}
.user-titles {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.user-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.user-row h2 {
font-size: 1.3rem;
font-weight: 750;
margin: 0;
}
.title-badge {
background: color-mix(in srgb, #f59e0b 25%, transparent);
color: #f59e0b;
}
.meta-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.global-lvl-tag {
color: var(--accent);
font-weight: 650;
}
.edit-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: rgba(0, 0, 0, 0.5);
}
.bio-section {
padding: 0.9rem 1.4rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
border-bottom: 1px solid var(--line);
}
.bio-text {
font-size: 0.88rem;
color: color-mix(in srgb, var(--text) 90%, transparent);
line-height: 1.4;
margin: 0;
}
.edit-profile-box {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.9rem;
border-radius: var(--radius-sm);
}
.edit-profile-box textarea, .edit-profile-box input {
background: color-mix(in srgb, var(--bg) 60%, transparent);
border: 1px solid var(--line);
color: var(--text);
padding: 0.4rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.82rem;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.badges-list {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.custom-badge {
background: color-mix(in srgb, var(--text) 8%, transparent);
font-size: 0.72rem;
}
.profile-tabs {
display: flex;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--surface) 90%, black);
padding: 0 1rem;
gap: 0.3rem;
}
.profile-tabs button {
display: flex;
align-items: center;
gap: 0.35rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 0.65rem 0.9rem;
font-size: 0.82rem;
font-weight: 560;
color: var(--muted);
border-radius: 0;
transition: all 0.15s;
}
.profile-tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.modal-tab-content {
padding: 1.2rem 1.4rem;
}
/* Feed Tab */
.feed-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.create-post-box {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.9rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.post-input {
background: color-mix(in srgb, var(--bg) 50%, transparent);
border: 1px solid var(--line);
color: var(--text);
padding: 0.5rem 0.7rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
resize: vertical;
}
.post-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.image-input-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 0.4rem;
background: color-mix(in srgb, var(--bg) 50%, transparent);
border: 1px solid var(--line);
padding: 0.25rem 0.6rem;
border-radius: var(--radius-sm);
}
.img-url-input {
flex: 1;
background: transparent;
border: none;
color: var(--text);
font-size: 0.8rem;
outline: none;
}
.posts-stream {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.post-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.post-top {
display: flex;
align-items: center;
gap: 0.6rem;
}
.author-name {
font-size: 0.85rem;
font-weight: 650;
display: block;
}
.post-body {
font-size: 0.86rem;
line-height: 1.4;
margin: 0;
}
.post-photo-wrap {
border-radius: var(--radius-sm);
overflow: hidden;
max-height: 16rem;
}
.post-photo {
width: 100%;
height: 100%;
object-fit: cover;
}
.post-footer {
display: flex;
justify-content: flex-start;
}
.like-btn {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--muted);
}
.like-btn.liked {
color: #e11d48;
}
/* Progression */
.progression-view {
display: flex;
flex-direction: column;
gap: 1rem;
}
.prog-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1.1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.prog-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.prog-header h3 {
margin: 0.1rem 0 0;
font-size: 1.1rem;
}
.level-pill {
padding: 0.3rem 0.7rem;
border-radius: 99rem;
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
font-weight: 700;
font-size: 0.88rem;
}
.xp-bar-wrap {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.xp-numbers {
display: flex;
justify-content: space-between;
color: var(--muted);
}
.xp-bar {
width: 100%;
height: 0.45rem;
background: color-mix(in srgb, var(--text) 10%, transparent);
border-radius: 99rem;
overflow: hidden;
}
.xp-fill {
height: 100%;
background: var(--accent);
border-radius: 99rem;
}
.subhead {
font-size: 0.88rem;
font-weight: 700;
}
.servers-levels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.8rem;
}
.server-lvl-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.8rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.sl-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.lvl-badge {
font-weight: 700;
color: var(--accent);
font-size: 0.82rem;
}
.rank-badge {
background: color-mix(in srgb, #f59e0b 20%, transparent);
color: #f59e0b;
font-size: 0.65rem;
}
/* Achievements */
.ach-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.8rem;
}
.ach-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.75rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.ach-icon-box {
width: 2.2rem;
height: 2.2rem;
border-radius: 4px;
display: grid;
place-items: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.ach-meta {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.ach-name {
font-size: 0.84rem;
font-weight: 700;
}
.ach-sub {
margin: 0.1rem 0;
line-height: 1.25;
}
.xp-label {
color: var(--accent);
font-weight: 700;
}
/* Stats Grid */
.stats-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 0.8rem;
}
.stat-mini-card {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.8rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.stat-mini-card strong {
font-size: 1.1rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 600;
padding: 0.16rem 0.5rem;
border-radius: 99rem;
}
.center-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 2.5rem 1rem;
text-align: center;
}
</style>