summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-12 21:35:25 +0200
committersalmonik <salmonik@tuta.io>2026-07-12 21:35:25 +0200
commit9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465 (patch)
tree2e638c274e0ab9dc4f3812e8ece6c603d854e1fd
parentfirst stable (diff)
downloadminecraft-ui-9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465.tar.gz
minecraft-ui-9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465.zip
second stable
-rw-r--r--Cargo.lock24
-rw-r--r--Cargo.toml2
-rw-r--r--src/main.rs1
-rw-r--r--src/properties.rs39
-rw-r--r--src/routes/backups.rs58
-rw-r--r--src/routes/instances.rs265
-rw-r--r--templates/backups/list.html3
-rw-r--r--templates/instances/edit.html30
8 files changed, 352 insertions, 70 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 749c521..c36739d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -192,6 +192,7 @@ dependencies = [
192 "matchit", 192 "matchit",
193 "memchr", 193 "memchr",
194 "mime", 194 "mime",
195 "multer",
195 "percent-encoding", 196 "percent-encoding",
196 "pin-project-lite", 197 "pin-project-lite",
197 "serde_core", 198 "serde_core",
@@ -1484,6 +1485,23 @@ dependencies = [
1484] 1485]
1485 1486
1486[[package]] 1487[[package]]
1488name = "multer"
1489version = "3.1.0"
1490source = "registry+https://github.com/rust-lang/crates.io-index"
1491checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
1492dependencies = [
1493 "bytes",
1494 "encoding_rs",
1495 "futures-util",
1496 "http",
1497 "httparse",
1498 "memchr",
1499 "mime",
1500 "spin",
1501 "version_check",
1502]
1503
1504[[package]]
1487name = "nu-ansi-term" 1505name = "nu-ansi-term"
1488version = "0.50.3" 1506version = "0.50.3"
1489source = "registry+https://github.com/rust-lang/crates.io-index" 1507source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2280,6 +2298,12 @@ dependencies = [
2280] 2298]
2281 2299
2282[[package]] 2300[[package]]
2301name = "spin"
2302version = "0.9.8"
2303source = "registry+https://github.com/rust-lang/crates.io-index"
2304checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
2305
2306[[package]]
2283name = "sqlite-wasm-rs" 2307name = "sqlite-wasm-rs"
2284version = "0.5.5" 2308version = "0.5.5"
2285source = "registry+https://github.com/rust-lang/crates.io-index" 2309source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index b13668d..828d45d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ version = "0.1.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
7axum = { version = "0.8.9", features = ["macros"] } 7axum = { version = "0.8.9", features = ["macros", "multipart"] }
8tokio = { version = "1.52.3", features = ["full"] } 8tokio = { version = "1.52.3", features = ["full"] }
9askama = "0.16" 9askama = "0.16"
10rusqlite = { version = "0.40.1", features = ["bundled"] } 10rusqlite = { version = "0.40.1", features = ["bundled"] }
diff --git a/src/main.rs b/src/main.rs
index a96b41b..8cbad70 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,6 +3,7 @@ mod config;
3mod db; 3mod db;
4mod docker; 4mod docker;
5mod models; 5mod models;
6mod properties;
6mod rcon; 7mod rcon;
7mod routes; 8mod routes;
8 9
diff --git a/src/properties.rs b/src/properties.rs
new file mode 100644
index 0000000..de391cc
--- /dev/null
+++ b/src/properties.rs
@@ -0,0 +1,39 @@
1use std::collections::HashMap;
2use std::path::Path;
3
4/// Parse a Java .properties file into key-value pairs.
5pub fn read_properties(path: &Path) -> HashMap<String, String> {
6 let mut map = HashMap::new();
7 let Ok(contents) = std::fs::read_to_string(path) else {
8 return map;
9 };
10 for line in contents.lines() {
11 let line = line.trim();
12 if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
13 continue;
14 }
15 if let Some((key, value)) = line.split_once('=') {
16 map.insert(key.trim().to_string(), value.trim().to_string());
17 } else if let Some((key, value)) = line.split_once(':') {
18 map.insert(key.trim().to_string(), value.trim().to_string());
19 }
20 }
21 map
22}
23
24/// Read the level-seed from an instance's server.properties.
25/// Returns None if the file doesn't exist or the seed is empty.
26pub fn read_instance_seed(server_dir: &Path, instance_name: &str) -> Option<String> {
27 let props_path = server_dir
28 .join("instances")
29 .join(instance_name)
30 .join("data")
31 .join("server.properties");
32 let props = read_properties(&props_path);
33 let seed = props.get("level-seed")?;
34 if seed.is_empty() {
35 None
36 } else {
37 Some(seed.clone())
38 }
39}
diff --git a/src/routes/backups.rs b/src/routes/backups.rs
index 09298d2..6b4dd49 100644
--- a/src/routes/backups.rs
+++ b/src/routes/backups.rs
@@ -37,7 +37,6 @@ pub fn router() -> Router<AppState> {
37 .route("/instances/{id}/backups/create", post(create_backup)) 37 .route("/instances/{id}/backups/create", post(create_backup))
38 .route("/backups/{id}/restore", post(restore_backup)) 38 .route("/backups/{id}/restore", post(restore_backup))
39 .route("/backups/{id}/download", get(download_backup)) 39 .route("/backups/{id}/download", get(download_backup))
40 .route("/instances/{id}/world/export", get(export_world))
41} 40}
42 41
43async fn list_backups( 42async fn list_backups(
@@ -197,60 +196,3 @@ async fn download_backup(
197 .body(Body::from(content)) 196 .body(Body::from(content))
198 .unwrap()) 197 .unwrap())
199} 198}
200
201async fn export_world(
202 State(state): State<AppState>,
203 Path(instance_id): Path<i64>,
204 CurrentUser(_user): CurrentUser,
205) -> Result<Response, (StatusCode, &'static str)> {
206 let conn = state.db.conn();
207 let instance_name: String = conn
208 .query_row(
209 "SELECT name FROM instances WHERE id = ?1",
210 params![instance_id],
211 |row| row.get(0),
212 )
213 .map_err(|_| (StatusCode::NOT_FOUND, "Instance not found"))?;
214 drop(conn);
215
216 let world_dir = state
217 .config
218 .server_directory
219 .join("instances")
220 .join(&instance_name)
221 .join("data")
222 .join("world");
223
224 let mut zip_buf = Vec::new();
225 {
226 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
227 let options: FileOptions<'_, ()> =
228 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
229
230 if let Ok(entries) = std::fs::read_dir(&world_dir) {
231 for entry in entries.flatten() {
232 let path = entry.path();
233 if path.is_file() {
234 if let Some(name) = path.file_name() {
235 if let Ok(contents) = std::fs::read(&path) {
236 let _ = zip.start_file(&*name.to_string_lossy(), options);
237 let _ = std::io::Write::write_all(&mut zip, &contents);
238 }
239 }
240 }
241 }
242 }
243 let _ = zip.finish();
244 }
245
246 let filename = format!("{instance_name}_world.zip");
247
248 Ok(Response::builder()
249 .header(header::CONTENT_TYPE, "application/zip")
250 .header(
251 header::CONTENT_DISPOSITION,
252 format!("attachment; filename=\"{filename}\""),
253 )
254 .body(Body::from(zip_buf))
255 .unwrap())
256}
diff --git a/src/routes/instances.rs b/src/routes/instances.rs
index 2da1319..e4f62f0 100644
--- a/src/routes/instances.rs
+++ b/src/routes/instances.rs
@@ -1,7 +1,7 @@
1use askama::Template; 1use askama::Template;
2use axum::{ 2use axum::{
3 Form, Router, 3 Form, Router,
4 extract::{Path, State}, 4 extract::{Multipart, Path, State},
5 response::{Html, Redirect}, 5 response::{Html, Redirect},
6 routing::{get, post}, 6 routing::{get, post},
7}; 7};
@@ -40,6 +40,7 @@ struct EditInstanceTemplate {
40 is_superuser: bool, 40 is_superuser: bool,
41 password_change_required: bool, 41 password_change_required: bool,
42 instance: Instance, 42 instance: Instance,
43 current_seed: Option<String>,
43 max_memory_mb: u32, 44 max_memory_mb: u32,
44 error: Option<String>, 45 error: Option<String>,
45} 46}
@@ -89,6 +90,9 @@ pub fn router() -> Router<AppState> {
89 .route("/instances/{id}/delete", post(delete_instance)) 90 .route("/instances/{id}/delete", post(delete_instance))
90 .route("/instances/{id}/activate", post(activate_instance)) 91 .route("/instances/{id}/activate", post(activate_instance))
91 .route("/instances/deactivate", post(deactivate_instance)) 92 .route("/instances/deactivate", post(deactivate_instance))
93 .route("/instances/{id}/world/export", get(export_world))
94 .route("/instances/{id}/world/import", post(import_world))
95 .route("/instances/{id}/world/regenerate", post(regenerate_world))
92} 96}
93 97
94async fn list_instances( 98async fn list_instances(
@@ -169,6 +173,7 @@ async fn create_instance(
169 let pvp = form.pvp.as_deref() == Some("on"); 173 let pvp = form.pvp.as_deref() == Some("on");
170 let motd = form.motd.unwrap_or_else(|| "A Minecraft Server".into()); 174 let motd = form.motd.unwrap_or_else(|| "A Minecraft Server".into());
171 let level_type = form.level_type.unwrap_or_else(|| "DEFAULT".into()); 175 let level_type = form.level_type.unwrap_or_else(|| "DEFAULT".into());
176 let seed = form.seed.filter(|s| !s.is_empty());
172 177
173 let conn = state.db.conn(); 178 let conn = state.db.conn();
174 conn.execute( 179 conn.execute(
@@ -180,7 +185,7 @@ async fn create_instance(
180 form.version, 185 form.version,
181 form.server_type, 186 form.server_type,
182 form.world_source, 187 form.world_source,
183 form.seed, 188 seed,
184 level_type, 189 level_type,
185 form.memory_mb, 190 form.memory_mb,
186 form.difficulty, 191 form.difficulty,
@@ -205,14 +210,15 @@ async fn create_instance(
205 Ok(Redirect::to("/instances")) 210 Ok(Redirect::to("/instances"))
206} 211}
207 212
213#[axum::debug_handler]
208async fn edit_form( 214async fn edit_form(
209 State(state): State<AppState>, 215 State(state): State<AppState>,
210 Path(id): Path<i64>, 216 Path(id): Path<i64>,
211 CurrentUser(account): CurrentUser, 217 CurrentUser(account): CurrentUser,
212) -> Result<Html<String>, axum::response::Redirect> { 218) -> Result<Html<String>, axum::response::Redirect> {
213 let conn = state.db.conn(); 219 let instance = {
214 let instance = conn 220 let conn = state.db.conn();
215 .query_row( 221 conn.query_row(
216 "SELECT id, name, version, server_type, world_source, world_seed, level_type, 222 "SELECT id, name, version, server_type, world_source, world_seed, level_type,
217 has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, 223 has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd,
218 rcon_password, is_active, created_at, updated_at 224 rcon_password, is_active, created_at, updated_at
@@ -241,14 +247,41 @@ async fn edit_form(
241 }) 247 })
242 }, 248 },
243 ) 249 )
244 .map_err(|_| Redirect::to("/instances"))?; 250 .map_err(|_| Redirect::to("/instances"))?
245 drop(conn); 251 };
252
253 let db_seed = instance.world_seed.clone().filter(|s| !s.is_empty());
254 let current_seed =
255 crate::properties::read_instance_seed(&state.config.server_directory, &instance.name)
256 .or(db_seed);
257
258 // If no seed known yet, try RCON /seed if the server is running
259 let current_seed = if current_seed.is_none() && instance.is_active {
260 let rcon = crate::rcon::RconClient::new(instance.rcon_password.clone());
261 match rcon.send_command_async("seed".into()).await {
262 Ok(resp) => {
263 let s = resp.trim();
264 // Format: "Seed: [1234567890]"
265 s.strip_prefix("Seed: [")
266 .and_then(|r| r.strip_suffix(']'))
267 .or_else(|| s.strip_prefix("Seed: "))
268 .map(|v| v.trim().to_string())
269 }
270 Err(e) => {
271 tracing::debug!("RCON /seed failed: {e}");
272 None
273 }
274 }
275 } else {
276 current_seed
277 };
246 278
247 let template = EditInstanceTemplate { 279 let template = EditInstanceTemplate {
248 username: account.username.clone(), 280 username: account.username.clone(),
249 is_superuser: account.is_superuser, 281 is_superuser: account.is_superuser,
250 password_change_required: account.password_change_required, 282 password_change_required: account.password_change_required,
251 instance, 283 instance,
284 current_seed,
252 max_memory_mb: state.config.max_memory_mb, 285 max_memory_mb: state.config.max_memory_mb,
253 error: None, 286 error: None,
254 }; 287 };
@@ -256,7 +289,6 @@ async fn edit_form(
256 Ok(Html(template.render().unwrap_or_default())) 289 Ok(Html(template.render().unwrap_or_default()))
257} 290}
258 291
259#[axum::debug_handler]
260async fn edit_instance( 292async fn edit_instance(
261 State(state): State<AppState>, 293 State(state): State<AppState>,
262 Path(id): Path<i64>, 294 Path(id): Path<i64>,
@@ -482,3 +514,220 @@ async fn fetch_minecraft_versions() -> Result<Vec<String>, String> {
482 514
483 Ok(versions) 515 Ok(versions)
484} 516}
517
518// ── World management ──────────────────────────────────────────────
519
520#[derive(Debug, Deserialize)]
521pub struct RegenerateWorldForm {
522 pub seed: Option<String>,
523}
524
525async fn export_world(
526 State(state): State<AppState>,
527 Path(id): Path<i64>,
528 CurrentUser(_user): CurrentUser,
529) -> Result<axum::response::Response, axum::response::Redirect> {
530 let conn = state.db.conn();
531 let instance_name: String = conn
532 .query_row(
533 "SELECT name FROM instances WHERE id = ?1",
534 params![id],
535 |row| row.get(0),
536 )
537 .map_err(|_| Redirect::to("/instances"))?;
538 drop(conn);
539
540 let world_dir = state
541 .config
542 .server_directory
543 .join("instances")
544 .join(&instance_name)
545 .join("data")
546 .join("world");
547
548 let mut zip_buf = Vec::new();
549 {
550 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
551 let options: zip::write::FileOptions<'_, ()> =
552 zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
553 add_dir_to_zip(&mut zip, &world_dir, "", options)?;
554 zip.finish().ok();
555 }
556
557 let filename = format!("{instance_name}_world.zip");
558 Ok(axum::response::Response::builder()
559 .header(axum::http::header::CONTENT_TYPE, "application/zip")
560 .header(
561 axum::http::header::CONTENT_DISPOSITION,
562 format!("attachment; filename=\"{filename}\""),
563 )
564 .body(axum::body::Body::from(zip_buf))
565 .unwrap())
566}
567
568fn add_dir_to_zip(
569 zip: &mut zip::ZipWriter<std::io::Cursor<&mut Vec<u8>>>,
570 dir: &std::path::Path,
571 prefix: &str,
572 options: zip::write::FileOptions<'_, ()>,
573) -> Result<(), axum::response::Redirect> {
574 if let Ok(entries) = std::fs::read_dir(dir) {
575 for entry in entries.flatten() {
576 let path = entry.path();
577 let name = path.file_name().unwrap().to_string_lossy();
578 let zip_path = if prefix.is_empty() {
579 name.to_string()
580 } else {
581 format!("{prefix}/{name}")
582 };
583 if path.is_dir() {
584 zip.add_directory(&zip_path, options).ok();
585 add_dir_to_zip(zip, &path, &zip_path, options)?;
586 } else if let Ok(contents) = std::fs::read(&path) {
587 zip.start_file(&zip_path, options).ok();
588 std::io::Write::write_all(zip, &contents).ok();
589 }
590 }
591 }
592 Ok(())
593}
594
595#[axum::debug_handler]
596async fn import_world(
597 State(state): State<AppState>,
598 Path(id): Path<i64>,
599 CurrentUser(_user): CurrentUser,
600 mut multipart: Multipart,
601) -> Result<Redirect, axum::response::Redirect> {
602 let (instance_name, is_active) = {
603 let conn = state.db.conn();
604 let row = conn.query_row(
605 "SELECT name, is_active FROM instances WHERE id = ?1",
606 params![id],
607 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)? != 0)),
608 );
609 match row {
610 Ok(v) => v,
611 Err(_) => return Ok(Redirect::to("/instances")),
612 }
613 };
614
615 if is_active {
616 return Ok(Redirect::to(&format!("/instances/{id}/edit")));
617 }
618
619 let data_dir = state
620 .config
621 .server_directory
622 .join("instances")
623 .join(&instance_name)
624 .join("data");
625
626 // Extract uploaded ZIP
627 while let Ok(Some(field)) = multipart.next_field().await {
628 let bytes = field
629 .bytes()
630 .await
631 .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?;
632 let cursor = std::io::Cursor::new(bytes);
633 let mut archive = zip::ZipArchive::new(cursor)
634 .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?;
635
636 // Remove existing world
637 let world_dir = data_dir.join("world");
638 let _ = std::fs::remove_dir_all(&world_dir);
639 let _ = std::fs::create_dir_all(&world_dir);
640
641 for i in 0..archive.len() {
642 let mut file = archive
643 .by_index(i)
644 .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?;
645 let out_path = match file.enclosed_name() {
646 Some(p) => world_dir.join(p),
647 None => continue,
648 };
649 if file.is_dir() {
650 let _ = std::fs::create_dir_all(&out_path);
651 } else {
652 if let Some(parent) = out_path.parent() {
653 let _ = std::fs::create_dir_all(parent);
654 }
655 let mut out = std::fs::File::create(&out_path)
656 .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?;
657 std::io::copy(&mut file, &mut out).ok();
658 }
659 }
660 break; // only process first file
661 }
662
663 Ok(Redirect::to(&format!("/instances/{id}/edit")))
664}
665
666async fn regenerate_world(
667 State(state): State<AppState>,
668 Path(id): Path<i64>,
669 CurrentUser(_user): CurrentUser,
670 Form(form): Form<RegenerateWorldForm>,
671) -> Result<Redirect, axum::response::Redirect> {
672 let conn = state.db.conn();
673 let (instance_name, is_active): (String, bool) = conn
674 .query_row(
675 "SELECT name, is_active FROM instances WHERE id = ?1",
676 params![id],
677 |row| Ok((row.get(0)?, row.get::<_, i32>(1)? != 0)),
678 )
679 .map_err(|_| Redirect::to("/instances"))?;
680 drop(conn);
681
682 if is_active {
683 return Ok(Redirect::to(&format!("/instances/{id}/edit")));
684 }
685
686 let props_path = state
687 .config
688 .server_directory
689 .join("instances")
690 .join(&instance_name)
691 .join("data")
692 .join("server.properties");
693
694 // Update level-seed in server.properties
695 let seed = form.seed.unwrap_or_default();
696 let contents = std::fs::read_to_string(&props_path).unwrap_or_default();
697 let mut new_lines = Vec::new();
698 let mut found = false;
699 for line in contents.lines() {
700 if line.trim_start().starts_with("level-seed") {
701 new_lines.push(format!("level-seed={seed}"));
702 found = true;
703 } else {
704 new_lines.push(line.to_string());
705 }
706 }
707 if !found {
708 new_lines.push(format!("level-seed={seed}"));
709 }
710 let _ = std::fs::write(&props_path, new_lines.join("\n"));
711
712 // Update DB record
713 let db_seed: Option<&str> = if seed.is_empty() { None } else { Some(&seed) };
714 let conn = state.db.conn();
715 conn.execute(
716 "UPDATE instances SET world_seed = ?1, updated_at = datetime('now') WHERE id = ?2",
717 params![db_seed, id],
718 )
719 .ok();
720 drop(conn);
721
722 // Delete existing world so it gets regenerated on next start
723 let world_dir = state
724 .config
725 .server_directory
726 .join("instances")
727 .join(&instance_name)
728 .join("data")
729 .join("world");
730 let _ = std::fs::remove_dir_all(&world_dir);
731
732 Ok(Redirect::to(&format!("/instances/{id}/edit")))
733}
diff --git a/templates/backups/list.html b/templates/backups/list.html
index d7508eb..d97e98d 100644
--- a/templates/backups/list.html
+++ b/templates/backups/list.html
@@ -6,7 +6,6 @@
6 <form action="/instances/{{ instance_id }}/backups/create" method="post" style="display:inline"> 6 <form action="/instances/{{ instance_id }}/backups/create" method="post" style="display:inline">
7 <button type="submit" class="btn-ok">Create Backup</button> 7 <button type="submit" class="btn-ok">Create Backup</button>
8 </form> 8 </form>
9 <a href="/instances/{{ instance_id }}/world/export" class="btn">Export World</a>
10 <a href="/instances" class="btn">Back to Instances</a> 9 <a href="/instances" class="btn">Back to Instances</a>
11 </div> 10 </div>
12</div> 11</div>
@@ -30,7 +29,7 @@
30 <td>{{ backup.filename }}</td> 29 <td>{{ backup.filename }}</td>
31 <td>{{ backup.created_at }}</td> 30 <td>{{ backup.created_at }}</td>
32 <td> 31 <td>
33 <a href="/backups/{{ backup.id }}/download" class="btn-sm">Download</a> 32 <a href="/backups/{{ backup.id }}/download" class="btn-sm" hx-boost="false">Download</a>
34 <form action="/backups/{{ backup.id }}/restore" method="post" style="display:inline"> 33 <form action="/backups/{{ backup.id }}/restore" method="post" style="display:inline">
35 <button type="submit" class="btn-sm btn-ok">Restore</button> 34 <button type="submit" class="btn-sm btn-ok">Restore</button>
36 </form> 35 </form>
diff --git a/templates/instances/edit.html b/templates/instances/edit.html
index 356c7cd..b6c982c 100644
--- a/templates/instances/edit.html
+++ b/templates/instances/edit.html
@@ -7,7 +7,7 @@
7 <span><strong>Version:</strong> {{ instance.version }}</span> 7 <span><strong>Version:</strong> {{ instance.version }}</span>
8 <span><strong>Type:</strong> {{ instance.server_type }}</span> 8 <span><strong>Type:</strong> {{ instance.server_type }}</span>
9 <span><strong>World:</strong> {{ instance.world_source }}</span> 9 <span><strong>World:</strong> {{ instance.world_source }}</span>
10 {% if let Some(seed) = instance.world_seed %} 10 {% if let Some(seed) = current_seed %}
11 <span><strong>Seed:</strong> {{ seed }}</span> 11 <span><strong>Seed:</strong> {{ seed }}</span>
12 {% endif %} 12 {% endif %}
13 {% if instance.has_modpack %} 13 {% if instance.has_modpack %}
@@ -59,4 +59,32 @@
59 <a href="/instances" class="btn">Cancel</a> 59 <a href="/instances" class="btn">Cancel</a>
60 </div> 60 </div>
61</form> 61</form>
62
63<div class="card" style="margin-top:1.5rem">
64 <h3>World Management</h3>
65
66 <div class="meta" style="margin-bottom:1rem">
67 <span><strong>Current seed:</strong>
68 {% if let Some(seed) = current_seed %}
69 {{ seed }}
70 {% else %}
71 <em>not yet generated</em>
72 {% endif %}
73 </span>
74 </div>
75
76 <div class="actions" style="flex-wrap:wrap; gap:1rem">
77 <a href="/instances/{{ instance.id }}/world/export" class="btn" hx-boost="false">Export World</a>
78
79 <form action="/instances/{{ instance.id }}/world/import" method="post" enctype="multipart/form-data" style="display:flex;align-items:center;gap:0.5rem" hx-boost="false">
80 <input type="file" name="world_zip" accept=".zip" required style="font-size:0.85rem">
81 <button type="submit" class="btn" onclick="return confirm('This will replace the current world. Continue?')">Import</button>
82 </form>
83
84 <form action="/instances/{{ instance.id }}/world/regenerate" method="post" class="form-row" onsubmit="return confirm('This will DELETE the current world and generate a new one. Continue?')">
85 <input type="text" name="seed" placeholder="Seed (empty = random)" style="width:180px">
86 <button type="submit" class="btn-danger">Regenerate</button>
87 </form>
88 </div>
89</div>
62{% endblock %} 90{% endblock %}