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
@@ -217,6 +217,30 @@ pub struct PlayerProfile {
|
|||||||
pub available_capes: Vec<CapeInfo>,
|
pub available_capes: Vec<CapeInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A saved skin and cape preset (wardrobe look).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SkinProfile {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub skin_data: Option<String>,
|
||||||
|
#[serde(default = "default_skin_model")]
|
||||||
|
pub skin_model: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cape_id: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cape_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cape_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_skin_model() -> String {
|
||||||
|
"classic".into()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::settings::Settings;
|
|||||||
use crate::state::{build, AppState};
|
use crate::state::{build, AppState};
|
||||||
use crate::updater;
|
use crate::updater;
|
||||||
use scopenet_core::ping::ServerStatus;
|
use scopenet_core::ping::ServerStatus;
|
||||||
use scopenet_shared::{LauncherManifest, PlayerProfile};
|
use scopenet_shared::{LauncherManifest, PlayerProfile, SkinProfile};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
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
|
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 ----
|
// ---- game ----
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ pub fn run() {
|
|||||||
commands::set_skin_model,
|
commands::set_skin_model,
|
||||||
commands::delete_skin,
|
commands::delete_skin,
|
||||||
commands::set_cape,
|
commands::set_cape,
|
||||||
|
commands::get_skin_profiles,
|
||||||
|
commands::save_skin_profile,
|
||||||
|
commands::delete_skin_profile,
|
||||||
|
commands::apply_skin_profile,
|
||||||
commands::set_username,
|
commands::set_username,
|
||||||
commands::select_account,
|
commands::select_account,
|
||||||
commands::remove_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 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 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 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 }
|
export interface UpdateInfo { version: string; notes: string; url: string; size: number }
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
import {
|
import {
|
||||||
UserRound, Gamepad2, Cpu, Keyboard, Palette, Rocket, HardDrive, Info, Plus, LogOut, Check, FolderOpen, Trash2,
|
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,
|
FileCode, RotateCcw, Download, LoaderCircle, RefreshCw, MousePointerClick, Zap, Gauge, Feather, Link2, Shirt, Upload, Ban,
|
||||||
|
BookmarkPlus, Sparkles,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import Avatar from '../components/Avatar.svelte';
|
import Avatar from '../components/Avatar.svelte';
|
||||||
|
import Modal from '../components/Modal.svelte';
|
||||||
import SkinView from '../components/SkinView.svelte';
|
import SkinView from '../components/SkinView.svelte';
|
||||||
import Toggle from '../components/Toggle.svelte';
|
import Toggle from '../components/Toggle.svelte';
|
||||||
import { activeAccount, app, connect, instances, refresh, removeAccount, saveSettings, switchAccount, toast } from '../lib/store.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 { BIND_GROUPS, defaults, fromKeyboard, fromMouse, keyLabel } from '../lib/keys';
|
||||||
import { bytes, gb } from '../lib/format';
|
import { bytes, gb } from '../lib/format';
|
||||||
import { errorText, invoke, openUrl, pickFile } from '../lib/tauri';
|
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 serverAccount = $derived(activeAccount()?.kind === 'panel');
|
||||||
const tabs = $derived([
|
const tabs = $derived([
|
||||||
@@ -114,6 +116,109 @@
|
|||||||
uploadSkin(e.dataTransfer?.files[0]);
|
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 ----
|
// ---- storage ----
|
||||||
let storage = $state<{ shared: number; runtimes: number; instances: [string, number][] } | null>(null);
|
let storage = $state<{ shared: number; runtimes: number; instances: [string, number][] } | null>(null);
|
||||||
function loadStorage() {
|
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>
|
<p class="muted small">No capes are available to you yet. Server staff can hand them out from the panel.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
{:else if app.settingsTab === 'game'}
|
{:else if app.settingsTab === 'game'}
|
||||||
<h1>Game</h1>
|
<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 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 { 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); }
|
.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; } }
|
@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>
|
</style>
|
||||||
Reference in new issue
Block a user