Add saved skin and cape profiles wardrobe feature
This commit is contained in:
1 parent
c7aa563dac
commit
ebd9de1bee
5 files changed
+375
-2
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user