feat: complete in-game commands, GUIs, admin panel fixes, and launcher enhancements
This commit is contained in:
1 parent
a9ff92e1d1
commit
752f0cc43d
34 files changed
+4170
-181
No files matched your search
@@ -438,6 +438,44 @@ const MIGRATIONS: &[&str] = &[
|
||||
);
|
||||
CREATE INDEX user_posts_user ON user_posts(user_uuid, id DESC);
|
||||
"#,
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS server_economy (
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
uuid TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
balance REAL NOT NULL DEFAULT 1000.0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, uuid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS economy_user ON server_economy(uuid);
|
||||
CREATE INDEX IF NOT EXISTS economy_bal ON server_economy(server_id, balance DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS economy_transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
from_uuid TEXT NOT NULL,
|
||||
from_name TEXT NOT NULL,
|
||||
to_uuid TEXT NOT NULL,
|
||||
to_name TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS econ_tx_user ON economy_transactions(server_id, from_uuid, to_uuid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_market (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
seller_uuid TEXT NOT NULL,
|
||||
seller_name TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_name TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL DEFAULT 1,
|
||||
price REAL NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS market_srv ON server_market(server_id, id DESC);
|
||||
"#,
|
||||
];
|
||||
|
||||
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||
|
||||
@@ -78,12 +78,15 @@ pub async fn get_achievements_for_user(
|
||||
title,
|
||||
description: desc,
|
||||
category: cat,
|
||||
icon_frame: frame,
|
||||
icon_frame: frame.clone(),
|
||||
frame_type: Some(frame),
|
||||
icon_item: item,
|
||||
icon_bg: bg,
|
||||
icon_border: border,
|
||||
xp_reward: xp,
|
||||
secret,
|
||||
stat_type: None,
|
||||
target_count: None,
|
||||
unlocked,
|
||||
unlocked_at,
|
||||
})
|
||||
@@ -105,6 +108,8 @@ pub struct AdminAchievementRow {
|
||||
pub requirement_type: String,
|
||||
pub requirement_key: String,
|
||||
pub requirement_value: i64,
|
||||
pub stat_type: String,
|
||||
pub target_count: i64,
|
||||
pub total_unlocked: i64,
|
||||
}
|
||||
|
||||
@@ -166,18 +171,23 @@ pub async fn admin_list_achievements(
|
||||
title,
|
||||
description: desc,
|
||||
category: cat,
|
||||
icon_frame: frame,
|
||||
icon_frame: frame.clone(),
|
||||
frame_type: Some(frame),
|
||||
icon_item: item,
|
||||
icon_bg: bg,
|
||||
icon_border: border,
|
||||
xp_reward: xp,
|
||||
secret,
|
||||
stat_type: Some(req_key.clone()),
|
||||
target_count: Some(req_val),
|
||||
unlocked: false,
|
||||
unlocked_at: None,
|
||||
},
|
||||
requirement_type: req_type,
|
||||
requirement_key: req_key,
|
||||
requirement_key: req_key.clone(),
|
||||
requirement_value: req_val,
|
||||
stat_type: req_key,
|
||||
target_count: req_val,
|
||||
total_unlocked,
|
||||
}
|
||||
},
|
||||
@@ -193,13 +203,17 @@ pub struct AchievementPayload {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
#[serde(alias = "frame_type")]
|
||||
pub icon_frame: String,
|
||||
pub icon_item: String,
|
||||
pub icon_bg: String,
|
||||
pub icon_border: String,
|
||||
pub requirement_type: String,
|
||||
pub requirement_key: String,
|
||||
pub requirement_value: i64,
|
||||
#[serde(default)]
|
||||
pub requirement_type: Option<String>,
|
||||
#[serde(default, alias = "stat_type")]
|
||||
pub requirement_key: Option<String>,
|
||||
#[serde(default, alias = "target_count")]
|
||||
pub requirement_value: Option<i64>,
|
||||
pub xp_reward: i64,
|
||||
pub secret: Option<bool>,
|
||||
}
|
||||
@@ -215,6 +229,9 @@ pub async fn admin_create_achievement(
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("ach_{}", uuid::Uuid::new_v4().simple()));
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let req_type = payload.requirement_type.unwrap_or_else(|| "stat".into());
|
||||
let req_key = payload.requirement_key.unwrap_or_else(|| "blocks_broken".into());
|
||||
let req_val = payload.requirement_value.unwrap_or(50);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO achievements (id, title, description, category, icon_frame, icon_item, icon_bg, icon_border, requirement_type, requirement_key, requirement_value, xp_reward, secret, created_at)
|
||||
@@ -228,9 +245,9 @@ pub async fn admin_create_achievement(
|
||||
.bind(payload.icon_item)
|
||||
.bind(payload.icon_bg)
|
||||
.bind(payload.icon_border)
|
||||
.bind(payload.requirement_type)
|
||||
.bind(payload.requirement_key)
|
||||
.bind(payload.requirement_value)
|
||||
.bind(req_type)
|
||||
.bind(req_key)
|
||||
.bind(req_val)
|
||||
.bind(payload.xp_reward)
|
||||
.bind(payload.secret.unwrap_or(false))
|
||||
.bind(&now)
|
||||
@@ -247,6 +264,10 @@ pub async fn admin_update_achievement(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AchievementPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let req_type = payload.requirement_type.unwrap_or_else(|| "stat".into());
|
||||
let req_key = payload.requirement_key.unwrap_or_else(|| "blocks_broken".into());
|
||||
let req_val = payload.requirement_value.unwrap_or(50);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE achievements SET
|
||||
title = ?,
|
||||
@@ -270,9 +291,9 @@ pub async fn admin_update_achievement(
|
||||
.bind(payload.icon_item)
|
||||
.bind(payload.icon_bg)
|
||||
.bind(payload.icon_border)
|
||||
.bind(payload.requirement_type)
|
||||
.bind(payload.requirement_key)
|
||||
.bind(payload.requirement_value)
|
||||
.bind(req_type)
|
||||
.bind(req_key)
|
||||
.bind(req_val)
|
||||
.bind(payload.xp_reward)
|
||||
.bind(payload.secret)
|
||||
.bind(&id)
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Per-server economy system, marketplace, transactions, and baltop.
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::routes::servers::GameServer;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEconomyBalance};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launcher & Public Economy API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Get current user's economy balances across all connected game servers.
|
||||
pub async fn get_my_balances(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<ServerEconomyBalance>>> {
|
||||
let rows: Vec<(i64, String, f64)> = sqlx::query_as(
|
||||
"SELECT se.server_id, gs.name, se.balance
|
||||
FROM server_economy se
|
||||
JOIN game_servers gs ON gs.id = se.server_id
|
||||
WHERE se.uuid = ?
|
||||
ORDER BY se.balance DESC",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(|(sid, sname, bal)| ServerEconomyBalance {
|
||||
server_id: sid,
|
||||
server_name: sname,
|
||||
balance: bal,
|
||||
currency_symbol: "$".into(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Get current user's recent transactions across all servers.
|
||||
pub async fn get_my_transactions(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<EconomyTransaction>>> {
|
||||
let rows: Vec<(i64, i64, String, String, String, String, String, f64, String, String)> = sqlx::query_as(
|
||||
"SELECT et.id, et.server_id, gs.name, et.from_uuid, et.from_name, et.to_uuid, et.to_name, et.amount, et.description, et.created_at
|
||||
FROM economy_transactions et
|
||||
JOIN game_servers gs ON gs.id = et.server_id
|
||||
WHERE et.from_uuid = ? OR et.to_uuid = ?
|
||||
ORDER BY et.id DESC
|
||||
LIMIT 50",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(|(id, sid, sname, fuuid, fname, tuuid, tname, amount, desc, created)| EconomyTransaction {
|
||||
id,
|
||||
server_id: sid,
|
||||
server_name: Some(sname),
|
||||
from_uuid: fuuid,
|
||||
from_name: fname,
|
||||
to_uuid: tuuid,
|
||||
to_name: tname,
|
||||
amount,
|
||||
description: desc,
|
||||
created_at: created,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Get top richest players for a specific server.
|
||||
pub async fn get_server_baltop(
|
||||
Path(server_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<BaltopEntry>>> {
|
||||
let rows: Vec<(String, String, f64)> = sqlx::query_as(
|
||||
"SELECT uuid, username, balance
|
||||
FROM server_economy
|
||||
WHERE server_id = ?
|
||||
ORDER BY balance DESC
|
||||
LIMIT 25",
|
||||
)
|
||||
.bind(server_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (uuid, username, balance))| BaltopEntry {
|
||||
rank: i + 1,
|
||||
uuid,
|
||||
username,
|
||||
balance,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Get active marketplace listings for a server.
|
||||
pub async fn get_server_market(
|
||||
Path(server_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<MarketListing>>> {
|
||||
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String)> = sqlx::query_as(
|
||||
"SELECT id, server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at
|
||||
FROM server_market
|
||||
WHERE server_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 100",
|
||||
)
|
||||
.bind(server_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(|(id, sid, suuid, sname, iid, iname, amt, price, created)| MarketListing {
|
||||
id,
|
||||
server_id: sid,
|
||||
seller_uuid: suuid,
|
||||
seller_name: sname,
|
||||
item_id: iid,
|
||||
item_name: iname,
|
||||
amount: amt,
|
||||
price,
|
||||
created_at: created,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server Integration Relay Endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ServerBalanceQuery {
|
||||
pub uuid: String,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn server_get_balance(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ServerBalanceQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let username = payload.username.unwrap_or_else(|| "Player".into());
|
||||
|
||||
let bal_opt: Option<f64> = sqlx::query_scalar(
|
||||
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let balance = match bal_opt {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
let starting_balance = 1000.0;
|
||||
sqlx::query(
|
||||
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(server_id, uuid) DO UPDATE SET username = excluded.username",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.uuid)
|
||||
.bind(&username)
|
||||
.bind(starting_balance)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
starting_balance
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"server_id": server.id,
|
||||
"uuid": payload.uuid,
|
||||
"balance": balance,
|
||||
"currency_symbol": "$"
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ServerTransferPayload {
|
||||
pub from_uuid: String,
|
||||
pub from_name: String,
|
||||
pub to_uuid: String,
|
||||
pub to_name: String,
|
||||
pub amount: f64,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn server_transfer(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ServerTransferPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if payload.amount <= 0.0 {
|
||||
return Err(AppError::bad_request("Transfer amount must be positive"));
|
||||
}
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Check sender balance
|
||||
let from_bal: Option<f64> = sqlx::query_scalar(
|
||||
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.from_uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let current_from_bal = from_bal.unwrap_or(0.0);
|
||||
if current_from_bal < payload.amount {
|
||||
return Err(AppError::bad_request("Insufficient funds"));
|
||||
}
|
||||
|
||||
let new_from_bal = current_from_bal - payload.amount;
|
||||
sqlx::query("UPDATE server_economy SET balance = ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
|
||||
.bind(new_from_bal)
|
||||
.bind(&now)
|
||||
.bind(server.id)
|
||||
.bind(&payload.from_uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Update receiver
|
||||
sqlx::query(
|
||||
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (server_id, uuid) DO UPDATE SET
|
||||
balance = balance + excluded.balance,
|
||||
username = excluded.username,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.to_uuid)
|
||||
.bind(&payload.to_name)
|
||||
.bind(payload.amount)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let desc = payload.description.unwrap_or_else(|| format!("Payment to {}", payload.to_name));
|
||||
sqlx::query(
|
||||
"INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.from_uuid)
|
||||
.bind(&payload.from_name)
|
||||
.bind(&payload.to_uuid)
|
||||
.bind(&payload.to_name)
|
||||
.bind(payload.amount)
|
||||
.bind(&desc)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"from_balance": new_from_bal
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ServerSyncBalancePayload {
|
||||
pub uuid: String,
|
||||
pub username: String,
|
||||
pub new_balance: f64,
|
||||
}
|
||||
|
||||
pub async fn server_sync_balance(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ServerSyncBalancePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(server_id, uuid) DO UPDATE SET
|
||||
balance = excluded.balance,
|
||||
username = excluded.username,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.uuid)
|
||||
.bind(&payload.username)
|
||||
.bind(payload.new_balance)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn server_baltop(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<BaltopEntry>>> {
|
||||
let rows: Vec<(String, String, f64)> = sqlx::query_as(
|
||||
"SELECT uuid, username, balance
|
||||
FROM server_economy
|
||||
WHERE server_id = ?
|
||||
ORDER BY balance DESC
|
||||
LIMIT 20",
|
||||
)
|
||||
.bind(server.id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (uuid, username, balance))| BaltopEntry {
|
||||
rank: i + 1,
|
||||
uuid,
|
||||
username,
|
||||
balance,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarketListPayload {
|
||||
pub seller_uuid: String,
|
||||
pub seller_name: String,
|
||||
pub item_id: String,
|
||||
pub item_name: String,
|
||||
pub amount: i32,
|
||||
pub price: f64,
|
||||
}
|
||||
|
||||
pub async fn server_market_list(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<MarketListPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(payload.seller_uuid)
|
||||
.bind(payload.seller_name)
|
||||
.bind(payload.item_id)
|
||||
.bind(payload.item_name)
|
||||
.bind(payload.amount)
|
||||
.bind(payload.price)
|
||||
.bind(&now)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarketBuyPayload {
|
||||
pub listing_id: i64,
|
||||
pub buyer_uuid: String,
|
||||
pub buyer_name: String,
|
||||
}
|
||||
|
||||
pub async fn server_market_buy(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<MarketBuyPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let listing: Option<(String, String, String, String, i32, f64)> = sqlx::query_as(
|
||||
"SELECT seller_uuid, seller_name, item_id, item_name, amount, price
|
||||
FROM server_market
|
||||
WHERE id = ? AND server_id = ?",
|
||||
)
|
||||
.bind(payload.listing_id)
|
||||
.bind(server.id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some((seller_uuid, seller_name, item_id, item_name, amount, price)) = listing else {
|
||||
return Err(AppError::not_found("Listing not found"));
|
||||
};
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Deduct buyer
|
||||
let buyer_bal: f64 = sqlx::query_scalar(
|
||||
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.buyer_uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(0.0);
|
||||
|
||||
if buyer_bal < price {
|
||||
return Err(AppError::bad_request("Insufficient funds"));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE server_economy SET balance = balance - ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
|
||||
.bind(price)
|
||||
.bind(&now)
|
||||
.bind(server.id)
|
||||
.bind(&payload.buyer_uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Pay seller
|
||||
sqlx::query(
|
||||
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (server_id, uuid) DO UPDATE SET
|
||||
balance = balance + excluded.balance,
|
||||
username = excluded.username,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&seller_uuid)
|
||||
.bind(&seller_name)
|
||||
.bind(price)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Delete listing
|
||||
sqlx::query("DELETE FROM server_market WHERE id = ?")
|
||||
.bind(payload.listing_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Log transaction
|
||||
let desc = format!("Market purchase: {}x {} from {}", amount, item_name, seller_name);
|
||||
sqlx::query(
|
||||
"INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.buyer_uuid)
|
||||
.bind(&payload.buyer_name)
|
||||
.bind(&seller_uuid)
|
||||
.bind(&seller_name)
|
||||
.bind(price)
|
||||
.bind(&desc)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"item_id": item_id,
|
||||
"item_name": item_name,
|
||||
"amount": amount,
|
||||
"price": price
|
||||
})))
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Guilds system, in-game relay, roles, posts, and land-claiming.
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::routes::servers::GameServer;
|
||||
use crate::state::AppState;
|
||||
@@ -710,10 +710,11 @@ pub async fn unclaim_by_id(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChunkGridQuery {
|
||||
pub server_id: i64,
|
||||
pub server_id: Option<i64>,
|
||||
pub instance_id: Option<String>,
|
||||
pub dimension: Option<String>,
|
||||
pub center_x: i32,
|
||||
pub center_z: i32,
|
||||
pub center_x: Option<i32>,
|
||||
pub center_z: Option<i32>,
|
||||
pub radius: Option<i32>,
|
||||
}
|
||||
|
||||
@@ -723,23 +724,31 @@ pub async fn get_chunk_grid(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<GuildClaim>>> {
|
||||
let dim = query.dimension.unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let r = query.radius.unwrap_or(8).clamp(2, 24);
|
||||
let min_x = query.center_x - r;
|
||||
let max_x = query.center_x + r;
|
||||
let min_z = query.center_z - r;
|
||||
let max_z = query.center_z + r;
|
||||
let inst_id = query.instance_id.unwrap_or_default();
|
||||
let cx = query.center_x.unwrap_or(0);
|
||||
let cz = query.center_z.unwrap_or(0);
|
||||
let r = query.radius.unwrap_or(32).clamp(2, 1024);
|
||||
let min_x = cx - r;
|
||||
let max_x = cx + r;
|
||||
let min_z = cz - r;
|
||||
let max_z = cz + r;
|
||||
|
||||
let rows: Vec<(i64, String, String, String, i64, String, i32, i32, String, String)> = sqlx::query_as(
|
||||
let rows: Vec<(i64, String, String, String, Option<i64>, String, i32, i32, String, String)> = sqlx::query_as(
|
||||
"SELECT gc.id, gc.guild_id, g.name, g.tag, gc.server_id, gc.dimension,
|
||||
gc.chunk_x, gc.chunk_z, gc.claimed_by_uuid, gc.claimed_at
|
||||
FROM guild_claims gc
|
||||
JOIN guilds g ON g.id = gc.guild_id
|
||||
WHERE gc.server_id = ? AND gc.dimension = ?
|
||||
WHERE (? IS NULL OR gc.server_id = ?)
|
||||
AND (? = '' OR g.instance_id = ?)
|
||||
AND gc.dimension = ?
|
||||
AND gc.chunk_x BETWEEN ? AND ?
|
||||
AND gc.chunk_z BETWEEN ? AND ?
|
||||
LIMIT 500",
|
||||
LIMIT 1000",
|
||||
)
|
||||
.bind(query.server_id)
|
||||
.bind(query.server_id)
|
||||
.bind(&inst_id)
|
||||
.bind(&inst_id)
|
||||
.bind(&dim)
|
||||
.bind(min_x)
|
||||
.bind(max_x)
|
||||
@@ -756,7 +765,7 @@ pub async fn get_chunk_grid(
|
||||
guild_id: gid,
|
||||
guild_name: gname,
|
||||
guild_tag: gtag,
|
||||
server_id: sid,
|
||||
server_id: sid.unwrap_or(0),
|
||||
dimension: gdim,
|
||||
chunk_x: cx,
|
||||
chunk_z: cz,
|
||||
@@ -769,6 +778,106 @@ pub async fn get_chunk_grid(
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Admin list all guilds.
|
||||
pub async fn admin_list_guilds(
|
||||
_admin: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<Guild>>> {
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT g.id, g.instance_id, g.name, g.tag, g.description, g.motd, g.leader_uuid,
|
||||
g.icon_url, g.banner_url, g.level, g.xp, g.max_claims, g.created_at,
|
||||
(SELECT COUNT(*) FROM guild_members gm WHERE gm.guild_id = g.id) as member_count,
|
||||
(SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id) as claims_count
|
||||
FROM guilds g
|
||||
ORDER BY g.level DESC, member_count DESC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
id,
|
||||
inst_id,
|
||||
name,
|
||||
tag,
|
||||
desc,
|
||||
motd,
|
||||
leader,
|
||||
icon,
|
||||
banner,
|
||||
lvl,
|
||||
xp,
|
||||
max_c,
|
||||
created,
|
||||
members,
|
||||
claims,
|
||||
)| Guild {
|
||||
id,
|
||||
instance_id: inst_id,
|
||||
name,
|
||||
tag,
|
||||
description: desc,
|
||||
motd,
|
||||
leader_uuid: leader,
|
||||
icon_url: icon,
|
||||
banner_url: banner,
|
||||
level: lvl,
|
||||
xp,
|
||||
max_claims: max_c,
|
||||
member_count: members,
|
||||
claims_count: claims,
|
||||
created_at: created,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Admin delete a guild.
|
||||
pub async fn admin_delete_guild(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM guild_claims WHERE guild_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM guild_members WHERE guild_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM guild_posts WHERE guild_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM guilds WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server Integration / Plugin In-Game Land Claim Relay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -827,3 +936,307 @@ pub async fn server_check_chunk(
|
||||
"message": if is_member { None } else { Some(format!("This land is claimed by [{guild_tag}] {guild_name}")) }
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ServerClaimChunkPayload {
|
||||
pub uuid: String,
|
||||
pub dimension: String,
|
||||
pub chunk_x: i32,
|
||||
pub chunk_z: i32,
|
||||
}
|
||||
|
||||
pub async fn server_claim_chunk(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ServerClaimChunkPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let member: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT guild_id, role FROM guild_members WHERE uuid = ?",
|
||||
)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some((guild_id, _role)) = member else {
|
||||
return Err(AppError::bad_request("You must be in a guild to claim land. Create one with /guild create <name> <tag>"));
|
||||
};
|
||||
|
||||
let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(16);
|
||||
|
||||
let current_claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
if current_claims >= max_claims {
|
||||
return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks")));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO guild_claims (guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(server.id)
|
||||
.bind(&payload.dimension)
|
||||
.bind(payload.chunk_x)
|
||||
.bind(payload.chunk_z)
|
||||
.bind(&payload.uuid)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
if let Err(e) = res {
|
||||
tracing::warn!("server claim chunk failed: {e}");
|
||||
return Err(AppError::bad_request("Chunk is already claimed by another guild"));
|
||||
}
|
||||
|
||||
let guild_name: String = sqlx::query_scalar("SELECT name FROM guilds WHERE id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or_else(|_| "Guild".into());
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"guild_id": guild_id,
|
||||
"guild_name": guild_name,
|
||||
"chunk_x": payload.chunk_x,
|
||||
"chunk_z": payload.chunk_z
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn server_unclaim_chunk(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ServerClaimChunkPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let claim: Option<(i64, String)> = sqlx::query_as(
|
||||
"SELECT id, guild_id FROM guild_claims WHERE server_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.dimension)
|
||||
.bind(payload.chunk_x)
|
||||
.bind(payload.chunk_z)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some((claim_id, guild_id)) = claim else {
|
||||
return Err(AppError::not_found("This chunk is not claimed"));
|
||||
};
|
||||
|
||||
let member: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if member.is_none() {
|
||||
return Err(AppError::forbidden("You cannot unclaim land belonging to another guild"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM guild_claims WHERE id = ?")
|
||||
.bind(claim_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ServerGuildPlayerQuery {
|
||||
pub uuid: String,
|
||||
}
|
||||
|
||||
pub async fn server_get_player_guild(
|
||||
GameServer(_server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ServerGuildPlayerQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let member_opt: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT guild_id, role FROM guild_members WHERE uuid = ?",
|
||||
)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some((guild_id, player_role)) = member_opt else {
|
||||
return Ok(Json(serde_json::json!({ "in_guild": false })));
|
||||
};
|
||||
|
||||
let guild_opt: Option<(String, String, String, String, String, i64, i64, i64)> = sqlx::query_as(
|
||||
"SELECT name, tag, description, motd, leader_uuid, level, xp, max_claims FROM guilds WHERE id = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some((name, tag, desc, motd, leader_uuid, level, xp, max_claims)) = guild_opt else {
|
||||
return Ok(Json(serde_json::json!({ "in_guild": false })));
|
||||
};
|
||||
|
||||
let member_rows: Vec<(String, String, String)> = sqlx::query_as(
|
||||
"SELECT uuid, name, role FROM guild_members WHERE guild_id = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let claims_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let members_val: Vec<Value> = member_rows
|
||||
.into_iter()
|
||||
.map(|(u, n, r)| serde_json::json!({ "uuid": u, "name": n, "role": r }))
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"in_guild": true,
|
||||
"guild": {
|
||||
"id": guild_id,
|
||||
"name": name,
|
||||
"tag": tag,
|
||||
"description": desc,
|
||||
"motd": motd,
|
||||
"leader_uuid": leader_uuid,
|
||||
"role": player_role,
|
||||
"level": level,
|
||||
"xp": xp,
|
||||
"claims_count": claims_count,
|
||||
"max_claims": max_claims,
|
||||
"members": members_val
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ServerCreateGuildPayload {
|
||||
pub uuid: String,
|
||||
pub username: String,
|
||||
pub name: String,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
pub async fn server_create_guild(
|
||||
GameServer(_server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ServerCreateGuildPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let name = payload.name.trim();
|
||||
let tag = payload.tag.trim();
|
||||
if name.len() < 3 || name.len() > 32 {
|
||||
return Err(AppError::bad_request("Guild name must be between 3 and 32 characters"));
|
||||
}
|
||||
if tag.len() < 2 || tag.len() > 6 {
|
||||
return Err(AppError::bad_request("Guild tag must be between 2 and 6 characters"));
|
||||
}
|
||||
|
||||
let in_guild: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members WHERE uuid = ?)")
|
||||
.bind(&payload.uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if in_guild {
|
||||
return Err(AppError::bad_request("You are already in a guild. Leave your current guild first"));
|
||||
}
|
||||
|
||||
let guild_id = format!("guild_{}", uuid::Uuid::new_v4().simple());
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, created_at)
|
||||
VALUES (?, '', ?, ?, '', 'Welcome to the guild!', ?, ?)",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(name)
|
||||
.bind(tag)
|
||||
.bind(&payload.uuid)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
if let Err(e) = res {
|
||||
tracing::warn!("server_create_guild failed: {e}");
|
||||
return Err(AppError::bad_request("A guild with that name or tag already exists"));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO guild_members (guild_id, uuid, name, role, joined_at)
|
||||
VALUES (?, ?, ?, 'leader', ?)",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.uuid)
|
||||
.bind(&payload.username)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"guild_id": guild_id,
|
||||
"name": name,
|
||||
"tag": tag
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ServerGuildLeavePayload {
|
||||
pub uuid: String,
|
||||
}
|
||||
|
||||
pub async fn server_guild_leave(
|
||||
GameServer(_server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ServerGuildLeavePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let member_opt: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT guild_id, role FROM guild_members WHERE uuid = ?",
|
||||
)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some((guild_id, role)) = member_opt else {
|
||||
return Err(AppError::bad_request("You are not in a guild"));
|
||||
};
|
||||
|
||||
if role == "leader" {
|
||||
let member_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_members WHERE guild_id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(1);
|
||||
|
||||
if member_count > 1 {
|
||||
return Err(AppError::bad_request("Guild leader cannot leave without transferring leadership"));
|
||||
} else {
|
||||
sqlx::query("DELETE FROM guild_claims WHERE guild_id = ?").bind(&guild_id).execute(&state.db).await?;
|
||||
sqlx::query("DELETE FROM guild_members WHERE guild_id = ?").bind(&guild_id).execute(&state.db).await?;
|
||||
sqlx::query("DELETE FROM guilds WHERE id = ?").bind(&guild_id).execute(&state.db).await?;
|
||||
return Ok(Json(serde_json::json!({ "ok": true, "disbanded": true })));
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "disbanded": false })))
|
||||
}
|
||||
@@ -63,6 +63,7 @@ pub struct LandingBlock {
|
||||
pub enabled: bool,
|
||||
pub title: Option<String>,
|
||||
pub subtitle: Option<String>,
|
||||
pub options: serde_json::Value,
|
||||
}
|
||||
|
||||
impl Default for LandingBlock {
|
||||
@@ -73,6 +74,7 @@ impl Default for LandingBlock {
|
||||
enabled: true,
|
||||
title: None,
|
||||
subtitle: None,
|
||||
options: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,15 +117,15 @@ impl Default for LandingConfig {
|
||||
hosted_downloads: vec![],
|
||||
external_download_url: None,
|
||||
blocks: vec![
|
||||
LandingBlock { id: "b_hero".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None },
|
||||
LandingBlock { id: "b_download".into(), block_type: "download".into(), enabled: true, title: Some("Get the Launcher".into()), subtitle: Some("Available on Windows, macOS, and Linux".into()) },
|
||||
LandingBlock { id: "b_servers".into(), block_type: "servers".into(), enabled: true, title: Some("Live Servers".into()), subtitle: Some("Check our current status and join the game".into()) },
|
||||
LandingBlock { id: "b_stats".into(), block_type: "stats".into(), enabled: true, title: Some("Community By Numbers".into()), subtitle: Some("Every block placed, every second played".into()) },
|
||||
LandingBlock { id: "b_leaderboard".into(), block_type: "leaderboard".into(), enabled: true, title: Some("Top Players".into()), subtitle: Some("The champions of our network".into()) },
|
||||
LandingBlock { id: "b_instances".into(), block_type: "instances".into(), enabled: true, title: Some("Featured Instances".into()), subtitle: Some("Hand-crafted modpacks ready to launch".into()) },
|
||||
LandingBlock { id: "b_news".into(), block_type: "news".into(), enabled: true, title: Some("Latest News".into()), subtitle: Some("Updates, events, and patch notes".into()) },
|
||||
LandingBlock { id: "b_faq".into(), block_type: "faq".into(), enabled: true, title: Some("Frequently Asked Questions".into()), subtitle: Some("Everything you need to know".into()) },
|
||||
LandingBlock { id: "b_socials".into(), block_type: "socials".into(), enabled: true, title: Some("Join Our Community".into()), subtitle: Some("Connect with thousands of players".into()) },
|
||||
LandingBlock { id: "b_hero".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None, options: serde_json::json!({}) },
|
||||
LandingBlock { id: "b_download".into(), block_type: "download".into(), enabled: true, title: Some("Get the Launcher".into()), subtitle: Some("Available on Windows, macOS, and Linux".into()), options: serde_json::json!({}) },
|
||||
LandingBlock { id: "b_servers".into(), block_type: "servers".into(), enabled: true, title: Some("Live Servers".into()), subtitle: Some("Check our current status and join the game".into()), options: serde_json::json!({}) },
|
||||
LandingBlock { id: "b_stats".into(), block_type: "stats".into(), enabled: true, title: Some("Community By Numbers".into()), subtitle: Some("Every block placed, every second played".into()), options: serde_json::json!({}) },
|
||||
LandingBlock { id: "b_leaderboard".into(), block_type: "leaderboard".into(), enabled: true, title: Some("Top Players".into()), subtitle: Some("The champions of our network".into()), options: serde_json::json!({}) },
|
||||
LandingBlock { id: "b_instances".into(), block_type: "instances".into(), enabled: true, title: Some("Featured Instances".into()), subtitle: Some("Hand-crafted modpacks ready to launch".into()), options: serde_json::json!({}) },
|
||||
LandingBlock { id: "b_news".into(), block_type: "news".into(), enabled: true, title: Some("Latest News".into()), subtitle: Some("Updates, events, and patch notes".into()), options: serde_json::json!({}) },
|
||||
LandingBlock { id: "b_faq".into(), block_type: "faq".into(), enabled: true, title: Some("Frequently Asked Questions".into()), subtitle: Some("Everything you need to know".into()), options: serde_json::json!({}) },
|
||||
LandingBlock { id: "b_socials".into(), block_type: "socials".into(), enabled: true, title: Some("Join Our Community".into()), subtitle: Some("Connect with thousands of players".into()), options: serde_json::json!({}) },
|
||||
],
|
||||
faqs: vec![
|
||||
FaqItem {
|
||||
|
||||
@@ -103,16 +103,56 @@ pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Query rewards
|
||||
let rewards_rows: Vec<(i64, i64, String, String, String)> = sqlx::query_as(
|
||||
"SELECT id, level_req, reward_type, reward_name, reward_data
|
||||
FROM level_rewards
|
||||
ORDER BY level_req ASC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let rewards = rewards_rows
|
||||
.into_iter()
|
||||
.map(|(id, req, rtype, rname, rdata)| {
|
||||
let data: Value = serde_json::from_str(&rdata).unwrap_or_default();
|
||||
let desc = data.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let icon = data.get("icon").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
scopenet_shared::LevelReward {
|
||||
id,
|
||||
level: req,
|
||||
reward_type: rtype,
|
||||
reward_value: rname,
|
||||
description: desc,
|
||||
icon,
|
||||
server_id: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rank_opt: Option<i64> = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) + 1 FROM user_levels WHERE global_xp > ?",
|
||||
)
|
||||
.bind(global_xp)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.unwrap_or(Some(1));
|
||||
|
||||
Ok(UserLevelInfo {
|
||||
uuid: uuid.to_string(),
|
||||
global_xp,
|
||||
current_xp: global_xp,
|
||||
global_level,
|
||||
level: global_level,
|
||||
current_level_xp,
|
||||
next_level_xp,
|
||||
progress_pct,
|
||||
title,
|
||||
badges,
|
||||
server_levels,
|
||||
rank: rank_opt.map(|r| r as usize),
|
||||
rewards,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -168,13 +208,32 @@ pub async fn list_rewards(
|
||||
.into_iter()
|
||||
.map(|(id, ltype, sid, req, rtype, name, data, created)| {
|
||||
let parsed_data: Value = serde_json::from_str(&data).unwrap_or_default();
|
||||
let desc = parsed_data.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let icon = parsed_data.get("icon")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(match rtype.as_str() {
|
||||
"title" => "👑",
|
||||
"profile_badge" | "badge" => "⭐",
|
||||
"cosmetic" => "✨",
|
||||
"item" => "🗡️",
|
||||
_ => "🎁",
|
||||
})
|
||||
.to_string();
|
||||
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"level": req,
|
||||
"level_req": req,
|
||||
"level_type": ltype,
|
||||
"server_id": sid,
|
||||
"level_req": req,
|
||||
"reward_type": rtype,
|
||||
"reward_value": name,
|
||||
"reward_name": name,
|
||||
"description": if desc.is_empty() { format!("Granted at Level {req}") } else { desc },
|
||||
"icon": icon,
|
||||
"reward_data": parsed_data,
|
||||
"created_at": created
|
||||
})
|
||||
@@ -185,12 +244,17 @@ pub async fn list_rewards(
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateRewardPayload {
|
||||
pub level_type: String,
|
||||
pub struct RewardPayload {
|
||||
pub id: Option<i64>,
|
||||
pub level_type: Option<String>,
|
||||
pub server_id: Option<i64>,
|
||||
pub level_req: i64,
|
||||
#[serde(alias = "level")]
|
||||
pub level_req: Option<i64>,
|
||||
pub reward_type: String,
|
||||
pub reward_name: String,
|
||||
#[serde(alias = "reward_value")]
|
||||
pub reward_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub reward_data: Option<Value>,
|
||||
}
|
||||
|
||||
@@ -198,21 +262,36 @@ pub struct CreateRewardPayload {
|
||||
pub async fn admin_create_reward(
|
||||
_admin: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateRewardPayload>,
|
||||
Json(payload): Json<RewardPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let data_str = serde_json::to_string(&payload.reward_data.unwrap_or_default()).unwrap_or_default();
|
||||
let level_req = payload.level_req.unwrap_or(2);
|
||||
let reward_name = payload.reward_name.unwrap_or_else(|| "Reward".into());
|
||||
let level_type = payload.level_type.unwrap_or_else(|| "global".into());
|
||||
|
||||
let mut data_obj = match payload.reward_data {
|
||||
Some(Value::Object(map)) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
if let Some(desc) = payload.description {
|
||||
data_obj.insert("description".into(), Value::String(desc));
|
||||
}
|
||||
if let Some(ico) = payload.icon {
|
||||
data_obj.insert("icon".into(), Value::String(ico));
|
||||
}
|
||||
data_obj.insert("title".into(), Value::String(reward_name.clone()));
|
||||
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
|
||||
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO level_rewards (level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(payload.level_type)
|
||||
.bind(level_type)
|
||||
.bind(payload.server_id)
|
||||
.bind(payload.level_req)
|
||||
.bind(level_req)
|
||||
.bind(payload.reward_type)
|
||||
.bind(payload.reward_name)
|
||||
.bind(reward_name)
|
||||
.bind(data_str)
|
||||
.bind(&now)
|
||||
.fetch_one(&state.db)
|
||||
@@ -221,6 +300,48 @@ pub async fn admin_create_reward(
|
||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||
}
|
||||
|
||||
/// Admin update reward.
|
||||
pub async fn admin_update_reward(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RewardPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let level_req = payload.level_req.unwrap_or(2);
|
||||
let reward_name = payload.reward_name.unwrap_or_else(|| "Reward".into());
|
||||
|
||||
let mut data_obj = match payload.reward_data {
|
||||
Some(Value::Object(map)) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
if let Some(desc) = payload.description {
|
||||
data_obj.insert("description".into(), Value::String(desc));
|
||||
}
|
||||
if let Some(ico) = payload.icon {
|
||||
data_obj.insert("icon".into(), Value::String(ico));
|
||||
}
|
||||
data_obj.insert("title".into(), Value::String(reward_name.clone()));
|
||||
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE level_rewards SET
|
||||
level_req = ?,
|
||||
reward_type = ?,
|
||||
reward_name = ?,
|
||||
reward_data = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(level_req)
|
||||
.bind(payload.reward_type)
|
||||
.bind(reward_name)
|
||||
.bind(data_str)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||
}
|
||||
|
||||
/// Admin delete reward.
|
||||
pub async fn admin_delete_reward(
|
||||
_admin: AdminUser,
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod account;
|
||||
pub mod achievements;
|
||||
pub mod activity;
|
||||
pub mod admin;
|
||||
pub mod economy;
|
||||
pub mod guilds;
|
||||
pub mod landing;
|
||||
pub mod leveling;
|
||||
@@ -23,6 +24,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/launcher/manifest", get(public::manifest))
|
||||
.route("/launcher/instances/{id}", get(public::instance_manifest))
|
||||
.route("/launcher/events", post(public::event))
|
||||
.route("/instances", get(admin::list_instances))
|
||||
.route("/auth/login", post(public::login))
|
||||
.route("/auth/register", post(public::register))
|
||||
.route("/auth/me", get(public::me))
|
||||
@@ -65,7 +67,13 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/guilds/{id}/claims", post(guilds::claim_chunk).delete(guilds::unclaim_chunk))
|
||||
.route("/guilds/{id}/claim", post(guilds::claim_chunk))
|
||||
.route("/guilds/{id}/unclaim", post(guilds::unclaim_chunk))
|
||||
// Social: Friends, DMs, Invites, Profiles, Posts
|
||||
// Economy
|
||||
.route("/economy/me", get(economy::get_my_balances))
|
||||
.route("/economy/balances", get(economy::get_my_balances))
|
||||
.route("/economy/transactions", get(economy::get_my_transactions))
|
||||
.route("/servers/{id}/economy/baltop", get(economy::get_server_baltop))
|
||||
.route("/servers/{id}/economy/market", get(economy::get_server_market))
|
||||
// Social: Friends, DMs, Invites, Profiles, Posts, Members Search
|
||||
.route("/friends", get(social::list_friends))
|
||||
.route("/friends/request", post(social::send_friend_request))
|
||||
.route("/friends/respond", post(social::respond_friend_request))
|
||||
@@ -87,6 +95,8 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/social/profile/{uuid}", get(social::get_player_profile))
|
||||
.route("/social/posts", post(social::create_user_post))
|
||||
.route("/social/posts/{id}/like", post(social::like_user_post))
|
||||
.route("/social/members", get(social::search_members))
|
||||
.route("/members/search", get(social::search_members))
|
||||
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
|
||||
|
||||
let admin = Router::new()
|
||||
@@ -124,13 +134,17 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/modrinth/project/{id}/versions", get(meta::modrinth_versions))
|
||||
.route("/curseforge/search", get(meta::curseforge_search))
|
||||
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
|
||||
// Admin Quests & Achievements & Rewards
|
||||
.route("/rewards", post(leveling::admin_create_reward))
|
||||
.route("/rewards/{id}", delete(leveling::admin_delete_reward))
|
||||
// Admin Quests & Achievements & Rewards & Guilds
|
||||
.route("/rewards", get(leveling::list_rewards).post(leveling::admin_create_reward))
|
||||
.route("/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward))
|
||||
.route("/levels/rewards", get(leveling::list_rewards).post(leveling::admin_create_reward))
|
||||
.route("/levels/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward))
|
||||
.route("/quests", get(quests::admin_list_quests).post(quests::admin_create_quest))
|
||||
.route("/quests/{id}", put(quests::admin_update_quest).delete(quests::admin_delete_quest))
|
||||
.route("/achievements", get(achievements::admin_list_achievements).post(achievements::admin_create_achievement))
|
||||
.route("/achievements/{id}", put(achievements::admin_update_achievement).delete(achievements::admin_delete_achievement))
|
||||
.route("/guilds", get(guilds::admin_list_guilds))
|
||||
.route("/guilds/{id}", delete(guilds::admin_delete_guild))
|
||||
.layer(DefaultBodyLimit::max(upload_limit));
|
||||
|
||||
// Called by the SCOPENET Paper plugin and Fabric/Forge mods.
|
||||
@@ -139,6 +153,18 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/login", post(servers::login))
|
||||
.route("/sync", post(servers::sync))
|
||||
.route("/guilds/check-chunk", post(guilds::server_check_chunk))
|
||||
.route("/guilds/claim", post(guilds::server_claim_chunk))
|
||||
.route("/guilds/unclaim", post(guilds::server_unclaim_chunk))
|
||||
.route("/guilds/player", post(guilds::server_get_player_guild))
|
||||
.route("/guilds/create", post(guilds::server_create_guild))
|
||||
.route("/guilds/leave", post(guilds::server_guild_leave))
|
||||
.route("/economy/balance", post(economy::server_get_balance))
|
||||
.route("/economy/pay", post(economy::server_transfer))
|
||||
.route("/economy/transfer", post(economy::server_transfer))
|
||||
.route("/economy/sync-balance", post(economy::server_sync_balance))
|
||||
.route("/economy/baltop", post(economy::server_baltop))
|
||||
.route("/economy/market/list", post(economy::server_market_list))
|
||||
.route("/economy/market/buy", post(economy::server_market_buy))
|
||||
.layer(DefaultBodyLimit::max(2 * 1024 * 1024));
|
||||
|
||||
Router::new()
|
||||
|
||||
@@ -92,18 +92,23 @@ pub async fn get_my_quests(
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
period,
|
||||
period: period.clone(),
|
||||
category: cat,
|
||||
target_stat: stat,
|
||||
target_stat: stat.clone(),
|
||||
target_count: target,
|
||||
xp_reward: xp,
|
||||
icon,
|
||||
enabled,
|
||||
quest_type: Some(period),
|
||||
stat_type: Some(stat),
|
||||
active: Some(enabled),
|
||||
},
|
||||
progress,
|
||||
current_count: progress,
|
||||
completed,
|
||||
claimed,
|
||||
period_key: pkey,
|
||||
expires_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -259,13 +264,16 @@ pub async fn admin_list_quests(
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
period,
|
||||
period: period.clone(),
|
||||
category: cat,
|
||||
target_stat: stat,
|
||||
target_stat: stat.clone(),
|
||||
target_count: target,
|
||||
xp_reward: xp,
|
||||
icon,
|
||||
enabled,
|
||||
quest_type: Some(period),
|
||||
stat_type: Some(stat),
|
||||
active: Some(enabled),
|
||||
},
|
||||
total_completions: completions,
|
||||
}
|
||||
@@ -281,12 +289,15 @@ pub struct QuestPayload {
|
||||
pub id: Option<String>,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub period: String,
|
||||
#[serde(alias = "quest_type")]
|
||||
pub period: Option<String>,
|
||||
pub category: String,
|
||||
pub target_stat: String,
|
||||
#[serde(alias = "stat_type")]
|
||||
pub target_stat: Option<String>,
|
||||
pub target_count: i64,
|
||||
pub xp_reward: i64,
|
||||
pub icon: Option<String>,
|
||||
#[serde(alias = "active")]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -296,10 +307,12 @@ pub async fn admin_create_quest(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<QuestPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let period = payload.period.unwrap_or_else(|| "daily".into());
|
||||
let target_stat = payload.target_stat.unwrap_or_else(|| "blocks_broken".into());
|
||||
let id = payload
|
||||
.id
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("q_{}_{}", payload.period, uuid::Uuid::new_v4().simple()));
|
||||
.unwrap_or_else(|| format!("q_{}_{}", period, uuid::Uuid::new_v4().simple()));
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
@@ -309,12 +322,12 @@ pub async fn admin_create_quest(
|
||||
.bind(&id)
|
||||
.bind(payload.title)
|
||||
.bind(payload.description)
|
||||
.bind(payload.period)
|
||||
.bind(period)
|
||||
.bind(payload.category)
|
||||
.bind(payload.target_stat)
|
||||
.bind(target_stat)
|
||||
.bind(payload.target_count)
|
||||
.bind(payload.xp_reward)
|
||||
.bind(payload.icon.unwrap_or_else(|| "star".into()))
|
||||
.bind(payload.icon.unwrap_or_else(|| "⛏️".into()))
|
||||
.bind(payload.enabled.unwrap_or(true))
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
@@ -330,6 +343,9 @@ pub async fn admin_update_quest(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<QuestPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let period = payload.period.unwrap_or_else(|| "daily".into());
|
||||
let target_stat = payload.target_stat.unwrap_or_else(|| "blocks_broken".into());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE quests SET
|
||||
title = ?,
|
||||
@@ -345,9 +361,9 @@ pub async fn admin_update_quest(
|
||||
)
|
||||
.bind(payload.title)
|
||||
.bind(payload.description)
|
||||
.bind(payload.period)
|
||||
.bind(period)
|
||||
.bind(payload.category)
|
||||
.bind(payload.target_stat)
|
||||
.bind(target_stat)
|
||||
.bind(payload.target_count)
|
||||
.bind(payload.xp_reward)
|
||||
.bind(payload.icon)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::Json;
|
||||
use scopenet_shared::{DirectMessage, FriendInfo, GameInvite, UserPost, UserProfileView};
|
||||
use serde::Deserialize;
|
||||
@@ -621,3 +621,72 @@ pub async fn like_user_post(
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Members Search & Discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchMembersQuery {
|
||||
pub q: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn search_members(
|
||||
auth: AuthUser,
|
||||
Query(query): Query<SearchMembersQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<scopenet_shared::MemberProfile>>> {
|
||||
let q = query.q.unwrap_or_default().trim().to_lowercase();
|
||||
let pattern = format!("%{q}%");
|
||||
|
||||
let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<String>, i64, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT u.uuid, u.username, u.role, u.status, u.skin_url,
|
||||
ul.global_level, ul.title, u.playtime_secs, u.last_seen_ingame,
|
||||
f.status as friendship_status
|
||||
FROM users u
|
||||
LEFT JOIN user_levels ul ON ul.uuid = u.uuid
|
||||
LEFT JOIN friendships f ON (f.user_uuid = ? AND f.friend_uuid = u.uuid)
|
||||
WHERE (? = '' OR LOWER(u.username) LIKE ?)
|
||||
ORDER BY (CASE WHEN LOWER(u.username) = ? THEN 1 ELSE 2 END), u.playtime_secs DESC
|
||||
LIMIT 60",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&q)
|
||||
.bind(&pattern)
|
||||
.bind(&q)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let now_ts = chrono::Utc::now().timestamp();
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(|(uuid, username, role, status, skin_url, glvl, title, playtime, last_seen, friend_status)| {
|
||||
let online = if let Some(ref ls) = last_seen {
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ls) {
|
||||
(now_ts - dt.timestamp()).abs() < 180
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let is_friend = friend_status.as_deref() == Some("accepted");
|
||||
scopenet_shared::MemberProfile {
|
||||
uuid,
|
||||
username,
|
||||
role,
|
||||
status,
|
||||
skin_url,
|
||||
global_level: glvl.unwrap_or(1),
|
||||
title,
|
||||
playtime_secs: playtime,
|
||||
last_seen,
|
||||
online,
|
||||
is_friend,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
@@ -163,6 +163,7 @@ export interface LandingBlock {
|
||||
enabled: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
options?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface LandingConfig {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Plus, Pencil, Trash2, Trophy, Sparkles, Shield, Swords, Pickaxe, Compass, Users } from '@lucide/svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { get, post, put, del } from '../lib/api';
|
||||
@@ -61,7 +62,21 @@
|
||||
async function loadAchievements() {
|
||||
loading = true;
|
||||
try {
|
||||
achievements = await get<Achievement[]>('/api/admin/achievements');
|
||||
const res = await get<any[]>('/api/admin/achievements');
|
||||
achievements = (res || []).map((a: any) => ({
|
||||
id: a.id || '',
|
||||
title: a.title || '',
|
||||
description: a.description || '',
|
||||
category: a.category || 'progression',
|
||||
xp_reward: a.xp_reward || 0,
|
||||
frame_type: a.frame_type || a.icon_frame || 'task',
|
||||
icon_item: a.icon_item || 'diamond_pickaxe',
|
||||
icon_bg: a.icon_bg || 'deepslate',
|
||||
icon_border: a.icon_border || 'gold',
|
||||
stat_type: a.stat_type || a.requirement_key || 'blocks_broken',
|
||||
target_count: a.target_count || a.requirement_value || 1,
|
||||
secret: !!a.secret,
|
||||
}));
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
@@ -69,7 +84,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
onMount(() => {
|
||||
loadAchievements();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Shield, Users, MapPin, Trash2, Search, ExternalLink, Flag } from '@lucide/svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { get, del } from '../lib/api';
|
||||
@@ -17,11 +18,28 @@
|
||||
loading = true;
|
||||
try {
|
||||
const [g, inst] = await Promise.all([
|
||||
get<Guild[]>('/api/admin/guilds'),
|
||||
get<Instance[]>('/api/v1/instances'),
|
||||
get<any[]>('/api/admin/guilds').catch(() => []),
|
||||
get<Instance[]>('/api/admin/instances').catch(() => []),
|
||||
]);
|
||||
guilds = g;
|
||||
instances = inst;
|
||||
guilds = (g || []).map((x: any) => ({
|
||||
id: x.id || '',
|
||||
instance_id: x.instance_id || '',
|
||||
name: x.name || 'Unnamed Guild',
|
||||
tag: x.tag || '',
|
||||
description: x.description || '',
|
||||
motd: x.motd || '',
|
||||
leader_uuid: x.leader_uuid || '',
|
||||
icon_url: x.icon_url || null,
|
||||
banner_url: x.banner_url || null,
|
||||
level: x.level || 1,
|
||||
xp: x.xp || 0,
|
||||
max_claims: x.max_claims || 10,
|
||||
member_count: x.member_count ?? 1,
|
||||
max_members: x.max_members || 50,
|
||||
claims_count: x.claims_count ?? 0,
|
||||
created_at: x.created_at || new Date().toISOString(),
|
||||
}));
|
||||
instances = inst || [];
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
@@ -29,7 +47,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
onMount(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Save, LoaderCircle, Plus, Trash2, ArrowUp, ArrowDown, ExternalLink,
|
||||
Upload, Globe, Sparkles, HelpCircle, Server, Trophy, BarChart3,
|
||||
Boxes, Newspaper, MessageSquare, Download, Check, Eye, Code,
|
||||
ChevronDown, ChevronUp, Sliders,
|
||||
} from '@lucide/svelte';
|
||||
import Toggle from '../components/Toggle.svelte';
|
||||
import { api, get, put, formatBytes } from '../lib/api';
|
||||
@@ -14,6 +15,11 @@
|
||||
let saving = $state(false);
|
||||
let uploading = $state(false);
|
||||
let activeTab = $state<'blocks' | 'downloads' | 'faq' | 'css'>('blocks');
|
||||
let openBlocks = $state<Record<string, boolean>>({});
|
||||
|
||||
function toggleBlockOptions(id: string) {
|
||||
openBlocks[id] = !openBlocks[id];
|
||||
}
|
||||
|
||||
const blockMeta: Record<BlockType, { label: string; icon: any; desc: string }> = {
|
||||
hero: { label: 'Hero Header', icon: Sparkles, desc: 'Top visual headline with CTA buttons and particle effects' },
|
||||
@@ -200,39 +206,222 @@
|
||||
{#each config.blocks as block, idx (block.id)}
|
||||
{@const meta = blockMeta[block.type as BlockType] || { label: block.type, icon: Sparkles, desc: '' }}
|
||||
{@const Icon = meta.icon}
|
||||
<div class="block-row" class:disabled={!block.enabled}>
|
||||
<div class="block-reorder">
|
||||
<button class="icon-btn" disabled={idx === 0} onclick={() => moveBlock(idx, -1)} title="Move up">
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button class="icon-btn" disabled={idx === config.blocks.length - 1} onclick={() => moveBlock(idx, 1)} title="Move down">
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{@const isOpen = !!openBlocks[block.id]}
|
||||
<div class="block-item-container" class:disabled={!block.enabled}>
|
||||
<div class="block-row">
|
||||
<div class="block-reorder">
|
||||
<button class="icon-btn" disabled={idx === 0} onclick={() => moveBlock(idx, -1)} title="Move up">
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button class="icon-btn" disabled={idx === config.blocks.length - 1} onclick={() => moveBlock(idx, 1)} title="Move down">
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="block-icon">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div class="block-icon">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
|
||||
<div class="block-info grow">
|
||||
<div class="row align-center gap-xs">
|
||||
<strong>{meta.label}</strong>
|
||||
<span class="block-type-tag tiny muted">{block.type}</span>
|
||||
<div class="block-info grow" role="button" tabindex="0" onclick={() => toggleBlockOptions(block.id)} onkeydown={(e) => e.key === 'Enter' && toggleBlockOptions(block.id)}>
|
||||
<div class="row align-center gap-xs">
|
||||
<strong>{meta.label}</strong>
|
||||
<span class="block-type-tag tiny muted">{block.type}</span>
|
||||
{#if block.title}
|
||||
<span class="badge-sub small muted">"{block.title}"</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="tiny muted">{meta.desc}</span>
|
||||
</div>
|
||||
|
||||
<div class="block-inputs row gap-sm align-center">
|
||||
<button class="btn secondary sm" onclick={() => toggleBlockOptions(block.id)} title="Configure Module Settings">
|
||||
<Sliders size={13} /> {isOpen ? 'Hide Options' : 'Configure'}
|
||||
{#if isOpen}<ChevronUp size={13} />{:else}<ChevronDown size={13} />{/if}
|
||||
</button>
|
||||
<Toggle bind:checked={block.enabled} label="" />
|
||||
</div>
|
||||
<span class="tiny muted">{meta.desc}</span>
|
||||
</div>
|
||||
|
||||
<div class="block-inputs row gap-sm align-center">
|
||||
{#if block.type !== 'hero'}
|
||||
<input
|
||||
bind:value={block.title}
|
||||
placeholder="Section title"
|
||||
class="sm-input"
|
||||
title="Custom section title"
|
||||
/>
|
||||
{/if}
|
||||
<Toggle bind:checked={block.enabled} label="" />
|
||||
</div>
|
||||
{#if isOpen}
|
||||
<div class="block-config-pane">
|
||||
{#if !block.options}
|
||||
{((block.options = {}), '')}
|
||||
{/if}
|
||||
|
||||
<div class="grid-2">
|
||||
<label class="field">
|
||||
<span>Section Title</span>
|
||||
<input bind:value={block.title} placeholder="Custom Section Title" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Section Subtitle</span>
|
||||
<input bind:value={block.subtitle} placeholder="Sub-heading or explanatory text" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if block.type === 'hero'}
|
||||
<div class="module-fields grid-2">
|
||||
<label class="field">
|
||||
<span>Headline Override</span>
|
||||
<input bind:value={block.options.headline} placeholder="Defaults to General Hero Headline" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Top Badge Text</span>
|
||||
<input bind:value={block.options.badge_text} placeholder="e.g. Season 2 Now Live!" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Primary CTA Button Text</span>
|
||||
<input bind:value={block.options.cta_text} placeholder="e.g. Download Launcher" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Custom CTA URL (Overrides auto-download)</span>
|
||||
<input bind:value={block.options.cta_url} placeholder="https://..." />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Show Server IP Copy Button</span>
|
||||
<Toggle bind:checked={block.options.show_ip_copy} label="Enable 1-Click Server IP Copy" />
|
||||
</label>
|
||||
</div>
|
||||
{:else if block.type === 'download'}
|
||||
<div class="module-fields grid-2">
|
||||
<label class="field">
|
||||
<span>Display Layout</span>
|
||||
<select bind:value={block.options.layout}>
|
||||
<option value="cards">Feature Cards (Default)</option>
|
||||
<option value="compact">Compact List</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Badge Highlight Note</span>
|
||||
<input bind:value={block.options.badge_note} placeholder="e.g. Java 21 Bundled · Auto-Sync" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Show System Requirements</span>
|
||||
<Toggle bind:checked={block.options.show_specs} label="Display OS requirements info" />
|
||||
</label>
|
||||
</div>
|
||||
{:else if block.type === 'servers'}
|
||||
<div class="module-fields grid-2">
|
||||
<label class="field">
|
||||
<span>Show Offline Servers</span>
|
||||
<Toggle bind:checked={block.options.show_offline} label="List servers even when offline" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Show Version / Software Badges</span>
|
||||
<Toggle bind:checked={block.options.show_badges} label="Display MC version and loader" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Action Button Text</span>
|
||||
<input bind:value={block.options.cta_label} placeholder="e.g. Copy Server IP" />
|
||||
</label>
|
||||
</div>
|
||||
{:else if block.type === 'leaderboard'}
|
||||
<div class="module-fields grid-2">
|
||||
<label class="field">
|
||||
<span>Default Metric</span>
|
||||
<select bind:value={block.options.default_metric}>
|
||||
<option value="playtime_secs">Playtime</option>
|
||||
<option value="global_level">Global Level</option>
|
||||
<option value="player_kills">Player Kills</option>
|
||||
<option value="mob_kills">Mob Kills</option>
|
||||
<option value="blocks_broken">Blocks Mined</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Leaderboard Size</span>
|
||||
<select bind:value={block.options.limit}>
|
||||
<option value={5}>Top 5</option>
|
||||
<option value={10}>Top 10 (Default)</option>
|
||||
<option value={20}>Top 20</option>
|
||||
<option value={50}>Top 50</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Show Top 3 Podium</span>
|
||||
<Toggle bind:checked={block.options.show_podium} label="Highlight Gold, Silver, and Bronze winners" />
|
||||
</label>
|
||||
</div>
|
||||
{:else if block.type === 'stats'}
|
||||
<div class="module-fields grid-2">
|
||||
<label class="field">
|
||||
<span>Show Players Online Counter</span>
|
||||
<Toggle bind:checked={block.options.show_players} label="Display total registered / online players" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Show Total Playtime Counter</span>
|
||||
<Toggle bind:checked={block.options.show_playtime} label="Display cumulative playtime" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Show Guilds Counter</span>
|
||||
<Toggle bind:checked={block.options.show_guilds} label="Display active guilds count" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Custom Stat (Label & Value)</span>
|
||||
<div class="row gap-xs">
|
||||
<input bind:value={block.options.custom_stat1_label} placeholder="e.g. Blocks Placed" />
|
||||
<input bind:value={block.options.custom_stat1_value} placeholder="e.g. 1.2M+" style="max-width: 100px" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{:else if block.type === 'instances'}
|
||||
<div class="module-fields grid-2">
|
||||
<label class="field">
|
||||
<span>Grid Columns</span>
|
||||
<select bind:value={block.options.columns}>
|
||||
<option value={2}>2 Columns</option>
|
||||
<option value={3}>3 Columns (Default)</option>
|
||||
<option value={4}>4 Columns</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Show Mod Loader Badges</span>
|
||||
<Toggle bind:checked={block.options.show_loaders} label="Display Fabric / Forge / NeoForge pills" />
|
||||
</label>
|
||||
</div>
|
||||
{:else if block.type === 'news'}
|
||||
<div class="module-fields grid-2">
|
||||
<label class="field">
|
||||
<span>Max Articles Shown</span>
|
||||
<select bind:value={block.options.max_items}>
|
||||
<option value={3}>3 Articles</option>
|
||||
<option value={5}>5 Articles (Default)</option>
|
||||
<option value={10}>10 Articles</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Show Date Badges</span>
|
||||
<Toggle bind:checked={block.options.show_dates} label="Show publication date on posts" />
|
||||
</label>
|
||||
</div>
|
||||
{:else if block.type === 'faq'}
|
||||
<div class="module-fields grid-2">
|
||||
<label class="field">
|
||||
<span>Allow Multiple Open Questions</span>
|
||||
<Toggle bind:checked={block.options.allow_multiple} label="Visitors can expand multiple FAQs at once" />
|
||||
</label>
|
||||
</div>
|
||||
{:else if block.type === 'socials'}
|
||||
<div class="module-fields grid-2">
|
||||
<label class="field">
|
||||
<span>Discord Invite URL</span>
|
||||
<input bind:value={block.options.discord_url} placeholder="https://discord.gg/..." />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Server Webstore URL</span>
|
||||
<input bind:value={block.options.store_url} placeholder="https://store.myserver.com" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>YouTube / Twitch URL</span>
|
||||
<input bind:value={block.options.media_url} placeholder="https://youtube.com/@..." />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Twitter / X or Bluesky URL</span>
|
||||
<input bind:value={block.options.social_url} placeholder="https://x.com/..." />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -378,11 +567,17 @@
|
||||
.field input, .field textarea { background: var(--bg); border: 1px solid var(--line); color: var(--text); padding: 8px 12px; border-radius: var(--radius-sm); font-size: 14px; }
|
||||
.field input:focus, .field textarea:focus { border-color: var(--accent); outline: none; }
|
||||
.blocks-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
||||
.block-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: var(--radius-sm); background: var(--bg); border: 1px solid var(--line); transition: opacity 0.15s, border-color 0.15s; }
|
||||
.block-row.disabled { opacity: 0.55; }
|
||||
.block-item-container { border-radius: var(--radius-sm); border: 1px solid var(--line); background: var(--bg); overflow: hidden; transition: opacity 0.15s, border-color 0.15s; }
|
||||
.block-item-container.disabled { opacity: 0.55; }
|
||||
.block-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; }
|
||||
.block-config-pane { border-top: 1px dashed var(--line); padding: 16px; background: color-mix(in srgb, var(--surface) 50%, transparent); display: flex; flex-direction: column; gap: 14px; }
|
||||
.module-fields { border-top: 1px solid var(--line); padding-top: 12px; }
|
||||
.badge-sub { font-style: italic; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
select { background: var(--bg); border: 1px solid var(--line); color: var(--text); padding: 8px 12px; border-radius: var(--radius-sm); font-size: 14px; outline: none; }
|
||||
select:focus { border-color: var(--accent); }
|
||||
.block-reorder { display: flex; flex-direction: column; gap: 2px; }
|
||||
.block-icon { width: 34px; height: 34px; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.block-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.block-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; cursor: pointer; }
|
||||
.block-type-tag { text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; }
|
||||
.sm-input { width: 180px; padding: 6px 10px; font-size: 12px; }
|
||||
.downloads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; margin-top: 6px; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Sparkles, Trophy, Plus, Pencil, Trash2, Award, Shield, Crown } from '@lucide/svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { get, post, put, del } from '../lib/api';
|
||||
@@ -26,11 +27,18 @@
|
||||
loading = true;
|
||||
try {
|
||||
const [r, lb] = await Promise.all([
|
||||
get<LevelReward[]>('/api/v1/levels/rewards'),
|
||||
get<any[]>('/api/v1/levels/rewards'),
|
||||
get<UserLevelInfo[]>('/api/v1/levels/leaderboard?limit=10'),
|
||||
]);
|
||||
rewards = r.sort((a, b) => a.level - b.level);
|
||||
leaderboard = lb;
|
||||
rewards = (r || []).map((x: any) => ({
|
||||
id: x.id,
|
||||
level: Number(x.level ?? x.level_req ?? 1),
|
||||
reward_type: x.reward_type || 'title',
|
||||
reward_value: x.reward_value ?? x.reward_name ?? '',
|
||||
description: x.description ?? '',
|
||||
icon: x.icon ?? '🎁',
|
||||
})).sort((a, b) => a.level - b.level);
|
||||
leaderboard = lb || [];
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
@@ -38,7 +46,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
onMount(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {
|
||||
Download, Server, Users, Trophy, Sparkles, HelpCircle, Newspaper,
|
||||
Copy, Check, ExternalLink, ChevronDown, ChevronUp, Swords, Flame,
|
||||
Pickaxe, Clock, Box, Shield, Zap, ArrowRight, LogIn, Crown,
|
||||
Pickaxe, Clock, Box, Shield, Zap, ArrowRight, LogIn, Crown, MessageSquare,
|
||||
} from '@lucide/svelte';
|
||||
import { get, formatBytes } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
@@ -171,18 +171,25 @@
|
||||
<div class="container hero-content">
|
||||
<div class="hero-badge">
|
||||
<span class="hero-badge-dot"></span>
|
||||
<span>{totalOnline} Players Online Now</span>
|
||||
<span>{block.options?.badge_text || `${totalOnline} Players Online Now`}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="hero-title">{config.hero_title}</h1>
|
||||
<p class="hero-sub">{config.hero_subtitle}</p>
|
||||
<h1 class="hero-title">{block.options?.headline || config.hero_title}</h1>
|
||||
<p class="hero-sub">{block.options?.subtitle || config.hero_subtitle}</p>
|
||||
|
||||
<div class="hero-cta-group">
|
||||
{#if primaryDownload}
|
||||
{#if block.options?.cta_url}
|
||||
<a href={block.options.cta_url} target="_blank" class="btn-hero-primary">
|
||||
<Download size={20} />
|
||||
<div class="cta-text-group">
|
||||
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
|
||||
</div>
|
||||
</a>
|
||||
{:else if primaryDownload}
|
||||
<a href={primaryDownload.file_url} class="btn-hero-primary" download>
|
||||
<Download size={20} />
|
||||
<div class="cta-text-group">
|
||||
<span class="cta-main">{config.hero_cta_text}</span>
|
||||
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
|
||||
<span class="cta-sub">for {primaryDownload.label} ({formatBytes(primaryDownload.size)})</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -190,18 +197,18 @@
|
||||
<a href={config.external_download_url} target="_blank" class="btn-hero-primary">
|
||||
<Download size={20} />
|
||||
<div class="cta-text-group">
|
||||
<span class="cta-main">{config.hero_cta_text}</span>
|
||||
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
|
||||
<span class="cta-sub">Download Latest Release</span>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<button type="button" class="btn-hero-primary" onclick={() => scrollToSection('download')}>
|
||||
<Download size={20} />
|
||||
<span class="cta-main">{config.hero_cta_text}</span>
|
||||
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if config.server_ip}
|
||||
{#if block.options?.show_ip_copy !== false && config.server_ip}
|
||||
<button class="btn-hero-secondary" onclick={() => copyIp(config?.server_ip ?? '')}>
|
||||
{#if copied}<Check size={18} /> Copied Server IP{:else}<Copy size={18} /> Copy Server IP{/if}
|
||||
</button>
|
||||
@@ -217,9 +224,12 @@
|
||||
<div class="section-head">
|
||||
<h2>{block.title || 'Get the Launcher'}</h2>
|
||||
<p class="muted">{block.subtitle || 'Custom modpack synchronisation, fast Java auto-updates, and HD skin wardrobe.'}</p>
|
||||
{#if block.options?.badge_note}
|
||||
<div class="badge-note-pill">{block.options.badge_note}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="downloads-cards-grid">
|
||||
<div class="downloads-cards-grid" class:compact-list={block.options?.layout === 'compact'}>
|
||||
{#each config.hosted_downloads as d}
|
||||
<div class="download-card" class:recommended={d.platform === detectedOS}>
|
||||
{#if d.platform === detectedOS}
|
||||
@@ -260,16 +270,16 @@
|
||||
<p class="muted">{block.subtitle || 'Connect to our high-performance Minecraft worlds.'}</p>
|
||||
</div>
|
||||
|
||||
{#if publicServers.length}
|
||||
{#if publicServers.filter((s) => (block.options?.show_offline === false ? s.online : true)).length}
|
||||
<div class="servers-grid">
|
||||
{#each publicServers as s}
|
||||
{#each publicServers.filter((s) => (block.options?.show_offline === false ? s.online : true)) as s}
|
||||
<div class="server-card">
|
||||
<div class="server-card-top">
|
||||
<div class="server-status-pill" class:online={s.online}>
|
||||
<span class="dot"></span>
|
||||
<span>{s.online ? `${s.players_online} / ${s.players_max} Online` : 'Offline'}</span>
|
||||
</div>
|
||||
{#if s.mc_version}
|
||||
{#if block.options?.show_badges !== false && s.mc_version}
|
||||
<span class="mc-ver-tag tiny">{s.mc_version}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -277,7 +287,7 @@
|
||||
<h3 class="server-card-title">{s.name}</h3>
|
||||
<div class="server-card-bottom">
|
||||
<button class="server-copy-btn" onclick={() => copyIp(config?.server_ip ?? '')}>
|
||||
<span>{config?.server_ip || 'play.server.com'}</span>
|
||||
<span>{block.options?.cta_label || config?.server_ip || 'play.server.com'}</span>
|
||||
<Copy size={13} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -442,22 +452,34 @@
|
||||
</div>
|
||||
|
||||
<div class="stats-counter-grid">
|
||||
<div class="counter-card">
|
||||
<span class="counter-num">{formatNumber(totalPlayers)}</span>
|
||||
<span class="counter-label">Total Players</span>
|
||||
</div>
|
||||
<div class="counter-card">
|
||||
<span class="counter-num">{formatTime(totalPlaytime)}</span>
|
||||
<span class="counter-label">Time Explored</span>
|
||||
</div>
|
||||
{#if block.options?.show_players !== false}
|
||||
<div class="counter-card">
|
||||
<span class="counter-num">{formatNumber(totalPlayers)}</span>
|
||||
<span class="counter-label">Total Players</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if block.options?.show_playtime !== false}
|
||||
<div class="counter-card">
|
||||
<span class="counter-num">{formatTime(totalPlaytime)}</span>
|
||||
<span class="counter-label">Time Explored</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if block.options?.show_guilds !== false && publicGuilds.length > 0}
|
||||
<div class="counter-card">
|
||||
<span class="counter-num">{publicGuilds.length}</span>
|
||||
<span class="counter-label">Active Guilds</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="counter-card">
|
||||
<span class="counter-num">{publicServers.length}</span>
|
||||
<span class="counter-label">Connected Servers</span>
|
||||
</div>
|
||||
<div class="counter-card">
|
||||
<span class="counter-num">{instances.length}</span>
|
||||
<span class="counter-label">Modpack Instances</span>
|
||||
</div>
|
||||
{#if block.options?.custom_stat1_label && block.options?.custom_stat1_value}
|
||||
<div class="counter-card">
|
||||
<span class="counter-num">{block.options.custom_stat1_value}</span>
|
||||
<span class="counter-label">{block.options.custom_stat1_label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -542,6 +564,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{:else if block.type === 'socials'}
|
||||
<!-- Socials Block -->
|
||||
<section class="section" id="socials">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2>{block.title || 'Join Our Community'}</h2>
|
||||
<p class="muted">{block.subtitle || 'Connect with players, join our Discord, and follow news.'}</p>
|
||||
</div>
|
||||
|
||||
<div class="socials-buttons-grid">
|
||||
{#if block.options?.discord_url}
|
||||
<a href={block.options.discord_url} target="_blank" rel="noreferrer" class="social-btn discord">
|
||||
<MessageSquare size={18} /> Join our Discord
|
||||
</a>
|
||||
{/if}
|
||||
{#if block.options?.store_url}
|
||||
<a href={block.options.store_url} target="_blank" rel="noreferrer" class="social-btn store">
|
||||
<Sparkles size={18} /> Visit Webstore
|
||||
</a>
|
||||
{/if}
|
||||
{#if block.options?.media_url}
|
||||
<a href={block.options.media_url} target="_blank" rel="noreferrer" class="social-btn media">
|
||||
<Flame size={18} /> YouTube / Twitch
|
||||
</a>
|
||||
{/if}
|
||||
{#if block.options?.social_url}
|
||||
<a href={block.options.social_url} target="_blank" rel="noreferrer" class="social-btn twitter">
|
||||
<ExternalLink size={18} /> Follow on Twitter / X
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -670,6 +726,15 @@
|
||||
.footer { border-top: 1px solid rgba(255, 255, 255, 0.08); padding: 40px 0; margin-top: auto; }
|
||||
.footer-content { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; }
|
||||
.empty-state-box { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 40px; }
|
||||
.badge-note-pill { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 99rem; background: rgba(109, 106, 245, 0.15); color: #a5a3ff; font-size: 13px; font-weight: 600; margin-top: 10px; }
|
||||
.downloads-cards-grid.compact-list { grid-template-columns: 1fr; }
|
||||
.socials-buttons-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-top: 24px; }
|
||||
.social-btn { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 16px 20px; border-radius: 12px; font-weight: 600; text-decoration: none; transition: transform 0.15s, opacity 0.15s; color: #fff; font-size: 15px; }
|
||||
.social-btn:hover { transform: translateY(-2px); opacity: 0.95; }
|
||||
.social-btn.discord { background: #5865F2; }
|
||||
.social-btn.store { background: #8b5cf6; }
|
||||
.social-btn.media { background: #ef4444; }
|
||||
.social-btn.twitter { background: #0284c7; }
|
||||
@media (max-width: 768px) {
|
||||
.nav-links { display: none; }
|
||||
.hero-title { font-size: 34px; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Award, Sparkles, Filter } from '@lucide/svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { get, post, put, del } from '../lib/api';
|
||||
@@ -33,7 +34,19 @@
|
||||
async function loadQuests() {
|
||||
loading = true;
|
||||
try {
|
||||
quests = await get<Quest[]>('/api/admin/quests');
|
||||
const res = await get<any[]>('/api/admin/quests');
|
||||
quests = (res || []).map((q: any) => ({
|
||||
id: q.id || '',
|
||||
title: q.title || '',
|
||||
description: q.description || '',
|
||||
quest_type: q.quest_type || q.period || 'daily',
|
||||
category: q.category || 'mining',
|
||||
xp_reward: q.xp_reward || 0,
|
||||
stat_type: q.stat_type || q.target_stat || 'blocks_broken',
|
||||
target_count: q.target_count || 1,
|
||||
icon: q.icon || '⛏️',
|
||||
active: q.active !== undefined ? q.active : (q.enabled !== undefined ? q.enabled : true),
|
||||
}));
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
@@ -41,7 +54,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
onMount(() => {
|
||||
loadQuests();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user