mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-13 04:21:02 +00:00
Merge remote-tracking branch 'origin/codex/client-only-mode' into codex/release-all-pending
This commit is contained in:
commit
7c096a4844
19 files changed
+412
-50
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: players
|
||||
title: Download and update the game client without installing a server
|
||||
---
|
||||
Choose to host a server or connect to someone else's. Players joining a friend can download, update and launch the client, save the host's address and use the Manager without installing a local server.
|
||||
@@ -180,7 +180,7 @@ fn backup_dir(meta: &Path) -> PathBuf {
|
||||
meta.join("backups").join("client")
|
||||
}
|
||||
|
||||
fn host_ok(h: &str) -> bool {
|
||||
pub fn host_ok(h: &str) -> bool {
|
||||
!h.is_empty() && h.len() <= 253 && h.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':')) && !h.starts_with('-')
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod backup;
|
||||
pub mod cleanbase;
|
||||
pub mod companions;
|
||||
pub mod client;
|
||||
pub mod remote_client;
|
||||
pub mod clientdl;
|
||||
pub mod config;
|
||||
pub mod console;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! A player's client connection, independent of any local server installation.
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{client, fsx, Error, Result};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
pub host: String,
|
||||
pub client_path: Option<String>,
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Result<Profile> {
|
||||
match std::fs::read(dir.join("connection.json")) {
|
||||
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Profile::default()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(dir: &Path, profile: &Profile) -> Result<()> {
|
||||
if !profile.host.is_empty() && !client::host_ok(&profile.host) {
|
||||
return Err(Error::Invalid("Enter a server IP address or hostname, without a URL or port.".into()));
|
||||
}
|
||||
if let Some(path) = &profile.client_path {
|
||||
let client = PathBuf::from(path);
|
||||
if client::detect(&client, None).is_none() {
|
||||
return Err(Error::Invalid("This folder does not look like a game client (it needs Data and the game executable).".into()));
|
||||
}
|
||||
if client::is_running(&client) {
|
||||
return Err(Error::Invalid("Close the game before changing its connection.".into()));
|
||||
}
|
||||
if !profile.host.is_empty() { client::set_realmlist(&client, dir, &profile.host)?; }
|
||||
}
|
||||
fsx::atomic_write_json(&dir.join("connection.json"), profile)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn connection_needs_no_server_and_survives_restart() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let profile = Profile { host: "192.168.1.10".into(), client_path: None };
|
||||
save(dir.path(), &profile).unwrap();
|
||||
assert_eq!(load(dir.path()).unwrap().host, profile.host);
|
||||
assert!(!dir.path().join("install.json").exists());
|
||||
}
|
||||
#[test]
|
||||
fn linking_client_sets_host_without_touching_other_game_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let game = dir.path().join("game");
|
||||
std::fs::create_dir_all(game.join("Data/enUS")).unwrap();
|
||||
std::fs::write(game.join("Wow.exe"), b"fixture").unwrap();
|
||||
std::fs::write(game.join("Data/enUS/realmlist.wtf"), "set realmlist 127.0.0.1\n").unwrap();
|
||||
std::fs::create_dir_all(game.join("WTF")).unwrap();
|
||||
std::fs::write(game.join("WTF/Config.wtf"), "SET realmName \"My realm\"\n").unwrap();
|
||||
let profile = Profile { host: "192.168.1.10".into(), client_path: Some(game.to_string_lossy().into()) };
|
||||
save(dir.path(), &profile).unwrap();
|
||||
assert!(std::fs::read_to_string(game.join("Data/enUS/realmlist.wtf")).unwrap().contains(&profile.host));
|
||||
assert_eq!(std::fs::read_to_string(game.join("WTF/Config.wtf")).unwrap(), "SET realmName \"My realm\"\n");
|
||||
assert!(save(dir.path(), &Profile { host: "bad\nset realmlist other".into(), ..profile }).is_err());
|
||||
assert_eq!(load(dir.path()).unwrap().host, "192.168.1.10");
|
||||
}
|
||||
}
|
||||
@@ -578,6 +578,11 @@ The reference install classifies as **Healthy (customised)**: repack shape, bina
|
||||
* **UI:** PLAY becomes **UPDATE CLIENT** when a tracked client is outdated (with "Play without updating"); with no client linked it
|
||||
becomes **Set up game client** (choose an existing folder, or download into `<chosen folder>/CoA Client` with a free-space check).
|
||||
An existing client that is not tracked can be adopted from Settings > Game client ("Keep this client up to date").
|
||||
A fresh download gets `realmlist.wtf` for each installed locale (the manifest has none) and the companion addon.
|
||||
A fresh download gets `realmlist.wtf` for each installed locale (the manifest has none) and, for local hosts, the companion addon.
|
||||
* **Joining another server:** the welcome screen offers hosting or joining. Joining uses a separate player profile in
|
||||
the Manager's `remote-client/connection.json`, storing the host address and client folder without a server registry entry,
|
||||
installation metadata, database or server processes. Only Overview and application Settings appear in the sidebar.
|
||||
Downloading, adopting and updating clients use the same verified downloader; Play applies the saved realmlist without
|
||||
forcing a local realm selection. The player can return to hosting without removing their saved connection.
|
||||
* **Open points:** ask the launcher maintainers before putting heavy load on their API; DBCs live inside the MPQ patch archives
|
||||
(extracting them would need an MPQ reader), so the server's `Data\dbc` still comes from our `base` package for now.
|
||||
+99
-31
@@ -929,6 +929,68 @@ async fn add_companions(state: State<'_, AppState>, id: String, count: u32) -> s
|
||||
.await
|
||||
}
|
||||
|
||||
const REMOTE_CLIENT_ID: &str = "@remote-client";
|
||||
|
||||
fn remote_dir() -> PathBuf { data_dir().join("remote-client") }
|
||||
|
||||
#[tauri::command]
|
||||
fn remote_connection() -> std::result::Result<coa_core::remote_client::Profile, UiError> {
|
||||
Ok(coa_core::remote_client::load(&remote_dir())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remote_connect(state: State<'_, AppState>, host: String) -> std::result::Result<coa_core::remote_client::Profile, UiError> {
|
||||
let job = state.client_cancel.lock().unwrap();
|
||||
if job.is_some() { return Err(Error::Invalid("Wait for the client operation to finish.".into()).into()); }
|
||||
let dir = remote_dir();
|
||||
let mut profile = coa_core::remote_client::load(&dir)?;
|
||||
let host = host.trim();
|
||||
if host.is_empty() { return Err(Error::Invalid("Enter the host's IP address or hostname.".into()).into()); }
|
||||
profile.host = host.into();
|
||||
coa_core::remote_client::save(&dir, &profile)?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
struct ClientContext {
|
||||
dir: PathBuf,
|
||||
path: Option<PathBuf>,
|
||||
source: Option<PathBuf>,
|
||||
host: String,
|
||||
root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn client_context(state: &AppState, id: &str) -> Result<ClientContext> {
|
||||
if id == REMOTE_CLIENT_ID {
|
||||
let dir = remote_dir();
|
||||
let profile = coa_core::remote_client::load(&dir)?;
|
||||
return Ok(ClientContext { dir, path: profile.client_path.map(PathBuf::from), source: None, host: profile.host, root: None });
|
||||
}
|
||||
let root = path_of(state, id)?;
|
||||
let (dir, meta) = install_meta(&root)?;
|
||||
let source = coa_core::client::addon_source(&root);
|
||||
Ok(ClientContext { dir, path: meta.client_path.map(PathBuf::from), source, host: "127.0.0.1".into(), root: Some(root) })
|
||||
}
|
||||
|
||||
impl ClientContext {
|
||||
fn linked(&self) -> Option<PathBuf> {
|
||||
self.path.clone().filter(|p| coa_core::client::detect(p, None).is_some())
|
||||
}
|
||||
fn require(&self) -> Result<PathBuf> {
|
||||
self.linked().ok_or_else(|| Error::Invalid("Set up a game client first.".into()))
|
||||
}
|
||||
fn link(&self, path: &str) -> Result<()> {
|
||||
if let Some(root) = &self.root {
|
||||
let (_, mut meta) = install_meta(root)?;
|
||||
meta.client_path = Some(path.into());
|
||||
coa_core::fsx::atomic_write_json(&self.dir.join("install.json"), &meta)
|
||||
} else {
|
||||
let mut profile = coa_core::remote_client::load(&self.dir)?;
|
||||
profile.client_path = Some(path.into());
|
||||
coa_core::remote_client::save(&self.dir, &profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn client_of(root: &std::path::Path) -> Result<(PathBuf, InstallMeta, Option<PathBuf>)> {
|
||||
let (dir, meta) = install_meta(root)?;
|
||||
let client = meta.client_path.clone().map(PathBuf::from).ok_or_else(|| Error::Invalid("No game client is set up for this server yet.".into()))?;
|
||||
@@ -937,22 +999,19 @@ fn client_of(root: &std::path::Path) -> Result<(PathBuf, InstallMeta, Option<Pat
|
||||
|
||||
#[tauri::command]
|
||||
fn client_info(state: State<'_, AppState>, id: String) -> std::result::Result<Option<coa_core::client::ClientInfo>, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let (_, meta) = install_meta(&root)?;
|
||||
let source = coa_core::client::addon_source(&root);
|
||||
Ok(meta.client_path.and_then(|p| coa_core::client::detect(std::path::Path::new(&p), source.as_deref())))
|
||||
let ctx = client_context(&state, &id)?;
|
||||
Ok(ctx.path.and_then(|p| coa_core::client::detect(&p, ctx.source.as_deref())))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_client(state: State<'_, AppState>, id: String, path: String) -> std::result::Result<coa_core::client::ClientInfo, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let (dir, mut meta) = install_meta(&root)?;
|
||||
let source = coa_core::client::addon_source(&root);
|
||||
let info = coa_core::client::detect(std::path::Path::new(&path), source.as_deref())
|
||||
let job = state.client_cancel.lock().unwrap();
|
||||
if job.is_some() { return Err(Error::Invalid("Wait for the client operation to finish.".into()).into()); }
|
||||
let ctx = client_context(&state, &id)?;
|
||||
let info = coa_core::client::detect(std::path::Path::new(&path), ctx.source.as_deref())
|
||||
.ok_or_else(|| Error::Invalid("This folder does not look like a game client (it needs Data and the game executable).".into()))?;
|
||||
meta.client_path = Some(info.path.clone());
|
||||
coa_core::fsx::atomic_write_json(&dir.join("install.json"), &meta).map_err(UiError::from)?;
|
||||
Ok(info)
|
||||
ctx.link(&info.path)?;
|
||||
Ok(coa_core::client::detect(std::path::Path::new(&info.path), ctx.source.as_deref()).unwrap_or(info))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -991,9 +1050,9 @@ fn linked_client(root: &std::path::Path) -> Result<Option<PathBuf>> {
|
||||
/// Cheap: one small manifest request and the local state file; no game file is read.
|
||||
#[tauri::command]
|
||||
async fn client_status(state: State<'_, AppState>, id: String) -> std::result::Result<ClientStatus, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let ctx = client_context(&state, &id)?;
|
||||
blocking(move || {
|
||||
let client = linked_client(&root)?;
|
||||
let client = ctx.linked();
|
||||
let latest = coa_core::clientdl::fetch_latest().ok();
|
||||
let local = client.as_deref().map(coa_core::clientdl::local);
|
||||
let managed = local.as_ref().map(|l| l.managed).unwrap_or(false);
|
||||
@@ -1040,10 +1099,10 @@ async fn client_download_check(parent: String) -> std::result::Result<ClientDown
|
||||
/// Compare the linked client with the published one. Hashes files only where the recorded state cannot vouch for them.
|
||||
#[tauri::command]
|
||||
async fn client_plan(app: AppHandle, state: State<'_, AppState>, id: String) -> std::result::Result<coa_core::clientdl::Plan, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let ctx = client_context(&state, &id)?;
|
||||
let cancel = begin_client_job(&state)?;
|
||||
let result = blocking(move || {
|
||||
let client = linked_client(&root)?.ok_or_else(|| Error::Invalid("No game client is set up for this server yet.".into()))?;
|
||||
let client = ctx.require()?;
|
||||
let manifest = coa_core::clientdl::fetch_latest()?;
|
||||
let current = coa_core::clientdl::load_state(&client).unwrap_or_default();
|
||||
coa_core::clientdl::plan(&client, &manifest, ¤t, &cancel, &|s| {
|
||||
@@ -1058,11 +1117,13 @@ async fn client_plan(app: AppHandle, state: State<'_, AppState>, id: String) ->
|
||||
/// Bring the linked client to the published version. `keep_modified` leaves files the player changed alone.
|
||||
#[tauri::command]
|
||||
async fn client_sync(app: AppHandle, state: State<'_, AppState>, id: String, keep_modified: bool) -> std::result::Result<(), UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let ctx = client_context(&state, &id)?;
|
||||
let cancel = begin_client_job(&state)?;
|
||||
let result = blocking(move || {
|
||||
let client = linked_client(&root)?.ok_or_else(|| Error::Invalid("No game client is set up for this server yet.".into()))?;
|
||||
run_client_sync(&app, &client, keep_modified, &cancel)
|
||||
let client = ctx.require()?;
|
||||
run_client_sync(&app, &client, keep_modified, &cancel)?;
|
||||
if ctx.root.is_none() && !ctx.host.is_empty() { coa_core::client::set_realmlist(&client, &ctx.dir, &ctx.host)?; }
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
end_client_job(&state);
|
||||
@@ -1086,13 +1147,12 @@ fn run_client_sync(app: &AppHandle, client: &std::path::Path, keep_modified: boo
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download the whole client into `<parent>/CoA Client`, then link it to this server.
|
||||
/// Download the client into `<parent>/CoA Client`, then link it to a host or player profile.
|
||||
#[tauri::command]
|
||||
async fn client_download(app: AppHandle, state: State<'_, AppState>, id: String, parent: String) -> std::result::Result<coa_core::client::ClientInfo, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let ctx = client_context(&state, &id)?;
|
||||
let cancel = begin_client_job(&state)?;
|
||||
let result = blocking(move || {
|
||||
let (dir, mut meta) = install_meta(&root)?;
|
||||
let parent = PathBuf::from(&parent);
|
||||
if !parent.is_dir() {
|
||||
return Err(Error::Invalid("Choose an existing folder to put the game client in.".into()));
|
||||
@@ -1105,16 +1165,11 @@ async fn client_download(app: AppHandle, state: State<'_, AppState>, id: String,
|
||||
}
|
||||
std::fs::create_dir_all(&dest)?;
|
||||
run_client_sync(&app, &dest, false, &cancel)?;
|
||||
let source = coa_core::client::addon_source(&root);
|
||||
let info = coa_core::client::detect(&dest, source.as_deref()).ok_or_else(|| Error::Invalid("The downloaded client looks incomplete.".into()))?;
|
||||
meta.client_path = Some(info.path.clone());
|
||||
coa_core::fsx::atomic_write_json(&dir.join("install.json"), &meta)?;
|
||||
// a fresh client has no realmlist; this server is on this computer
|
||||
coa_core::client::set_realmlist(&dest, &dir, "127.0.0.1")?;
|
||||
if let Some(src) = source {
|
||||
coa_core::client::install_addon(&dest, &dir, &src)?;
|
||||
}
|
||||
Ok(coa_core::client::detect(&dest, coa_core::client::addon_source(&root).as_deref()).unwrap_or(info))
|
||||
let info = coa_core::client::detect(&dest, ctx.source.as_deref()).ok_or_else(|| Error::Invalid("The downloaded client looks incomplete.".into()))?;
|
||||
ctx.link(&info.path)?;
|
||||
if !ctx.host.is_empty() { coa_core::client::set_realmlist(&dest, &ctx.dir, &ctx.host)?; }
|
||||
if let Some(src) = &ctx.source { coa_core::client::install_addon(&dest, &ctx.dir, src)?; }
|
||||
Ok(coa_core::client::detect(&dest, ctx.source.as_deref()).unwrap_or(info))
|
||||
})
|
||||
.await;
|
||||
end_client_job(&state);
|
||||
@@ -1149,6 +1204,17 @@ fn end_client_job(state: &AppState) {
|
||||
/// Launch the game client independently of the local server.
|
||||
#[tauri::command]
|
||||
async fn play(state: State<'_, AppState>, id: String) -> std::result::Result<DriverOutcome, UiError> {
|
||||
if id == REMOTE_CLIENT_ID {
|
||||
let ctx = client_context(&state, &id)?;
|
||||
let client = ctx.require()?;
|
||||
return blocking(move || {
|
||||
if ctx.host.is_empty() { return Err(Error::Invalid("Enter the host's IP address or hostname first.".into())); }
|
||||
if coa_core::client::is_running(&client) { return Err(Error::Invalid("The game is already running.".into())); }
|
||||
coa_core::client::set_realmlist(&client, &ctx.dir, &ctx.host)?;
|
||||
coa_core::client::launch(&client)?;
|
||||
Ok(DriverOutcome { ok: true, exit_code: None, code: None, human: None, output: String::new() })
|
||||
}).await;
|
||||
}
|
||||
let root = path_of(&state, &id)?;
|
||||
let (dir, _, client) = client_of(&root)?;
|
||||
let client = client.unwrap();
|
||||
@@ -1501,6 +1567,8 @@ pub fn run() {
|
||||
open_link,
|
||||
companion_sizes,
|
||||
add_companions,
|
||||
remote_connection,
|
||||
remote_connect,
|
||||
client_info,
|
||||
set_client,
|
||||
client_realmlist,
|
||||
|
||||
+17
-2
@@ -4,14 +4,20 @@ import { Welcome } from "@/screens/Welcome";
|
||||
import { ImportServer } from "@/screens/ImportServer";
|
||||
import { Shell } from "@/screens/Shell";
|
||||
import { InstallServer } from "@/screens/InstallServer";
|
||||
import { RemoteClient } from "@/screens/RemoteClient";
|
||||
import { UpdateBanner } from "@/components/UpdateBanner";
|
||||
import { autoUpdateEnabled, installOnClose, lookForUpdate } from "@/lib/selfUpdate";
|
||||
|
||||
type View = "welcome" | "add" | "import" | "install";
|
||||
type View = "welcome" | "add" | "import" | "install" | "remote";
|
||||
|
||||
function initialView(): View {
|
||||
try { return localStorage.getItem("coa-play-mode") === "remote" ? "remote" : "welcome"; }
|
||||
catch { return "welcome"; }
|
||||
}
|
||||
|
||||
function Main() {
|
||||
const [servers, setServers] = useState<ServerSummary[] | null>(null);
|
||||
const [view, setView] = useState<View>("welcome");
|
||||
const [view, setView] = useState<View>(initialView);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -26,6 +32,11 @@ function Main() {
|
||||
|
||||
if (servers === null) return null;
|
||||
|
||||
if (view === "remote") return <RemoteClient onHost={() => {
|
||||
try { localStorage.setItem("coa-play-mode", "host"); } catch { /* storage unavailable */ }
|
||||
setView(servers.length ? "welcome" : "add");
|
||||
}} />;
|
||||
|
||||
if (servers.length > 0 && view === "welcome" && activeId) {
|
||||
return (
|
||||
<Shell
|
||||
@@ -71,6 +82,10 @@ function Main() {
|
||||
|
||||
return (
|
||||
<Welcome
|
||||
onConnect={() => {
|
||||
try { localStorage.setItem("coa-play-mode", "remote"); } catch { /* storage unavailable */ }
|
||||
setView("remote");
|
||||
}}
|
||||
onImport={() => setView("import")}
|
||||
onInstall={() => setView("install")}
|
||||
onBack={servers.length > 0 ? () => setView("welcome") : undefined}
|
||||
|
||||
+5
-1
@@ -10,6 +10,7 @@ export {};
|
||||
let added = new URLSearchParams(location.search).has("demo"), running = false;
|
||||
let realm: "coa" | "wildcard" = "coa", wildcardCreated = false;
|
||||
let simultaneous = false;
|
||||
let remoteHost = localStorage.getItem("demo-remote-host") ?? "";
|
||||
const moduleEdits: Record<string, Record<string, string>> = {};
|
||||
const modState: Record<string, boolean> = { "companions": true, "content-scaling": true, "client-compat": true, "tbc-content": true, "wotlk-content": true, "npc-enchanter": true };
|
||||
const report = {path:"C:\\games\\CoA-Repack",classification:"healthy",items:[
|
||||
@@ -30,6 +31,9 @@ export {};
|
||||
if(cmd==="check_database") return [{realm, migrations:[],problems:[],full_schema:false}];
|
||||
if(cmd==="repair_server") { if(running) throw new Error("Stop the server before repairing it."); return {restored:[],applied:[],backup:"demo-before-repair",database:[{realm,migrations:[],problems:[],full_schema:false}],error:null}; }
|
||||
if(cmd==="realm_select"){await new Promise(r=>setTimeout(r,600));realm=args.mode;wildcardCreated ||= realm==="wildcard";modState.companions=realm==="coa";return {active:realm,wildcard_created:wildcardCreated,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:simultaneous&&running};}
|
||||
if(cmd==="remote_connection") return {host:remoteHost,client_path:null};
|
||||
if(cmd==="remote_connect") { remoteHost=args.host.trim(); localStorage.setItem("demo-remote-host",remoteHost); return {host:remoteHost,client_path:null}; }
|
||||
if(cmd==="play") return {ok:true,exit_code:null,code:null,human:null,output:""};
|
||||
if(cmd==="list_servers") return added?[{id:"1",name:"CoA-Repack",path:report.path}]:[];
|
||||
if(cmd==="scan_server") return report;
|
||||
if(cmd==="add_server"){added=true;return {id:"1",name:"CoA-Repack",path:report.path};}
|
||||
@@ -92,7 +96,7 @@ export {};
|
||||
if(cmd==="account_set_access"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.access=args.level;return null;}
|
||||
if(cmd==="account_set_password") return null;
|
||||
if(cmd==="account_rename"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.name=String(args.newName).toUpperCase();return null;}
|
||||
if(cmd==="client_info"){const c=(window as any).__client??"none";return c==="none"?null:{path:"C:\\games\\CoA Client",executable:"Ascension.exe",realmlists:[{path:"x",host:"127.0.0.1"}],addon:{installed:true,version:"1",up_to_date:true},other_addons:2};}
|
||||
if(cmd==="client_info"){const c=(window as any).__client??"none";return c==="none"?null:{path:"C:\\games\\CoA Client",executable:"Ascension.exe",realmlists:[{path:"x",host:args.id==="@remote-client"?remoteHost:"127.0.0.1"}],addon:{installed:true,version:"1",up_to_date:true},other_addons:2};}
|
||||
if(cmd==="set_client"){(window as any).__client="foreign";return {path:args.path,executable:"Ascension.exe",realmlists:[],addon:{installed:false,version:null,up_to_date:null},other_addons:0};}
|
||||
if(cmd==="plugin:event|listen"){const w=window as any;(w.__handlers??={})[args.event]=[...(w.__handlers[args.event]??[]),args.handler];return 1;}
|
||||
if(cmd==="plugin:event|unlisten") return null;
|
||||
|
||||
@@ -2,6 +2,21 @@ import type en from "./en";
|
||||
|
||||
/** Deutsch. Entwurf einer Übersetzung; vor der Veröffentlichung von Muttersprachlern zu prüfen. */
|
||||
const de: Partial<Record<keyof typeof en, string>> = {
|
||||
"remote.clientReady": "Dein Client ist bereit. Speichere die Hostadresse und klicke auf Spielen.",
|
||||
"remote.clientIntro": "Wähle einen vorhandenen Spielclient oder lade ihn hier herunter. Ein lokaler Server ist nicht nötig.",
|
||||
"welcome.host.title": "Einen Server hosten",
|
||||
"welcome.host.text": "Einen neuen Server installieren oder einen vorhandenen verwalten.",
|
||||
"welcome.connect.title": "Mit anderem Server verbinden",
|
||||
"welcome.connect.text": "Den Spielclient herunterladen oder aktualisieren und bei Freunden spielen.",
|
||||
"remote.intro": "Richte deinen Spielclient ein. Dein Freund betreibt den Server.",
|
||||
"remote.address": "Serveradresse",
|
||||
"remote.addressHint": "Für LAN-Spiele frage den Host nach der lokalen IP-Adresse seines Computers.",
|
||||
"remote.save": "Adresse speichern",
|
||||
"remote.saved": "Serveradresse gespeichert.",
|
||||
"remote.accountHint": "Bitte den Host, ein Spielkonto für dich anzulegen.",
|
||||
"remote.hostInstead": "Eigenen Server hosten",
|
||||
"welcome.roleSubtitle": "Wie möchtest du spielen?",
|
||||
|
||||
"nav.overview": "Übersicht",
|
||||
"nav.bots": "Bots",
|
||||
"nav.server": "Server",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
/** English is the source of truth: every key must exist here; other locales fall back to it. */
|
||||
const en = {
|
||||
"remote.clientReady": "Your client is ready. Save the host’s address, then press Play.",
|
||||
"remote.clientIntro": "Choose an existing game client or download one here. No local server is needed.",
|
||||
"welcome.host.title": "Host a server",
|
||||
"welcome.host.text": "Install a new server or manage one you already have.",
|
||||
"welcome.connect.title": "Connect to another server",
|
||||
"welcome.connect.text": "Download or update the game client and play on a friend’s server.",
|
||||
"remote.intro": "Set up your game client. Your friend runs the server.",
|
||||
"remote.address": "Server address",
|
||||
"remote.addressHint": "For LAN play, ask the host for their computer’s local IP address.",
|
||||
"remote.save": "Save address",
|
||||
"remote.saved": "Server address saved.",
|
||||
"remote.accountHint": "Ask the host to create a game account for you.",
|
||||
"remote.hostInstead": "Host my own server",
|
||||
"welcome.roleSubtitle": "How would you like to play?",
|
||||
|
||||
"realm.profileLabel": "Realm",
|
||||
"realm.startupTitle": "Realm startup",
|
||||
"realm.secondWorld": "Second world",
|
||||
|
||||
@@ -2,6 +2,21 @@ import type en from "./en";
|
||||
|
||||
/** Español. Traducción provisional; debe revisarla un hablante nativo antes de publicarse. */
|
||||
const es: Partial<Record<keyof typeof en, string>> = {
|
||||
"remote.clientReady": "Tu cliente está listo. Guarda la dirección del anfitrión y pulsa Jugar.",
|
||||
"remote.clientIntro": "Elige un cliente existente o descárgalo aquí. No necesitas un servidor local.",
|
||||
"welcome.host.title": "Alojar un servidor",
|
||||
"welcome.host.text": "Instalar un servidor nuevo o gestionar uno existente.",
|
||||
"welcome.connect.title": "Conectarse a otro servidor",
|
||||
"welcome.connect.text": "Descargar o actualizar el cliente y jugar en el servidor de un amigo.",
|
||||
"remote.intro": "Configura tu cliente. Tu amigo aloja el servidor.",
|
||||
"remote.address": "Dirección del servidor",
|
||||
"remote.addressHint": "Para jugar en LAN, pide al anfitrión la IP local de su ordenador.",
|
||||
"remote.save": "Guardar dirección",
|
||||
"remote.saved": "Dirección del servidor guardada.",
|
||||
"remote.accountHint": "Pide al anfitrión que te cree una cuenta de juego.",
|
||||
"remote.hostInstead": "Alojar mi servidor",
|
||||
"welcome.roleSubtitle": "¿Cómo quieres jugar?",
|
||||
|
||||
"nav.overview": "Resumen",
|
||||
"nav.bots": "Bots",
|
||||
"nav.server": "Servidor",
|
||||
|
||||
@@ -2,6 +2,21 @@ import type en from "./en";
|
||||
|
||||
/** Français. Traduction provisoire ; à faire relire par un locuteur natif avant la publication. */
|
||||
const fr: Partial<Record<keyof typeof en, string>> = {
|
||||
"remote.clientReady": "Votre client est prêt. Enregistrez l’adresse de l’hôte, puis cliquez sur Jouer.",
|
||||
"remote.clientIntro": "Choisissez un client existant ou téléchargez-le ici. Aucun serveur local n’est nécessaire.",
|
||||
"welcome.host.title": "Héberger un serveur",
|
||||
"welcome.host.text": "Installer un serveur ou gérer un serveur existant.",
|
||||
"welcome.connect.title": "Rejoindre un autre serveur",
|
||||
"welcome.connect.text": "Télécharger ou mettre à jour le client et jouer sur le serveur d’un ami.",
|
||||
"remote.intro": "Configurez votre client. Votre ami héberge le serveur.",
|
||||
"remote.address": "Adresse du serveur",
|
||||
"remote.addressHint": "Pour jouer en réseau local, demandez l’adresse IP locale de l’ordinateur hôte.",
|
||||
"remote.save": "Enregistrer l’adresse",
|
||||
"remote.saved": "Adresse du serveur enregistrée.",
|
||||
"remote.accountHint": "Demandez à l’hôte de créer un compte de jeu pour vous.",
|
||||
"remote.hostInstead": "Héberger mon serveur",
|
||||
"welcome.roleSubtitle": "Comment souhaitez-vous jouer ?",
|
||||
|
||||
"nav.overview": "Vue d’ensemble",
|
||||
"nav.bots": "Bots",
|
||||
"nav.server": "Serveur",
|
||||
|
||||
@@ -2,6 +2,21 @@ import type en from "./en";
|
||||
|
||||
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
|
||||
const ru: Partial<Record<keyof typeof en, string>> = {
|
||||
"remote.clientReady": "Клиент готов. Сохраните адрес хоста и нажмите «Играть».",
|
||||
"remote.clientIntro": "Выберите существующий клиент игры или скачайте его здесь. Локальный сервер не требуется.",
|
||||
"welcome.host.title": "Хочу быть хостом",
|
||||
"welcome.host.text": "Установить новый сервер или управлять существующим.",
|
||||
"welcome.connect.title": "Подключиться к другому серверу",
|
||||
"welcome.connect.text": "Скачать или обновить клиент и играть на сервере друга.",
|
||||
"remote.intro": "Настройте игровой клиент. Сервер запускает ваш друг.",
|
||||
"remote.address": "Адрес сервера",
|
||||
"remote.addressHint": "Для игры по локальной сети попросите у хоста локальный IP его компьютера.",
|
||||
"remote.save": "Сохранить адрес",
|
||||
"remote.saved": "Адрес сервера сохранён.",
|
||||
"remote.accountHint": "Попросите хоста создать для вас игровой аккаунт.",
|
||||
"remote.hostInstead": "Создать свой сервер",
|
||||
"welcome.roleSubtitle": "Как вы хотите играть?",
|
||||
|
||||
"realm.profileLabel": "Реалм",
|
||||
"realm.startupTitle": "Запуск реалмов",
|
||||
"realm.secondWorld": "Второй мир",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
/** Simplified Chinese localization (zh-CN) */
|
||||
const zh = {
|
||||
"remote.clientReady": "客户端已就绪。保存服主地址,然后点击开始游戏。",
|
||||
"remote.clientIntro": "选择已有游戏客户端,或在此下载。无需安装本地服务器。",
|
||||
"welcome.host.title": "我要搭建服务器",
|
||||
"welcome.host.text": "安装新服务器或管理已有服务器。",
|
||||
"welcome.connect.title": "连接其他服务器",
|
||||
"welcome.connect.text": "下载或更新游戏客户端,在朋友的服务器上游玩。",
|
||||
"remote.intro": "设置游戏客户端,服务器由朋友运行。",
|
||||
"remote.address": "服务器地址",
|
||||
"remote.addressHint": "在局域网中游玩时,请向服主询问其电脑的局域网 IP 地址。",
|
||||
"remote.save": "保存地址",
|
||||
"remote.saved": "服务器地址已保存。",
|
||||
"remote.accountHint": "请服主为你创建游戏账号。",
|
||||
"remote.hostInstead": "搭建自己的服务器",
|
||||
"welcome.roleSubtitle": "你想如何游玩?",
|
||||
|
||||
"realm.profileLabel": "服务器",
|
||||
"realm.switching": "正在切换服务器…",
|
||||
"realm.create": "创建 Wildcard 服务器",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export const REMOTE_CLIENT_ID = "@remote-client";
|
||||
export interface RemoteConnection { host: string; client_path: string | null }
|
||||
|
||||
export type Classification = "healthy" | "partial" | "unknown-custom" | "incompatible";
|
||||
export type ItemStatus = "found" | "missing" | "attention";
|
||||
|
||||
@@ -376,6 +379,8 @@ export interface ConsoleLine {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
remoteConnection: () => invoke<RemoteConnection>("remote_connection"),
|
||||
remoteConnect: (host: string) => invoke<RemoteConnection>("remote_connect", { host }),
|
||||
realmProfiles: (id: string) => invoke<RealmProfiles>("realm_profiles", { id }),
|
||||
realmSelect: (id: string, mode: RealmMode, restart: boolean) => invoke<RealmProfiles>("realm_select", { id, mode, restart }),
|
||||
realmSimultaneous: (id: string, enabled: boolean) => invoke<RealmProfiles>("realm_simultaneous", { id, enabled }),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useHuman, useT } from "@/i18n";
|
||||
|
||||
export function ClientCard({ serverId }: { serverId: string }) {
|
||||
export function ClientCard({ serverId, remote = false }: { serverId: string; remote?: boolean }) {
|
||||
const t = useT();
|
||||
const human = useHuman();
|
||||
const [info, setInfo] = useState<ClientInfo | null | undefined>(undefined);
|
||||
@@ -33,6 +33,7 @@ export function ClientCard({ serverId }: { serverId: string }) {
|
||||
const msg = await fn();
|
||||
if (msg) setNote(msg);
|
||||
await refresh();
|
||||
void checkClient(serverId);
|
||||
} catch (e) {
|
||||
setError(asUiError(e));
|
||||
} finally {
|
||||
@@ -54,7 +55,7 @@ export function ClientCard({ serverId }: { serverId: string }) {
|
||||
{info === undefined && <p className="mt-2 text-sm text-muted">{t("client.looking")}</p>}
|
||||
{info === null && (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-muted">{t("client.intro")}</p>
|
||||
<p className="mt-1 text-sm text-muted">{t(remote ? "remote.clientIntro" : "client.intro")}</p>
|
||||
<Button className="mt-4" onClick={() => setDialog("setup")} disabled={busy}>{t("btn.setupClient")}</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -65,7 +66,7 @@ export function ClientCard({ serverId }: { serverId: string }) {
|
||||
<dt className="text-muted">{t("client.connectsTo")}</dt>
|
||||
<dd>
|
||||
{hosts.length ? hosts.filter(Boolean).join(", ") : "—"}
|
||||
{pointsLocal ? <span className="ml-2 text-ok">{t("client.thisComputer")}</span> : <span className="ml-2 text-warn">{t("client.notThisServer")}</span>}
|
||||
{!remote && (pointsLocal ? <span className="ml-2 text-ok">{t("client.thisComputer")}</span> : <span className="ml-2 text-warn">{t("client.notThisServer")}</span>)}
|
||||
</dd>
|
||||
<dt className="text-muted">{t("client.version")}</dt>
|
||||
<dd>
|
||||
@@ -74,27 +75,29 @@ export function ClientCard({ serverId }: { serverId: string }) {
|
||||
: t("client.versionUntracked")}
|
||||
{status?.update_available && <span className="ml-2 text-warn">{t("client.updateAvailable")}</span>}
|
||||
</dd>
|
||||
{!remote && <>
|
||||
<dt className="text-muted">{t("client.addon")}</dt>
|
||||
<dd>
|
||||
{info.addon.installed ? (info.addon.version ? t("client.installedVersion", { v: info.addon.version }) : t("client.installed")) : t("client.addonNotInstalled")}
|
||||
{info.addon.up_to_date === false && <span className="ml-2 text-warn">{t("client.updateAvailable")}</span>}
|
||||
<span className="mt-1 block text-xs text-muted">{t("client.addonWhat")}</span>
|
||||
</dd>
|
||||
</>}
|
||||
<dt className="text-muted">{t("client.otherAddons")}</dt>
|
||||
<dd>{t("client.leftAsIs", { n: info.other_addons })}</dd>
|
||||
</dl>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{!pointsLocal && (
|
||||
{!remote && !pointsLocal && (
|
||||
<Button size="sm" disabled={busy} onClick={() => void run(async () => { await api.setRealmlist(serverId, "127.0.0.1"); return t("client.pointedNote"); })}>
|
||||
{t("client.pointLocal")}
|
||||
</Button>
|
||||
)}
|
||||
{(!info.addon.installed || info.addon.up_to_date === false) && (
|
||||
{!remote && (!info.addon.installed || info.addon.up_to_date === false) && (
|
||||
<Button size="sm" disabled={busy} onClick={() => void run(async () => { await api.installAddon(serverId); return t("client.addonNote"); })}>
|
||||
{info.addon.installed ? t("client.updateAddon") : t("client.installAddon")}
|
||||
</Button>
|
||||
)}
|
||||
{status?.update_available && (
|
||||
{(status?.update_available || (remote && status?.managed)) && (
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => setDialog("update")}>{t("client.upd.start")}</Button>
|
||||
)}
|
||||
{status && !status.managed && (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { api, asUiError, type ClientDownloadCheck, type ClientPlan, type ClientStep, type UiError } from "@/lib/api";
|
||||
import { api, asUiError, REMOTE_CLIENT_ID, type ClientDownloadCheck, type ClientPlan, type ClientStep, type UiError } from "@/lib/api";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -261,7 +261,7 @@ export function ClientDialog(props: {
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-ok/15 text-ok"><Check className="h-5 w-5" aria-hidden /></span>
|
||||
<div>
|
||||
<p className="font-medium">{mode === "setup" ? t("client.dl.done") : t("client.upd.done")}</p>
|
||||
<p className="text-sm text-muted">{mode === "setup" ? t("client.dl.doneText") : t("client.upd.doneText")}</p>
|
||||
<p className="text-sm text-muted">{mode === "setup" ? t(serverId === REMOTE_CLIENT_ID ? "remote.clientReady" : "client.dl.doneText") : t("client.upd.doneText")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Gauge, Settings } from "lucide-react";
|
||||
import { api, asUiError, REMOTE_CLIENT_ID } from "@/lib/api";
|
||||
import { checkClient, startClientPolling, useClientStatus } from "@/lib/clientUpdate";
|
||||
import { ClientCard } from "@/screens/ClientCard";
|
||||
import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog";
|
||||
import { AboutCard } from "@/screens/AboutCard";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useHuman, useT } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import logo from "@/assets/logo.png";
|
||||
|
||||
export function RemoteClient({ onHost }: { onHost: () => void }) {
|
||||
const t = useT();
|
||||
const human = useHuman();
|
||||
const [page, setPage] = useState<"overview" | "settings">("overview");
|
||||
const [host, setHost] = useState("");
|
||||
const [savedHost, setSavedHost] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [dialog, setDialog] = useState<ClientDialogMode | null>(null);
|
||||
const { status } = useClientStatus(REMOTE_CLIENT_ID);
|
||||
function fail(e: unknown) {
|
||||
const err = asUiError(e);
|
||||
setError(err.human.code === "unknown" ? err.technical : human(err.human).message);
|
||||
}
|
||||
useEffect(() => {
|
||||
void api.remoteConnection().then(p => { setHost(p.host); setSavedHost(p.host); }).catch(fail).finally(() => setLoading(false));
|
||||
return startClientPolling(REMOTE_CLIENT_ID);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { void checkClient(REMOTE_CLIENT_ID); }, [revision]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true); setError(null); setNote(null);
|
||||
try {
|
||||
const profile = await api.remoteConnect(host);
|
||||
setHost(profile.host); setSavedHost(profile.host); setRevision(n => n + 1);
|
||||
setNote(t("remote.saved"));
|
||||
} catch (e) { fail(e); } finally { setBusy(false); }
|
||||
}
|
||||
async function launch() {
|
||||
setBusy(true); setError(null); setNote(null);
|
||||
try { await api.play(REMOTE_CLIENT_ID); } catch (e) { fail(e); } finally { setBusy(false); }
|
||||
}
|
||||
function play() {
|
||||
if (!status?.linked) setDialog("setup");
|
||||
else if (status.update_available) setDialog("update");
|
||||
else void launch();
|
||||
}
|
||||
return <div className="flex h-full">
|
||||
<nav className="flex w-60 shrink-0 flex-col border-r border-line bg-[#0b0c0e] p-3" aria-label={t("nav.main")}>
|
||||
<div className="flex items-center gap-2 px-3 pb-4 pt-2 text-sm font-semibold tracking-wide text-gold"><img src={logo} alt="" className="h-7 w-7" />{t("app.name")}</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{(["overview", "settings"] as const).map(id => { const Icon = id === "overview" ? Gauge : Settings; return <li key={id}>
|
||||
<button onClick={() => setPage(id)} aria-current={page === id ? "page" : undefined} className={cn("flex h-10 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-[15px]", page === id ? "bg-white/[0.07] text-ink" : "text-muted hover:bg-white/5 hover:text-ink")}><Icon className="h-[18px] w-[18px]" aria-hidden />{t(id === "overview" ? "nav.overview" : "nav.settings")}</button>
|
||||
</li>; })}
|
||||
</ul>
|
||||
<div className="mt-auto border-t border-line pt-3"><Button variant="ghost" size="sm" className="w-full justify-start" onClick={onHost}>{t("remote.hostInstead")}</Button></div>
|
||||
</nav>
|
||||
<main className="h-full flex-1 overflow-y-auto px-10 py-8">
|
||||
{page === "settings" ? <><div className="flex items-center justify-between"><h1 className="text-2xl font-semibold">{t("nav.settings")}</h1><LanguagePicker /></div><AboutCard /></> : <>
|
||||
<div className="flex items-center justify-between"><h1 className="text-2xl font-semibold">{t("welcome.connect.title")}</h1><LanguagePicker /></div>
|
||||
<p className="mt-2 text-muted">{t("remote.intro")}</p>
|
||||
<Card className="mt-6 p-6">
|
||||
<form onSubmit={e => { e.preventDefault(); void save(); }}>
|
||||
<label htmlFor="remote-host" className="font-semibold">{t("remote.address")}</label>
|
||||
<p id="remote-host-hint" className="mt-1 text-sm text-muted">{t("remote.addressHint")}</p>
|
||||
<div className="mt-4 flex gap-3"><input id="remote-host" aria-describedby="remote-host-hint" value={host} onChange={e => { setHost(e.target.value); setNote(null); }} disabled={loading || busy} placeholder="192.168.1.10" className="min-w-0 flex-1 rounded-md border border-line bg-card px-3 py-2" /><Button type="submit" disabled={loading || busy || !host.trim()}>{t("remote.save")}</Button></div>
|
||||
</form>
|
||||
<p className="mt-3 text-sm text-muted">{t("remote.accountHint")}</p>
|
||||
<Button variant="primary" className="mt-4" disabled={loading || busy || !savedHost || host.trim() !== savedHost || status === null} onClick={play}>{t("btn.play")}</Button>
|
||||
{note && <p className="mt-3 text-sm text-ok" role="status">{note}</p>}
|
||||
{error && <p className="mt-3 text-sm text-bad" role="alert">{error}</p>}
|
||||
</Card>
|
||||
<ClientCard key={revision} serverId={REMOTE_CLIENT_ID} remote />
|
||||
</>}
|
||||
</main>
|
||||
{dialog && <ClientDialog serverId={REMOTE_CLIENT_ID} mode={dialog} onClose={() => setDialog(null)} onChanged={() => setRevision(n => n + 1)} onPlayAnyway={() => void launch()} />}
|
||||
</div>;
|
||||
}
|
||||
+21
-6
@@ -1,20 +1,34 @@
|
||||
import { FolderSearch, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { FolderSearch, Download, Server, Globe } from "lucide-react";
|
||||
import { useT } from "@/i18n";
|
||||
import logo from "@/assets/logo.png";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker";
|
||||
|
||||
export function Welcome({ onImport, onInstall, onBack }: { onImport: () => void; onInstall: () => void; onBack?: () => void }) {
|
||||
export function Welcome({ onImport, onInstall, onConnect, onBack }: { onImport: () => void; onInstall: () => void; onConnect: () => void; onBack?: () => void }) {
|
||||
const t = useT();
|
||||
const [hosting, setHosting] = useState(false);
|
||||
return (
|
||||
<main className="relative flex h-full flex-col items-center justify-center px-8">
|
||||
<LanguagePicker className="absolute right-6 top-5" />
|
||||
<div className="mb-10 text-center">
|
||||
<img src={logo} alt="" aria-hidden className="mx-auto mb-5 h-24 w-24 drop-shadow-[0_6px_24px_rgb(201_162_74/0.35)]" />
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{t("welcome.title")}</h1>
|
||||
<p className="mt-2 text-muted">{t("welcome.subtitle")}</p>
|
||||
<p className="mt-2 text-muted">{t(hosting ? "welcome.subtitle" : "welcome.roleSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-3xl grid-cols-2 gap-5">
|
||||
{!hosting ? <>
|
||||
<button onClick={() => setHosting(true)} className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left transition-colors hover:border-gold/60">
|
||||
<Server className="mb-4 h-7 w-7 text-gold" aria-hidden />
|
||||
<h2 className="text-lg font-semibold">{t("welcome.host.title")}</h2>
|
||||
<p className="mt-1 text-sm text-muted">{t("welcome.host.text")}</p>
|
||||
</button>
|
||||
<button onClick={onConnect} className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left transition-colors hover:border-gold/60">
|
||||
<Globe className="mb-4 h-7 w-7 text-gold" aria-hidden />
|
||||
<h2 className="text-lg font-semibold">{t("welcome.connect.title")}</h2>
|
||||
<p className="mt-1 text-sm text-muted">{t("welcome.connect.text")}</p>
|
||||
</button>
|
||||
</> : <>
|
||||
<button
|
||||
onClick={onInstall}
|
||||
className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left shadow-[0_8px_30px_rgb(0_0_0/0.28)] transition-colors hover:border-gold/60"
|
||||
@@ -34,10 +48,11 @@ export function Welcome({ onImport, onInstall, onBack }: { onImport: () => void;
|
||||
{t("welcome.import.text")}
|
||||
</p>
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
{onBack && (
|
||||
<button onClick={onBack} className="mt-8 cursor-pointer text-sm text-muted hover:text-ink">
|
||||
{t("welcome.back")}
|
||||
{(hosting || onBack) && (
|
||||
<button onClick={hosting ? () => setHosting(false) : onBack} className="mt-8 cursor-pointer text-sm text-muted hover:text-ink">
|
||||
{t(hosting ? "client.dl.back" : "welcome.back")}
|
||||
</button>
|
||||
)}
|
||||
</main>
|
||||
|
||||
Reference in new issue
Block a user