Add saved skin and cape profiles wardrobe feature

This commit is contained in:
SCOPEDD committed 2026-09-29 02:15:38 -04:00
1 parent c7aa563dac
commit ebd9de1bee
5 files changed
+375 -2

No files matched your search

+104 -1
View File
@@ -6,7 +6,7 @@ use crate::settings::Settings;
use crate::state::{build, AppState};
use crate::updater;
use scopenet_core::ping::ServerStatus;
use scopenet_shared::{LauncherManifest, PlayerProfile};
use scopenet_shared::{LauncherManifest, PlayerProfile, SkinProfile};
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use tauri_plugin_opener::OpenerExt;
@@ -220,6 +220,109 @@ pub async fn set_cape(state: State<'_, AppState>, cape_id: Option<i64>) -> Res<P
profile_response(req.json(&serde_json::json!({ "cape_id": cape_id })).send().await.map_err(err)?).await
}
fn load_skin_profiles(data_dir: &std::path::Path) -> Vec<SkinProfile> {
let path = data_dir.join("skin_profiles.json");
std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn write_skin_profiles(data_dir: &std::path::Path, profiles: &[SkinProfile]) -> anyhow::Result<()> {
let path = data_dir.join("skin_profiles.json");
let json = serde_json::to_vec_pretty(profiles)?;
std::fs::write(path, json)?;
Ok(())
}
#[tauri::command]
pub fn get_skin_profiles(state: State<'_, AppState>) -> Res<Vec<SkinProfile>> {
Ok(load_skin_profiles(&state.data_dir))
}
#[tauri::command]
pub async fn save_skin_profile(state: State<'_, AppState>, mut profile: SkinProfile) -> Res<Vec<SkinProfile>> {
use base64::Engine;
if let Some(ref mut data) = profile.skin_data {
if data.starts_with('/') {
if let Some(panel) = state.panel_url() {
*data = format!("{panel}{data}");
}
}
if data.starts_with("http://") || data.starts_with("https://") {
if let Ok(resp) = state.http.get(&*data).send().await {
if resp.status().is_success() {
if let Ok(bytes) = resp.bytes().await {
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
*data = format!("data:image/png;base64,{b64}");
}
}
}
} else if !data.starts_with("data:") && !data.is_empty() {
*data = format!("data:image/png;base64,{data}");
}
}
if profile.created_at.is_empty() {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_default();
profile.created_at = ts;
}
let mut profiles = load_skin_profiles(&state.data_dir);
if let Some(idx) = profiles.iter().position(|x| x.id == profile.id) {
profiles[idx] = profile;
} else {
profiles.push(profile);
}
write_skin_profiles(&state.data_dir, &profiles).map_err(aerr)?;
Ok(profiles)
}
#[tauri::command]
pub fn delete_skin_profile(state: State<'_, AppState>, id: String) -> Res<Vec<SkinProfile>> {
let mut profiles = load_skin_profiles(&state.data_dir);
profiles.retain(|x| x.id != id);
write_skin_profiles(&state.data_dir, &profiles).map_err(aerr)?;
Ok(profiles)
}
#[tauri::command]
pub async fn apply_skin_profile(state: State<'_, AppState>, id: String) -> Res<PlayerProfile> {
use base64::Engine;
let profiles = load_skin_profiles(&state.data_dir);
let p = profiles.into_iter().find(|x| x.id == id).ok_or_else(|| "profile not found".to_string())?;
if let Some(skin_data) = p.skin_data {
if !skin_data.is_empty() {
let bytes = if skin_data.starts_with("data:") {
let comma = skin_data.find(',').ok_or_else(|| "invalid data uri".to_string())?;
base64::engine::general_purpose::STANDARD.decode(skin_data[comma + 1..].trim()).map_err(err)?
} else if skin_data.starts_with("http://") || skin_data.starts_with("https://") {
state.http.get(&skin_data).send().await.map_err(err)?.bytes().await.map_err(err)?.to_vec()
} else {
base64::engine::general_purpose::STANDARD.decode(skin_data.trim()).map_err(err)?
};
let form = reqwest::multipart::Form::new()
.text("model", p.skin_model.clone())
.part("file", reqwest::multipart::Part::bytes(bytes).file_name("skin.png").mime_str("image/png").map_err(err)?);
let req = account_api(&state, reqwest::Method::POST, "/account/skin").await?;
profile_response(req.multipart(form).send().await.map_err(err)?).await?;
} else {
let req = account_api(&state, reqwest::Method::PUT, "/account/skin/model").await?;
profile_response(req.json(&serde_json::json!({ "model": p.skin_model })).send().await.map_err(err)?).await?;
}
} else {
let req = account_api(&state, reqwest::Method::PUT, "/account/skin/model").await?;
profile_response(req.json(&serde_json::json!({ "model": p.skin_model })).send().await.map_err(err)?).await?;
}
let req = account_api(&state, reqwest::Method::PUT, "/account/cape").await?;
profile_response(req.json(&serde_json::json!({ "cape_id": p.cape_id })).send().await.map_err(err)?).await?;
account_profile(state).await
}
// ---- game ----
#[tauri::command]
+4
View File
@@ -59,6 +59,10 @@ pub fn run() {
commands::set_skin_model,
commands::delete_skin,
commands::set_cape,
commands::get_skin_profiles,
commands::save_skin_profile,
commands::delete_skin_profile,
commands::apply_skin_profile,
commands::set_username,
commands::select_account,
commands::remove_account,
+11
View File
@@ -110,4 +110,15 @@ export type ProgressEvent =
export interface ServerStatus { online: boolean; version: string; players_online: number; players_max: number; sample: string[]; motd: string; favicon: string | null; latency_ms: number }
export interface CapeInfo { id: number; name: string; url: string }
export interface PlayerProfile { uuid: string; name: string; skin_url: string | null; skin_model: 'classic' | 'slim'; cape: CapeInfo | null; available_capes: CapeInfo[] }
export interface SkinProfile {
id: string;
name: string;
skin_data: string | null;
skin_model: 'classic' | 'slim';
cape_id: number | null;
cape_name: string | null;
cape_url: string | null;
account_id?: string | null;
created_at: string;
}
export interface UpdateInfo { version: string; notes: string; url: string; size: number }
+232 -1
View File
@@ -2,15 +2,17 @@
import {
UserRound, Gamepad2, Cpu, Keyboard, Palette, Rocket, HardDrive, Info, Plus, LogOut, Check, FolderOpen, Trash2,
FileCode, RotateCcw, Download, LoaderCircle, RefreshCw, MousePointerClick, Zap, Gauge, Feather, Link2, Shirt, Upload, Ban,
BookmarkPlus, Sparkles,
} from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import Modal from '../components/Modal.svelte';
import SkinView from '../components/SkinView.svelte';
import Toggle from '../components/Toggle.svelte';
import { activeAccount, app, connect, instances, refresh, removeAccount, saveSettings, switchAccount, toast } from '../lib/store.svelte';
import { BIND_GROUPS, defaults, fromKeyboard, fromMouse, keyLabel } from '../lib/keys';
import { bytes, gb } from '../lib/format';
import { errorText, invoke, openUrl, pickFile } from '../lib/tauri';
import type { Account, Gc, PlayerProfile, UpdateInfo } from '../lib/types';
import type { Account, Gc, PlayerProfile, SkinProfile, UpdateInfo } from '../lib/types';
const serverAccount = $derived(activeAccount()?.kind === 'panel');
const tabs = $derived([
@@ -114,6 +116,109 @@
uploadSkin(e.dataTransfer?.files[0]);
}
// ---- saved skin profiles (wardrobe) ----
let skinProfiles = $state<SkinProfile[]>([]);
let profileNameInput = $state('');
let savingCurrentLook = $state(false);
let applyingProfileId = $state<string | null>(null);
let newProfileModal = $state(false);
let newProfileName = $state('');
let newProfileFile = $state<File | null>(null);
let newProfileModel = $state<'classic' | 'slim'>('classic');
let newProfileCapeId = $state<number | null>(null);
async function loadSkinProfiles() {
try {
skinProfiles = await invoke<SkinProfile[]>('get_skin_profiles');
} catch {
skinProfiles = [];
}
}
$effect(() => {
if (app.settingsTab === 'skin') {
loadSkinProfiles();
}
});
async function saveCurrentLook() {
if (!profile) return;
const name = profileNameInput.trim() || `Look ${skinProfiles.length + 1}`;
try {
skinProfiles = await invoke<SkinProfile[]>('save_skin_profile', {
profile: {
id: 'sp_' + Math.random().toString(36).slice(2, 10),
name,
skin_data: profile.skin_url,
skin_model: profile.skin_model,
cape_id: profile.cape?.id ?? null,
cape_name: profile.cape?.name ?? null,
cape_url: profile.cape?.url ?? null,
account_id: activeAccount()?.id ?? null,
created_at: new Date().toISOString(),
}
});
profileNameInput = '';
savingCurrentLook = false;
toast(`Saved "${name}" to wardrobe`);
} catch (e) {
toast(errorText(e), 'error');
}
}
async function equipProfile(p: SkinProfile) {
applyingProfileId = p.id;
try {
profile = await invoke<PlayerProfile>('apply_skin_profile', { id: p.id });
app.skinVersion++;
toast(`Equipped "${p.name}"`);
} catch (e) {
toast(errorText(e), 'error');
} finally {
applyingProfileId = null;
}
}
async function deleteProfile(id: string, name: string) {
try {
skinProfiles = await invoke<SkinProfile[]>('delete_skin_profile', { id });
toast(`Removed "${name}"`);
} catch (e) {
toast(errorText(e), 'error');
}
}
async function createProfileFromFile() {
if (!newProfileFile) return toast('Please select a skin PNG file', 'error');
const name = newProfileName.trim() || 'Custom Look';
const buf = new Uint8Array(await newProfileFile.arrayBuffer());
let bin = '';
for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
const base64Data = 'data:image/png;base64,' + btoa(bin);
const chosenCape = profile?.available_capes.find(c => c.id === newProfileCapeId);
try {
skinProfiles = await invoke<SkinProfile[]>('save_skin_profile', {
profile: {
id: 'sp_' + Math.random().toString(36).slice(2, 10),
name,
skin_data: base64Data,
skin_model: newProfileModel,
cape_id: newProfileCapeId,
cape_name: chosenCape?.name ?? null,
cape_url: chosenCape?.url ?? null,
account_id: activeAccount()?.id ?? null,
created_at: new Date().toISOString(),
}
});
newProfileModal = false;
newProfileName = '';
newProfileFile = null;
toast(`Saved "${name}" to wardrobe`);
} catch (e) {
toast(errorText(e), 'error');
}
}
// ---- storage ----
let storage = $state<{ shared: number; runtimes: number; instances: [string, number][] } | null>(null);
function loadStorage() {
@@ -273,6 +378,114 @@
<p class="muted small">No capes are available to you yet. Server staff can hand them out from the panel.</p>
{/if}
</div>
<div class="card glass col">
<div class="row align-center justify-between">
<div>
<h3 class="row gap-sm"><Sparkles size={18} class="accent-icon" /> Saved Profiles & Wardrobe</h3>
<p class="muted small">Save and swap between multiple skin + cape combinations in one click.</p>
</div>
<div class="row gap-sm">
{#if !savingCurrentLook}
<button class="sm" onclick={() => (savingCurrentLook = true)}>
<BookmarkPlus size={15} /> Save current look
</button>
<button class="sm ghost" onclick={() => (newProfileModal = true)}>
<Plus size={15} /> New preset
</button>
{/if}
</div>
</div>
{#if savingCurrentLook}
<form class="save-look-box row gap-sm" onsubmit={(e) => { e.preventDefault(); saveCurrentLook(); }}>
<input bind:value={profileNameInput} placeholder="Profile name (e.g. PvP, Casual, Outfit 1)" required />
<button type="submit" class="sm primary">Save</button>
<button type="button" class="sm ghost" onclick={() => { savingCurrentLook = false; profileNameInput = ''; }}>Cancel</button>
</form>
{/if}
{#if skinProfiles.length}
<div class="profiles-grid">
{#each skinProfiles as sp (sp.id)}
{@const isCurrent = profile.skin_model === sp.skin_model && (sp.cape_id === (profile.cape?.id ?? null))}
<div class="profile-card" class:active-look={isCurrent}>
<div class="profile-preview">
<SkinView skin={sp.skin_data} cape={sp.cape_url} slim={sp.skin_model === 'slim'} side="front" scale={3.5} />
</div>
<div class="profile-info">
<div class="row align-center justify-between">
<strong class="profile-name" title={sp.name}>{sp.name}</strong>
{#if isCurrent}
<span class="active-badge tiny-badge"><Check size={11} /> Active</span>
{/if}
</div>
<div class="profile-meta tiny muted">
<span>{sp.skin_model === 'slim' ? 'Slim' : 'Classic'}</span>
<span>·</span>
<span>{sp.cape_name ?? 'No cape'}</span>
</div>
<div class="profile-actions row gap-xs">
<button
class="sm grow"
disabled={isCurrent || applyingProfileId === sp.id}
onclick={() => equipProfile(sp)}
>
{#if applyingProfileId === sp.id}
<LoaderCircle size={13} class="spin" /> Equipping…
{:else if isCurrent}
Equipped
{:else}
Equip
{/if}
</button>
<button class="sm ghost icon" title="Delete preset" onclick={() => deleteProfile(sp.id, sp.name)}>
<Trash2 size={13} />
</button>
</div>
</div>
</div>
{/each}
</div>
{:else}
<p class="muted small">No saved profiles yet. Click "Save current look" to bookmark your current skin and cape, or "New preset" to add one.</p>
{/if}
</div>
{#if newProfileModal}
<Modal bind:open={newProfileModal} title="Create Skin & Cape Preset" width={28}>
<form class="col gap-sm" onsubmit={(e) => { e.preventDefault(); createProfileFromFile(); }}>
<label class="field">
<span>Preset name</span>
<input bind:value={newProfileName} placeholder="e.g. Red Hoodie, PvP" required />
</label>
<label class="field">
<span>Skin file (PNG)</span>
<input type="file" accept="image/png" required onchange={(e) => (newProfileFile = e.currentTarget.files?.[0] ?? null)} />
</label>
<label class="field">
<span>Arm model</span>
<div class="segmented full">
<button type="button" class:active={newProfileModel === 'classic'} onclick={() => (newProfileModel = 'classic')}>Classic (4px)</button>
<button type="button" class:active={newProfileModel === 'slim'} onclick={() => (newProfileModel = 'slim')}>Slim (3px)</button>
</div>
</label>
<label class="field">
<span>Cape</span>
<select bind:value={newProfileCapeId}>
<option value={null}>No cape</option>
{#each profile.available_capes as c (c.id)}
<option value={c.id}>{c.name}</option>
{/each}
</select>
</label>
<div class="row justify-end gap-sm" style="margin-top: 0.8rem;">
<button type="button" class="ghost" onclick={() => (newProfileModal = false)}>Cancel</button>
<button type="submit" class="primary">Save preset</button>
</div>
</form>
</Modal>
{/if}
{/if}
{:else if app.settingsTab === 'game'}
<h1>Game</h1>
@@ -559,5 +772,23 @@
/* Cape texture: the outer face is the 10×16 region at (1,1) of a 64×32 sheet. */
.cape-img { width: 2.5rem; height: 4rem; border-radius: 0.2rem; image-rendering: pixelated; background-color: color-mix(in srgb, var(--text) 8%, transparent); background-size: 640% 200%; background-position: 1.85% 6.25%; background-repeat: no-repeat; }
.cape-img.none { display: grid; place-items: center; color: var(--muted); }
.align-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.gap-xs { gap: 0.35rem; }
.gap-sm { gap: 0.6rem; }
:global(.accent-icon) { color: var(--accent); }
.save-look-box { padding: 0.6rem 0.8rem; background: color-mix(in srgb, var(--bg) 60%, transparent); border-radius: var(--radius-sm); border: 1px solid var(--line); }
.save-look-box input { flex: 1; }
.profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 0.8rem; }
.profile-card { display: flex; gap: 0.8rem; align-items: center; padding: 0.75rem 0.85rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg) 50%, transparent); border: 1px solid var(--line); transition: border-color 0.15s, background 0.15s; }
.profile-card:hover { border-color: var(--line-strong); background: color-mix(in srgb, var(--bg) 70%, transparent); }
.profile-card.active-look { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
.profile-preview { flex-shrink: 0; padding: 0.4rem 0.5rem; background: color-mix(in srgb, var(--bg) 80%, transparent); border-radius: var(--radius-sm); display: grid; place-items: center; }
.profile-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.profile-name { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.profile-meta { display: flex; gap: 0.3rem; align-items: center; }
.tiny-badge { padding: 0.15rem 0.4rem; font-size: 0.72rem; }
.profile-actions { margin-top: 0.2rem; }
@media (max-width: 1050px) { .binds { grid-template-columns: 1fr; } .tabs { width: 12rem; } .skin-card { flex-direction: column; align-items: stretch; } .preview { justify-content: center; } }
</style>