feat: add global & server leveling, quests, achievements, guilds land-claiming, social & toggleable configs
This commit is contained in:
1 parent
4b79f69c20
commit
b4cef85b1f
40 files changed
+10261
-27
No files matched your search
@@ -0,0 +1,492 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Shield, Lock, Plus, Trash2, Compass, ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
|
||||
MapPin, Globe, LoaderCircle, CheckCircle2, AlertTriangle, RefreshCw
|
||||
} from '@lucide/svelte';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { toast } from '../lib/store.svelte';
|
||||
import type { GuildClaim } from '../lib/types';
|
||||
|
||||
let {
|
||||
instanceId,
|
||||
myGuildId = null,
|
||||
myGuildTag = '',
|
||||
onclaim,
|
||||
onunclaim
|
||||
}: {
|
||||
instanceId: string;
|
||||
myGuildId?: string | null;
|
||||
myGuildTag?: string;
|
||||
onclaim?: (claim: GuildClaim) => void;
|
||||
onunclaim?: (claimId: number) => void;
|
||||
} = $props();
|
||||
|
||||
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
|
||||
let centerX = $state(0);
|
||||
let centerZ = $state(0);
|
||||
let radius = $state(5); // 11x11 grid
|
||||
let claims = $state<GuildClaim[]>([]);
|
||||
let loading = $state(false);
|
||||
let claiming = $state(false);
|
||||
let hoveredChunk = $state<{ x: number; z: number } | null>(null);
|
||||
let selectedChunk = $state<{ x: number; z: number } | null>(null);
|
||||
|
||||
const dimensions = [
|
||||
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
|
||||
{ id: 'minecraft:the_nether', label: 'The Nether', icon: Compass },
|
||||
{ id: 'minecraft:the_end', label: 'The End', icon: Compass }
|
||||
] as const;
|
||||
|
||||
async function loadClaims() {
|
||||
if (!instanceId) return;
|
||||
loading = true;
|
||||
try {
|
||||
claims = await invoke<GuildClaim[]>('get_guild_claims', {
|
||||
instanceId,
|
||||
dimension
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast(e?.message ?? 'Failed to load territory claims', 'error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getClaimAt(x: number, z: number): GuildClaim | undefined {
|
||||
return claims.find((c) => c.chunk_x === x && c.chunk_z === z && c.dimension === dimension);
|
||||
}
|
||||
|
||||
const selectedClaim = $derived(
|
||||
selectedChunk ? getClaimAt(selectedChunk.x, selectedChunk.z) : null
|
||||
);
|
||||
|
||||
const myClaimsCount = $derived(
|
||||
myGuildId ? claims.filter((c) => c.guild_id === myGuildId).length : 0
|
||||
);
|
||||
|
||||
async function handleClaim() {
|
||||
if (!selectedChunk || !myGuildId) return;
|
||||
claiming = true;
|
||||
try {
|
||||
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
|
||||
instanceId,
|
||||
dimension,
|
||||
chunkX: selectedChunk.x,
|
||||
chunkZ: selectedChunk.z
|
||||
});
|
||||
claims = [...claims, claim];
|
||||
toast(`Successfully claimed chunk [${selectedChunk.x}, ${selectedChunk.z}]!`, 'ok');
|
||||
onclaim?.(claim);
|
||||
} catch (e: any) {
|
||||
toast(e?.message ?? 'Failed to claim chunk', 'error');
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnclaim() {
|
||||
if (!selectedClaim) return;
|
||||
claiming = true;
|
||||
try {
|
||||
await invoke('unclaim_guild_chunk', { claimId: selectedClaim.id });
|
||||
claims = claims.filter((c) => c.id !== selectedClaim.id);
|
||||
toast(`Unclaimed chunk [${selectedClaim.chunk_x}, ${selectedClaim.chunk_z}]`, 'ok');
|
||||
onunclaim?.(selectedClaim.id);
|
||||
selectedChunk = null;
|
||||
} catch (e: any) {
|
||||
toast(e?.message ?? 'Failed to unclaim chunk', 'error');
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pan(dx: number, dz: number) {
|
||||
centerX += dx;
|
||||
centerZ += dz;
|
||||
}
|
||||
|
||||
function resetCenter() {
|
||||
centerX = 0;
|
||||
centerZ = 0;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (instanceId && dimension) {
|
||||
loadClaims();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadClaims();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="chunk-map-container glass">
|
||||
<!-- Controls Bar -->
|
||||
<div class="map-controls">
|
||||
<div class="dim-selector">
|
||||
{#each dimensions as dim}
|
||||
{@const DimIcon = dim.icon}
|
||||
<button
|
||||
class="dim-btn"
|
||||
class:active={dimension === dim.id}
|
||||
onclick={() => { dimension = dim.id; selectedChunk = null; }}
|
||||
>
|
||||
<DimIcon size={14} />
|
||||
<span>{dim.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="nav-controls">
|
||||
<span class="coord-label">Center: [{centerX}, {centerZ}]</span>
|
||||
<div class="nav-buttons">
|
||||
<button class="ghost icon sm" onclick={() => pan(0, -2)} title="Pan North"><ArrowUp size={14} /></button>
|
||||
<div class="nav-row">
|
||||
<button class="ghost icon sm" onclick={() => pan(-2, 0)} title="Pan West"><ArrowLeft size={14} /></button>
|
||||
<button class="ghost icon sm" onclick={resetCenter} title="Center at (0,0)"><MapPin size={14} /></button>
|
||||
<button class="ghost icon sm" onclick={() => pan(2, 0)} title="Pan East"><ArrowRight size={14} /></button>
|
||||
</div>
|
||||
<button class="ghost icon sm" onclick={() => pan(0, 2)} title="Pan South"><ArrowDown size={14} /></button>
|
||||
</div>
|
||||
<button class="ghost icon sm refresh-btn" onclick={loadClaims} title="Refresh Claims">
|
||||
<RefreshCw size={14} class={loading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Chunk Map Canvas / Grid -->
|
||||
<div class="grid-wrapper">
|
||||
<div
|
||||
class="chunk-grid"
|
||||
style:grid-template-columns="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
|
||||
style:grid-template-rows="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
|
||||
>
|
||||
{#each Array.from({ length: radius * 2 + 1 }, (_, r) => centerZ - radius + r) as cz}
|
||||
{#each Array.from({ length: radius * 2 + 1 }, (_, c) => centerX - radius + c) as cx}
|
||||
{@const claim = getClaimAt(cx, cz)}
|
||||
{@const isMyClaim = claim && myGuildId && claim.guild_id === myGuildId}
|
||||
{@const isOtherClaim = claim && (!myGuildId || claim.guild_id !== myGuildId)}
|
||||
{@const isSelected = selectedChunk && selectedChunk.x === cx && selectedChunk.z === cz}
|
||||
{@const isCenter = cx === 0 && cz === 0}
|
||||
|
||||
<button
|
||||
class="chunk-cell"
|
||||
class:my-claim={isMyClaim}
|
||||
class:other-claim={isOtherClaim}
|
||||
class:selected={isSelected}
|
||||
class:is-center={isCenter}
|
||||
onmouseenter={() => (hoveredChunk = { x: cx, z: cz })}
|
||||
onmouseleave={() => (hoveredChunk = null)}
|
||||
onclick={() => (selectedChunk = { x: cx, z: cz })}
|
||||
title="Chunk [{cx}, {cz}] (Blocks {cx * 16}..{(cx + 1) * 16 - 1}, {cz * 16}..{(cz + 1) * 16 - 1})"
|
||||
>
|
||||
{#if isMyClaim}
|
||||
<Shield size={16} class="claim-icon mine" />
|
||||
<span class="cell-tag">{claim.guild_tag || myGuildTag || 'YOU'}</span>
|
||||
{:else if isOtherClaim}
|
||||
<Lock size={15} class="claim-icon rival" />
|
||||
<span class="cell-tag">{claim.guild_tag || 'GUILD'}</span>
|
||||
{:else if isCenter}
|
||||
<span class="spawn-dot" title="World Origin"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend & Action Bar -->
|
||||
<div class="map-footer">
|
||||
<div class="info-block">
|
||||
{#if selectedChunk}
|
||||
<div class="selected-details">
|
||||
<span class="chunk-title">Selected: Chunk <strong>[{selectedChunk.x}, {selectedChunk.z}]</strong></span>
|
||||
<span class="block-range muted tiny">
|
||||
X: {selectedChunk.x * 16}..{(selectedChunk.x + 1) * 16 - 1}, Z: {selectedChunk.z * 16}..{(selectedChunk.z + 1) * 16 - 1}
|
||||
</span>
|
||||
{#if selectedClaim}
|
||||
<div class="claim-status">
|
||||
{#if myGuildId && selectedClaim.guild_id === myGuildId}
|
||||
<span class="badge mine"><Shield size={12} /> Claimed by Your Guild</span>
|
||||
<button
|
||||
class="danger sm action-btn"
|
||||
onclick={handleUnclaim}
|
||||
disabled={claiming}
|
||||
>
|
||||
<Trash2 size={13} /> Unclaim
|
||||
</button>
|
||||
{:else}
|
||||
<span class="badge rival"><Lock size={12} /> Claimed by {selectedClaim.guild_name || 'Rival Guild'} [{selectedClaim.guild_tag || '?'}]</span>
|
||||
<span class="tiny muted protected-note">Grief Protection Active</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="unclaimed-status">
|
||||
<span class="badge unclaimed">Wilderness (Unclaimed)</span>
|
||||
{#if myGuildId}
|
||||
<button
|
||||
class="primary sm action-btn"
|
||||
onclick={handleClaim}
|
||||
disabled={claiming}
|
||||
>
|
||||
{#if claiming}<LoaderCircle size={13} class="spin" />{:else}<Plus size={13} />{/if}
|
||||
Claim Chunk
|
||||
</button>
|
||||
{:else}
|
||||
<span class="tiny muted">Join or create a guild to claim land</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if hoveredChunk}
|
||||
<div class="hover-info">
|
||||
<span>Chunk: <strong>[{hoveredChunk.x}, {hoveredChunk.z}]</strong></span>
|
||||
<span class="muted tiny">Click to view details or claim</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hint-info muted tiny">
|
||||
<span>Click any grid square to inspect or claim chunks. Claimed chunks are protected in-game against block edits by other players.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="legend-box mine"></span><span>My Guild ({myClaimsCount})</span></div>
|
||||
<div class="legend-item"><span class="legend-box rival"></span><span>Rival Guild</span></div>
|
||||
<div class="legend-item"><span class="legend-box wild"></span><span>Wilderness</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chunk-map-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--surface) 80%, transparent);
|
||||
border: 1px solid var(--line);
|
||||
user-select: none;
|
||||
}
|
||||
.map-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.dim-selector {
|
||||
display: flex;
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||||
padding: 0.2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line);
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.dim-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 560;
|
||||
color: var(--muted);
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.dim-btn.active {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
.nav-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.coord-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.nav-row {
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.refresh-btn {
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
.grid-wrapper {
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
background: color-mix(in srgb, var(--bg) 80%, black);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
|
||||
max-height: 24rem;
|
||||
}
|
||||
.chunk-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.chunk-cell {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--surface) 60%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--line) 40%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.chunk-cell:hover {
|
||||
transform: scale(1.08);
|
||||
z-index: 10;
|
||||
border-color: var(--text);
|
||||
background: color-mix(in srgb, var(--text) 12%, transparent);
|
||||
}
|
||||
.chunk-cell.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
z-index: 15;
|
||||
}
|
||||
.chunk-cell.is-center::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
}
|
||||
.chunk-cell.my-claim {
|
||||
background: color-mix(in srgb, var(--success) 22%, transparent);
|
||||
border-color: color-mix(in srgb, var(--success) 70%, transparent);
|
||||
}
|
||||
.chunk-cell.other-claim {
|
||||
background: color-mix(in srgb, var(--danger) 22%, transparent);
|
||||
border-color: color-mix(in srgb, var(--danger) 70%, transparent);
|
||||
}
|
||||
:global(.claim-icon.mine) {
|
||||
color: var(--success);
|
||||
}
|
||||
:global(.claim-icon.rival) {
|
||||
color: var(--danger);
|
||||
}
|
||||
.cell-tag {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-top: 1px;
|
||||
max-width: 34px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.map-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.info-block {
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
}
|
||||
.selected-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.chunk-title {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.claim-status, .unclaimed-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 99rem;
|
||||
}
|
||||
.badge.mine {
|
||||
background: color-mix(in srgb, var(--success) 18%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
.badge.rival {
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.badge.unclaimed {
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
.protected-note {
|
||||
font-style: italic;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.76rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.legend-box {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.legend-box.mine {
|
||||
background: color-mix(in srgb, var(--success) 35%, transparent);
|
||||
border-color: var(--success);
|
||||
}
|
||||
.legend-box.rival {
|
||||
background: color-mix(in srgb, var(--danger) 35%, transparent);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.legend-box.wild {
|
||||
background: color-mix(in srgb, var(--surface) 80%, transparent);
|
||||
border-color: var(--line);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Settings, Plus, Check, LogOut, Home, Trophy } from '@lucide/svelte';
|
||||
import { Settings, Plus, Check, LogOut, Home, Trophy, Target, Shield, Users } from '@lucide/svelte';
|
||||
import Avatar from './Avatar.svelte';
|
||||
import { abs, activeAccount, app, instances, removeAccount, selectInstance, switchAccount } from '../lib/store.svelte';
|
||||
|
||||
@@ -27,9 +27,18 @@
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
{#if app.view === 'settings' || app.view === 'stats'}
|
||||
{#if app.view !== 'home'}
|
||||
<button class="ghost icon big" onclick={() => (app.view = 'home')} aria-label="Home" title="Home"><Home size={20} /></button>
|
||||
{/if}
|
||||
<button class="ghost icon big" class:on={app.view === 'quests'} onclick={() => (app.view = 'quests')} aria-label="Quests & Objectives" title="Quests & Objectives">
|
||||
<Target size={19} />
|
||||
</button>
|
||||
<button class="ghost icon big" class:on={app.view === 'guilds'} onclick={() => (app.view = 'guilds')} aria-label="Guilds & Claims" title="Guilds & Claims">
|
||||
<Shield size={19} />
|
||||
</button>
|
||||
<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 === 'stats'} onclick={() => (app.view = 'stats')} aria-label="Stats & Leaderboards" title="Stats & Leaderboards">
|
||||
<Trophy size={19} />
|
||||
</button>
|
||||
|
||||
Reference in new issue
Block a user