Add account connections, guild wallets and admin fixes [skip ci]

This commit is contained in:
SCOPEDD committed 2026-09-30 12:25:29 -04:00
1 parent 7076e847ea
commit 004862ea88
38 files changed
+1696 -458

No files matched your search

+2 -2
View File
@@ -67,7 +67,7 @@ impl AccountsFile {
}
}
async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
pub(crate) async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body: serde_json::Value = resp.json().await.unwrap_or_default();
anyhow!(
@@ -115,7 +115,7 @@ pub async fn register_panel(state: &AppState, username: &str, password: &str, em
Ok((Some(save_panel_account(state, &panel, auth)?), false))
}
fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
pub(crate) fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "panel".into(),
+206 -100
View File
@@ -7,9 +7,8 @@ use crate::state::{build, AppState};
use crate::updater;
use scopenet_core::ping::ServerStatus;
use scopenet_shared::{
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild,
GuildClaim, GuildMember, GuildPost, LauncherManifest, MemberProfile, PlayerProfile,
ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild, GuildClaim, GuildMember, GuildPost,
LauncherManifest, MemberProfile, PlayerProfile, ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
};
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
@@ -56,7 +55,10 @@ pub struct Bootstrap {
}
#[derive(Serialize)]
struct RunningGameInfo { run_id: String, instance_id: String }
struct RunningGameInfo {
run_id: String,
instance_id: String,
}
#[tauri::command]
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
@@ -77,7 +79,13 @@ pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
accounts: accounts.accounts,
active_account: accounts.active,
manifest: state.manifest.read().unwrap().clone(),
game_running: state.game.lock().unwrap().iter().map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() }).collect(),
game_running: state
.game
.lock()
.unwrap()
.iter()
.map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() })
.collect(),
}
}
@@ -134,7 +142,9 @@ pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) ->
return Err("close this instance before saving its game settings".into());
}
let options = scopenet_core::options::read_vanilla_preferences(&state.layout.instance_dir(&instance_id)).map_err(aerr)?;
if options.is_empty() { return Err("no vanilla options.txt settings found yet".into()); }
if options.is_empty() {
return Err("no vanilla options.txt settings found yet".into());
}
state.settings.write().unwrap().instance_game_options.insert(instance_id, options.clone());
state.save_settings().map_err(aerr)?;
Ok(options)
@@ -147,6 +157,80 @@ pub async fn login_panel(state: State<'_, AppState>, username: String, password:
accounts::login_panel(&state, &username, &password).await.map_err(aerr)
}
#[tauri::command]
pub async fn discord_sign_in_start(state: State<'_, AppState>) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let response = state.http.get(format!("{panel}/api/v1/auth/discord/start")).send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
response.json().await.map_err(err)
}
#[tauri::command]
pub async fn discord_sign_in_poll(state: State<'_, AppState>, oauth_state: String) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let response =
state.http.get(format!("{panel}/api/v1/auth/discord/poll")).query(&[("state", oauth_state)]).send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
let result: serde_json::Value = response.json().await.map_err(err)?;
if result.get("token").is_some() {
let auth = serde_json::from_value(result.clone()).map_err(err)?;
let account = accounts::save_panel_account(&state, &panel, auth).map_err(aerr)?;
return Ok(serde_json::json!({"account":account}));
}
Ok(result)
}
#[tauri::command]
pub async fn request_password_reset(state: State<'_, AppState>, email: String) -> Res<String> {
let panel = state.panel_url().ok_or("no panel configured")?;
let response = state
.http
.post(format!("{panel}/api/v1/auth/forgot-password"))
.json(&serde_json::json!({"email":email}))
.send()
.await
.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
let body: serde_json::Value = response.json().await.map_err(err)?;
Ok(body["message"].as_str().unwrap_or("Check your email for a reset link.").to_string())
}
#[tauri::command]
pub async fn discord_connection(state: State<'_, AppState>) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, "/account/connections/discord").await?;
let response = req.send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
response.json().await.map_err(err)
}
#[tauri::command]
pub async fn discord_link_start(state: State<'_, AppState>) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, "/auth/discord/start?kind=link").await?;
let response = req.send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
response.json().await.map_err(err)
}
#[tauri::command]
pub async fn discord_unlink(state: State<'_, AppState>) -> Res<()> {
let req = account_api(&state, reqwest::Method::DELETE, "/account/connections/discord").await?;
let response = req.send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
Ok(())
}
#[derive(Serialize)]
pub struct RegisterResult {
account: Option<Account>,
@@ -242,10 +326,7 @@ pub async fn set_cape(state: State<'_, AppState>, cape_id: Option<i64>) -> Res<P
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()
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<()> {
@@ -283,10 +364,7 @@ pub async fn save_skin_profile(state: State<'_, AppState>, mut profile: SkinProf
}
}
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();
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);
@@ -391,7 +469,9 @@ pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: St
#[tauri::command]
pub fn kill_game(state: State<'_, AppState>, run_id: String) {
if let Some(g) = state.game.lock().unwrap().iter_mut().find(|g| g.run_id == run_id) {
if let Some(kill) = g.kill.take() { kill.send(()).ok(); }
if let Some(kill) = g.kill.take() {
kill.send(()).ok();
}
}
}
@@ -512,11 +592,7 @@ pub async fn get_player_stats(state: State<'_, AppState>) -> Res<serde_json::Val
}
#[tauri::command]
pub async fn get_leaderboard(
state: State<'_, AppState>,
server_id: Option<i64>,
sort: Option<String>,
) -> Res<serde_json::Value> {
pub async fn get_leaderboard(state: State<'_, AppState>, server_id: Option<i64>, sort: Option<String>) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let sort_query = sort.map(|s| format!("?sort={s}")).unwrap_or_default();
let url = if let Some(id) = server_id {
@@ -539,9 +615,12 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
return Err("unable to load servers".into());
}
let data: serde_json::Value = resp.json().await.map_err(err)?;
let urls = data["servers"].as_array().into_iter().flatten().filter_map(|s| {
Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned()))
}).collect();
let urls = data["servers"]
.as_array()
.into_iter()
.flatten()
.filter_map(|s| Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned())))
.collect();
*state.map_urls.lock().unwrap() = urls;
Ok(data)
}
@@ -557,24 +636,29 @@ pub struct MapResource {
/// cross-origin Vantage live server, so the launcher uses its HTTP client.
/// Only paths under a map URL supplied by the panel can be fetched.
#[tauri::command]
pub async fn fetch_vantage_resource(
state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>,
) -> Res<MapResource> {
pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
use base64::Engine;
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
let base = reqwest::Url::parse(&configured).map_err(err)?;
let target = reqwest::Url::parse(&url).map_err(err)?;
let root = base.join(".").map_err(err)?;
if !matches!(target.scheme(), "http" | "https") || target.origin() != base.origin()
|| !target.path().starts_with(root.path()) || target.username() != ""
|| target.password().is_some() || target.fragment().is_some() {
if !matches!(target.scheme(), "http" | "https")
|| target.origin() != base.origin()
|| !target.path().starts_with(root.path())
|| target.username() != ""
|| target.password().is_some()
|| target.fragment().is_some()
{
return Err("map asset is outside the configured Vantage world".into());
}
if target.as_str().len() > 2048 { return Err("map asset URL is too long".into()); }
if target.as_str().len() > 2048 {
return Err("map asset URL is too long".into());
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(30))
.build().map_err(err)?;
.build()
.map_err(err)?;
let mut request = client.get(target);
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
@@ -582,13 +666,19 @@ pub async fn fetch_vantage_resource(
let response = request.send().await.map_err(err)?;
let status = response.status().as_u16();
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
if status == 304 { return Ok(MapResource { status, data: None, etag }); }
if !response.status().is_success() { return Ok(MapResource { status, data: None, etag }); }
if status == 304 {
return Ok(MapResource { status, data: None, etag });
}
if !response.status().is_success() {
return Ok(MapResource { status, data: None, etag });
}
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) {
return Err("Vantage asset exceeds 64 MiB".into());
}
let bytes = response.bytes().await.map_err(err)?;
if bytes.len() > 64 * 1024 * 1024 { return Err("Vantage asset exceeds 64 MiB".into()); }
if bytes.len() > 64 * 1024 * 1024 {
return Err("Vantage asset exceeds 64 MiB".into());
}
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
}
@@ -678,14 +768,18 @@ pub async fn create_guild(
banner_url: Option<String>,
) -> Res<Guild> {
let req = account_api(&state, reqwest::Method::POST, "/guilds").await?;
let resp = req.json(&serde_json::json!({
"instance_id": instance_id,
"name": name,
"tag": tag,
"description": description,
"icon_url": icon_url,
"banner_url": banner_url,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"instance_id": instance_id,
"name": name,
"tag": tag,
"description": description,
"icon_url": icon_url,
"banner_url": banner_url,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to create guild").to_string());
@@ -723,13 +817,17 @@ pub async fn claim_guild_chunk(
chunk_z: i32,
) -> Res<GuildClaim> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/claim")).await?;
let resp = req.json(&serde_json::json!({
"instance_id": instance_id,
"server_id": server_id,
"dimension": dimension,
"chunk_x": chunk_x,
"chunk_z": chunk_z,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"instance_id": instance_id,
"server_id": server_id,
"dimension": dimension,
"chunk_x": chunk_x,
"chunk_z": chunk_z,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to claim chunk").to_string());
@@ -755,17 +853,16 @@ pub async fn get_guild_posts(state: State<'_, AppState>, guild_id: String) -> Re
}
#[tauri::command]
pub async fn create_guild_post(
state: State<'_, AppState>,
guild_id: String,
title: String,
content: String,
) -> Res<GuildPost> {
pub async fn create_guild_post(state: State<'_, AppState>, guild_id: String, title: String, content: String) -> Res<GuildPost> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/posts")).await?;
let resp = req.json(&serde_json::json!({
"title": title,
"content": content,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"title": title,
"content": content,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to post announcement").to_string());
@@ -783,6 +880,24 @@ pub async fn get_guild_members(state: State<'_, AppState>, guild_id: String) ->
resp.json().await.map_err(err)
}
#[tauri::command]
pub async fn get_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/wallet?server_id={server_id}")).await?;
req.send().await.map_err(err)?.error_for_status().map_err(err)?.json().await.map_err(err)
}
#[tauri::command]
pub async fn transfer_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64, amount: f64, withdraw: bool) -> Res<serde_json::Value> {
let action = if withdraw { "withdraw" } else { "deposit" };
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/wallet/{action}")).await?;
let resp = req.json(&serde_json::json!({"server_id":server_id,"amount":amount})).send().await.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("Guild wallet transfer failed").to_string());
}
resp.json().await.map_err(err)
}
// ---------------------------------------------------------------------------
// Social: Friends, DMs, Invites, Profiles & Feed
// ---------------------------------------------------------------------------
@@ -798,11 +913,7 @@ pub async fn get_friends(state: State<'_, AppState>) -> Res<Vec<FriendInfo>> {
}
#[tauri::command]
pub async fn send_friend_request(
state: State<'_, AppState>,
friend_username: Option<String>,
username: Option<String>,
) -> Res<()> {
pub async fn send_friend_request(state: State<'_, AppState>, friend_username: Option<String>, username: Option<String>) -> Res<()> {
let target = friend_username.or(username).ok_or("username is required")?;
let req = account_api(&state, reqwest::Method::POST, "/friends/request").await?;
let resp = req.json(&serde_json::json!({ "username": target })).send().await.map_err(err)?;
@@ -831,11 +942,7 @@ pub async fn respond_friend_request(
}
#[tauri::command]
pub async fn remove_friend(
state: State<'_, AppState>,
friend_uuid: Option<String>,
target_uuid: Option<String>,
) -> Res<()> {
pub async fn remove_friend(state: State<'_, AppState>, friend_uuid: Option<String>, target_uuid: Option<String>) -> Res<()> {
let target = friend_uuid.or(target_uuid).ok_or("target_uuid is required")?;
let req = account_api(&state, reqwest::Method::DELETE, &format!("/friends/{target}")).await?;
let resp = req.send().await.map_err(err)?;
@@ -887,11 +994,7 @@ pub async fn get_transactions(state: State<'_, AppState>) -> Res<Vec<EconomyTran
}
#[tauri::command]
pub async fn get_direct_messages(
state: State<'_, AppState>,
friend_uuid: String,
before_id: Option<i64>,
) -> Res<Vec<DirectMessage>> {
pub async fn get_direct_messages(state: State<'_, AppState>, friend_uuid: String, before_id: Option<i64>) -> Res<Vec<DirectMessage>> {
// FIX #13: Support optional before_id for loading older messages beyond the first 100.
let path = if let Some(before) = before_id {
format!("/messages/{friend_uuid}?before_id={before}")
@@ -928,18 +1031,17 @@ pub async fn get_game_invites(state: State<'_, AppState>) -> Res<Vec<GameInvite>
}
#[tauri::command]
pub async fn send_game_invite(
state: State<'_, AppState>,
recipient_uuid: String,
instance_id: String,
server_id: Option<i64>,
) -> Res<()> {
pub async fn send_game_invite(state: State<'_, AppState>, recipient_uuid: String, instance_id: String, server_id: Option<i64>) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, "/invites").await?;
let resp = req.json(&serde_json::json!({
"recipient_uuid": recipient_uuid,
"instance_id": instance_id,
"server_id": server_id,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"recipient_uuid": recipient_uuid,
"instance_id": instance_id,
"server_id": server_id,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to send invite").to_string());
@@ -977,12 +1079,16 @@ pub async fn update_my_profile(
featured_achievement_id: Option<String>,
) -> Res<UserProfileView> {
let req = account_api(&state, reqwest::Method::PUT, "/profiles/me").await?;
let resp = req.json(&serde_json::json!({
"bio": bio,
"banner_url": banner_url,
"custom_badge": custom_badge,
"featured_achievement_id": featured_achievement_id,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"bio": bio,
"banner_url": banner_url,
"custom_badge": custom_badge,
"featured_achievement_id": featured_achievement_id,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to update profile").to_string());
@@ -1003,16 +1109,16 @@ pub async fn get_user_posts(state: State<'_, AppState>, user_uuid: Option<String
}
#[tauri::command]
pub async fn create_user_post(
state: State<'_, AppState>,
content: String,
image_url: Option<String>,
) -> Res<UserPost> {
pub async fn create_user_post(state: State<'_, AppState>, content: String, image_url: Option<String>) -> Res<UserPost> {
let req = account_api(&state, reqwest::Method::POST, "/profiles/me/posts").await?;
let resp = req.json(&serde_json::json!({
"content": content,
"image_url": image_url,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"content": content,
"image_url": image_url,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to publish post").to_string());
+8
View File
@@ -53,6 +53,12 @@ pub fn run() {
commands::save_settings,
commands::save_instance_options,
commands::login_panel,
commands::discord_sign_in_start,
commands::discord_sign_in_poll,
commands::request_password_reset,
commands::discord_connection,
commands::discord_link_start,
commands::discord_unlink,
commands::register_panel,
commands::add_offline,
commands::account_profile,
@@ -99,6 +105,8 @@ pub fn run() {
commands::get_guild_posts,
commands::create_guild_post,
commands::get_guild_members,
commands::get_guild_wallet,
commands::transfer_guild_wallet,
commands::get_friends,
commands::send_friend_request,
commands::respond_friend_request,
+40 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { UserRound, WifiOff, LoaderCircle, Check, ArrowRight, UserPlus } from '@lucide/svelte';
import { accountAdded, app, toast } from '../lib/store.svelte';
import { errorText, invoke } from '../lib/tauri';
import { errorText, invoke, openUrl } from '../lib/tauri';
import type { Account } from '../lib/types';
const auth = $derived(app.manifest?.auth);
@@ -23,6 +23,9 @@
let busy = $state(false);
let error = $state('');
let pendingNotice = $state(false);
let recoveryEmail = $state('');
let recovering = $state(false);
let recoveryNotice = $state('');
const canRegister = $derived(auth?.registration && auth.registration !== 'closed');
@@ -48,6 +51,33 @@
}
}
async function signInDiscord() {
error = ''; busy = true;
try {
const flow = await invoke<{url: string; state: string}>('discord_sign_in_start');
await openUrl(flow.url);
for (let i = 0; i < 120; i++) {
await new Promise(resolve => setTimeout(resolve, 1500));
const result = await invoke<{account?: Account; pending?: boolean}>('discord_sign_in_poll', {oauthState: flow.state});
if (result.account) {
await accountAdded(result.account);
toast(`Signed in as ${result.account.username}`);
return;
}
if (!result.pending) { pendingNotice = true; return; }
}
throw new Error('Discord sign-in timed out. Try again.');
} catch (err) { error = errorText(err); }
finally { busy = false; }
}
async function forgotPassword() {
error = ''; busy = true;
try { recoveryNotice = await invoke<string>('request_password_reset', {email: recoveryEmail}); }
catch (err) { error = errorText(err); }
finally { busy = false; }
}
</script>
<div class="signin">
@@ -89,6 +119,15 @@
{registering ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
</button>
{/if}
{#if method === 'panel' && !registering}
<button type="button" class="ghost sm switch" onclick={signInDiscord} disabled={busy}>Sign in with Discord</button>
<button type="button" class="ghost sm switch" onclick={() => recovering = !recovering}>Forgot password?</button>
{#if recovering}
<label class="field">Account email<input type="email" bind:value={recoveryEmail} placeholder="you@example.com" /></label>
<button type="button" disabled={busy || !recoveryEmail} onclick={forgotPassword}>Email reset link</button>
{#if recoveryNotice}<p class="help">{recoveryNotice}</p>{/if}
{/if}
{/if}
</form>
{/if}
+63 -2
View File
@@ -7,7 +7,7 @@
import Avatar from '../components/Avatar.svelte';
import ChunkMap from '../components/ChunkMap.svelte';
import Modal from '../components/Modal.svelte';
import { app, instances, selectedInstance, toast } from '../lib/store.svelte';
import { activeAccount, app, instances, selectedInstance, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { Guild, GuildMember, GuildPost } from '../lib/types';
@@ -16,8 +16,34 @@
let allGuilds = $state<Guild[]>([]);
let members = $state<GuildMember[]>([]);
let posts = $state<GuildPost[]>([]);
let activeTab = $state<'territory' | 'members' | 'feed'>('territory');
let activeTab = $state<'territory' | 'members' | 'feed' | 'wallet'>('territory');
let loading = $state(false);
let walletServers = $state<Array<{ id: number; name: string; instance_id: string }>>([]);
let walletServerId = $state<number | null>(null);
let wallet = $state<{ balance: number; transactions: Array<{ id: number; actor_uuid: string; kind: string; amount: number; created_at: string }> } | null>(null);
let walletAmount = $state('');
let walletBusy = $state(false);
const canWithdraw = $derived(members.some(m => m.uuid === activeAccount()?.uuid && ['leader', 'officer'].includes(m.role)));
async function loadWallet() {
if (!myGuild || !walletServerId) { wallet = null; return; }
try { wallet = await invoke('get_guild_wallet', { guildId: myGuild.id, serverId: walletServerId }); }
catch (e: any) { toast(e?.message ?? 'Unable to load guild wallet', 'error'); }
}
async function transferWallet(withdraw: boolean) {
if (!myGuild || !walletServerId || walletBusy) return;
const amount = Number(walletAmount);
if (!Number.isFinite(amount) || amount <= 0 || Math.round(amount * 100) !== amount * 100) { toast('Enter a valid amount with at most two decimals', 'error'); return; }
walletBusy = true;
try {
await invoke('transfer_guild_wallet', { guildId: myGuild.id, serverId: walletServerId, amount, withdraw });
walletAmount = '';
await loadWallet();
toast(withdraw ? 'Funds withdrawn' : 'Funds deposited', 'ok');
} catch (e: any) { toast(e?.message ?? 'Transfer failed', 'error'); }
finally { walletBusy = false; }
}
// Create Guild Modal
let createModalOpen = $state(false);
@@ -67,6 +93,16 @@
}
}
onMount(async () => {
try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; }
catch { walletServers = []; }
});
$effect(() => {
const available = walletServers.filter(s => s.instance_id === selectedInstId);
if (!available.some(s => s.id === walletServerId)) walletServerId = available[0]?.id ?? null;
});
$effect(() => { if (activeTab === 'wallet' && myGuild && walletServerId) loadWallet(); });
async function handleCreateGuild() {
if (!newGuildName.trim() || !newGuildTag.trim()) {
toast('Please enter both guild name and tag', 'error');
@@ -201,6 +237,9 @@
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
<MessageSquare size={15} /> Guild Feed ({posts.length})
</button>
<button class:active={activeTab === 'wallet'} onclick={() => (activeTab = 'wallet')}>
Guild Wallet
</button>
</div>
</div>
@@ -225,6 +264,28 @@
onunclaim={() => { if (myGuild) myGuild.claims_count = Math.max(0, myGuild.claims_count - 1); }}
/>
</div>
{:else if activeTab === 'wallet'}
<div class="wallet-tab glass">
<h3>Guild Wallet</h3>
<label>Game server
<select bind:value={walletServerId}>
{#each walletServers.filter(s => s.instance_id === selectedInstId) as server}<option value={server.id}>{server.name}</option>{/each}
</select>
</label>
{#if !walletServerId}<p>Link a game server to this instance to use the guild wallet.</p>{/if}
{#if wallet}
<p>Balance: ${wallet.balance.toFixed(2)}</p>
<div class="wallet-actions">
<input type="number" min="0.01" step="0.01" placeholder="Amount" bind:value={walletAmount} aria-label="Transfer amount" />
<button class="primary sm" disabled={walletBusy || !walletServerId} onclick={() => transferWallet(false)}>Deposit</button>
{#if canWithdraw}<button class="ghost sm" disabled={walletBusy || !walletServerId} onclick={() => transferWallet(true)}>Withdraw</button>{/if}
</div>
<h4>Recent transfers</h4>
{#each wallet.transactions as tx (tx.id)}
<p>{tx.kind === 'withdraw' ? '−' : '+'}${tx.amount.toFixed(2)} · {new Date(tx.created_at).toLocaleString()}</p>
{:else}<p>No transfers yet.</p>{/each}
{/if}
</div>
{:else if activeTab === 'members'}
<div class="members-tab">
<div class="members-grid">
+42
View File
@@ -15,6 +15,36 @@
import type { Account, Gc, PlayerProfile, SkinProfile, UpdateInfo } from '../lib/types';
const serverAccount = $derived(activeAccount()?.kind === 'panel');
let discordConnection = $state<{id: string; name: string} | null>(null);
let discordBusy = $state(false);
$effect(() => {
if (serverAccount && app.settingsTab === 'account') {
invoke<{id: string; name: string} | null>('discord_connection').then(x => discordConnection = x).catch(() => discordConnection = null);
}
});
async function linkDiscord() {
discordBusy = true;
try {
const flow = await invoke<{url: string; state: string}>('discord_link_start');
await openUrl(flow.url);
for (let i = 0; i < 120; i++) {
await new Promise(resolve => setTimeout(resolve, 1500));
const result = await invoke<{linked?: boolean; pending?: boolean}>('discord_sign_in_poll', {oauthState: flow.state});
if (result.linked) {
discordConnection = await invoke('discord_connection');
toast('Discord connected'); return;
}
}
toast('Discord link timed out', 'error');
} catch (e) { toast(errorText(e), 'error'); }
finally { discordBusy = false; }
}
async function unlinkDiscord() {
discordBusy = true;
try { await invoke('discord_unlink'); discordConnection = null; toast('Discord disconnected'); }
catch (e) { toast(errorText(e), 'error'); }
finally { discordBusy = false; }
}
const tabs = $derived([
{ id: 'account', label: 'Accounts', icon: UserRound },
...(serverAccount ? [{ id: 'skin', label: 'Skin & cape', icon: Shirt }] : []),
@@ -323,6 +353,18 @@
{/each}
<button class="add" onclick={() => (app.addAccount = true)}><Plus size={16} /> Add account</button>
</div>
{#if serverAccount}
<div class="card glass col">
<h3>Connections → Discord</h3>
<p class="muted small">Connect your Discord account to sign in and show your Discord identity in SCOPENET.</p>
{#if discordConnection}
<p>Connected as <strong>{discordConnection.name}</strong></p>
<button disabled={discordBusy} onclick={unlinkDiscord}>Disconnect Discord</button>
{:else}
<button disabled={discordBusy} onclick={linkDiscord}>{discordBusy ? 'Waiting for Discord…' : 'Connect Discord'}</button>
{/if}
</div>
{/if}
{:else if app.settingsTab === 'skin'}
<h1>Skin & cape</h1>
<form class="card glass col" onsubmit={(e) => { e.preventDefault(); renameAccount(); }}>