feat: portable play settings, improved offline messaging, and system tray support

This commit is contained in:
Дмитрий committed 2026-10-28 08:14:36 +05:00
1 parent 925a81f4f2
commit 88747c8da1
13 files changed
+154 -8

No files matched your search

@@ -0,0 +1,7 @@
---
area: manager
type: added
audience: admins
title: Control portable characters and keep Manager running in the system tray
---
Realm access settings can enable portable characters. Closing the window keeps Manager in the tray, with commands to reopen or exit. Offline realm messages explain why players cannot connect.
+11
View File
@@ -445,6 +445,17 @@
"max": 3600000, "max": 3600000,
"unit": "ms" "unit": "ms"
}, },
{
"key": "PortableSession.Enable",
"type": "bool",
"category": "characters",
"title": "Portable characters",
"description": "Allow players to play on this realm with portable characters saved in their Manager.",
"default": true,
"advanced": false,
"restartRequired": "world",
"dangerous": false
},
{ {
"key": "CharactersPerAccount", "key": "CharactersPerAccount",
"type": "int", "type": "int",
+1 -1
View File
@@ -12,7 +12,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
+70 -3
View File
@@ -2729,6 +2729,8 @@ async fn console_command(
.await .await
} }
static QUIT_REQUESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn run() { pub fn run() {
let dir = data_dir(); let dir = data_dir();
let _ = coa_core::logging::init(&dir.join("logs").join("manager.log")); let _ = coa_core::logging::init(&dir.join("logs").join("manager.log"));
@@ -2881,11 +2883,75 @@ pub fn run() {
control_cmds::join_characters, control_cmds::join_characters,
control_cmds::join_claim control_cmds::join_claim
]) ])
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.setup(|app| {
#[cfg(desktop)]
{
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::Manager;
let show = MenuItem::with_id(app, "show", "Open CoA Server Manager", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Exit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
let mut builder = TrayIconBuilder::new()
.tooltip("CoA Server Manager")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
"quit" => {
QUIT_REQUESTED.store(true, std::sync::atomic::Ordering::SeqCst);
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
});
if let Some(icon) = app.default_window_icon() {
builder = builder.icon(icon.clone());
}
builder.build(app)?;
}
Ok(())
})
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building CoA Server Manager") .expect("error while building CoA Server Manager")
.run(|app, event| { .run(|app, event| match event {
// the Host loop stops with the application: what it is doing is finished first, the stores keep every session tauri::RunEvent::ExitRequested { api, .. } => {
if let tauri::RunEvent::Exit = event { if !QUIT_REQUESTED.load(std::sync::atomic::Ordering::SeqCst) {
api.prevent_exit();
}
}
tauri::RunEvent::Exit => {
// the Host loop stops with the application: what it is doing is finished first, the stores keep every session
if let Some(rt) = app.state::<AppState>().portable.clone() { if let Some(rt) = app.state::<AppState>().portable.clone() {
rt.shutdown(); rt.shutdown();
} }
@@ -2896,6 +2962,7 @@ pub fn run() {
rt.shutdown(); rt.shutdown();
} }
} }
_ => {}
}); });
} }
+6 -1
View File
@@ -1006,7 +1006,7 @@ const en = {
"portable.note.blocked_ruleset": "This character is for another kind of realm.", "portable.note.blocked_ruleset": "This character is for another kind of realm.",
"portable.note.blocked_format": "This realm reads another version of the character format. Update the Manager or the realm.", "portable.note.blocked_format": "This realm reads another version of the character format. Update the Manager or the realm.",
"portable.note.blocked_online_import": "This realm cannot take characters while it runs. Update the realm.", "portable.note.blocked_online_import": "This realm cannot take characters while it runs. Update the realm.",
"portable.note.blocked_sessions": "This realm cannot follow a game session. Ask its owner to enable portable play.", "portable.note.blocked_sessions": "This realm cannot follow a game session. The server or host's Server Manager is offline, or portable play is not enabled.",
"portable.note.blocked_client_data": "The game data the Manager sees is not the data the realm loaded.", "portable.note.blocked_client_data": "The game data the Manager sees is not the data the realm loaded.",
"portable.note.blocked_progression": "The realm’s level cap is not known yet or cannot be handled. Start the realm and try again.", "portable.note.blocked_progression": "The realm’s level cap is not known yet or cannot be handled. Start the realm and try again.",
"portable.note.realm_offline": "The realm cannot be reached.", "portable.note.realm_offline": "The realm cannot be reached.",
@@ -1101,6 +1101,8 @@ const en = {
"browse.noCharacter": "You have no portable character yet. Make one portable on the Characters page to see whether it can play here.", "browse.noCharacter": "You have no portable character yet. Make one portable on the Characters page to see whether it can play here.",
"browse.canPlay": "Can play", "browse.canPlay": "Can play",
"browse.cannotPlay": "Cannot play here", "browse.cannotPlay": "Cannot play here",
"browse.realmOffline": "Offline",
"browse.offlineWarning": "The server is currently offline. Joining with a portable character will become available once the host starts the server.",
"browse.play": "Play", "browse.play": "Play",
"browse.playSoon": "Joining arrives with the account support of the next step.", "browse.playSoon": "Joining arrives with the account support of the next step.",
"registry.title": "Public listing", "registry.title": "Public listing",
@@ -1121,6 +1123,7 @@ const en = {
"registry.state.starting": "Starting to publish…", "registry.state.starting": "Starting to publish…",
"registry.state.waiting_for_realm": "Waiting for the realm to be ready…", "registry.state.waiting_for_realm": "Waiting for the realm to be ready…",
"registry.state.realm_stopped": "The realm is not running; it is listed as offline.", "registry.state.realm_stopped": "The realm is not running; it is listed as offline.",
"registry.realmStoppedHint": "The realm is currently stopped. Start the server so players can join and track character sessions.",
"registry.state.online": "Published and online.", "registry.state.online": "Published and online.",
"registry.state.retrying": "The Registry could not be reached; trying again. Your realm is not affected.", "registry.state.retrying": "The Registry could not be reached; trying again. Your realm is not affected.",
"registry.state.rejected": "The Registry refused this realm. Publishing is stopped.", "registry.state.rejected": "The Registry refused this realm. Publishing is stopped.",
@@ -1180,6 +1183,8 @@ const en = {
"join.err.unavailable": "The server could not do that right now.", "join.err.unavailable": "The server could not do that right now.",
"access.title": "Joining players", "access.title": "Joining players",
"access.text": "How players who find this realm in the list get in.", "access.text": "How players who find this realm in the list get in.",
"access.portableEnabled": "Allow portable characters (portable play)",
"access.portableEnabledHint": "Allows players with Manager-saved characters to play on this realm (PortableSession.Enable).",
"access.existingOnly": "Do not create accounts for new players", "access.existingOnly": "Do not create accounts for new players",
"access.existingOnlyHint": "Players can only link an account they already have on this realm.", "access.existingOnlyHint": "Players can only link an account they already have on this realm.",
"access.route": "Address players connect to", "access.route": "Address players connect to",
+6 -1
View File
@@ -988,7 +988,7 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"portable.note.blocked_ruleset": "Этот персонаж — для реалма другого типа.", "portable.note.blocked_ruleset": "Этот персонаж — для реалма другого типа.",
"portable.note.blocked_format": "Реалм читает другую версию формата персонажа. Обновите Менеджер или реалм.", "portable.note.blocked_format": "Реалм читает другую версию формата персонажа. Обновите Менеджер или реалм.",
"portable.note.blocked_online_import": "Этот реалм не принимает персонажей, пока работает. Обновите реалм.", "portable.note.blocked_online_import": "Этот реалм не принимает персонажей, пока работает. Обновите реалм.",
"portable.note.blocked_sessions": "Этот реалм не может следить за игровой сессией. Попросите владельца включить переносимую игру.", "portable.note.blocked_sessions": "Этот реалм не может следить за игровой сессией. Сервер или Server Manager хоста выключен, либо в ядре не включена переносимая игра.",
"portable.note.blocked_client_data": "Игровые данные, которые видит Менеджер, не те, что загрузил реалм.", "portable.note.blocked_client_data": "Игровые данные, которые видит Менеджер, не те, что загрузил реалм.",
"portable.note.blocked_progression": "Предел уровня реалма пока неизвестен или не поддерживается. Запустите реалм и попробуйте снова.", "portable.note.blocked_progression": "Предел уровня реалма пока неизвестен или не поддерживается. Запустите реалм и попробуйте снова.",
"portable.note.realm_offline": "Реалм недоступен.", "portable.note.realm_offline": "Реалм недоступен.",
@@ -1083,6 +1083,8 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"browse.noCharacter": "У вас пока нет переносимого персонажа. Сделайте его переносимым на странице «Персонажи», чтобы увидеть, сможет ли он играть здесь.", "browse.noCharacter": "У вас пока нет переносимого персонажа. Сделайте его переносимым на странице «Персонажи», чтобы увидеть, сможет ли он играть здесь.",
"browse.canPlay": "Можно играть", "browse.canPlay": "Можно играть",
"browse.cannotPlay": "Здесь играть нельзя", "browse.cannotPlay": "Здесь играть нельзя",
"browse.realmOffline": "Оффлайн",
"browse.offlineWarning": "Сервер сейчас не запущен хостом. Игра переносимым персонажем станет доступна, когда владелец включит сервер.",
"browse.play": "Играть", "browse.play": "Играть",
"browse.playSoon": "Вход на сервер появится вместе с поддержкой аккаунтов на следующем шаге.", "browse.playSoon": "Вход на сервер появится вместе с поддержкой аккаунтов на следующем шаге.",
"registry.title": "Публичный список", "registry.title": "Публичный список",
@@ -1103,6 +1105,7 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"registry.state.starting": "Начинаю публикацию…", "registry.state.starting": "Начинаю публикацию…",
"registry.state.waiting_for_realm": "Жду, пока реалм будет готов…", "registry.state.waiting_for_realm": "Жду, пока реалм будет готов…",
"registry.state.realm_stopped": "Реалм не запущен; в списке он отмечен как недоступный.", "registry.state.realm_stopped": "Реалм не запущен; в списке он отмечен как недоступный.",
"registry.realmStoppedHint": "Сервер сейчас выключен. Запустите реалм, чтобы игроки могли подключаться и отслеживать сессии персонажей.",
"registry.state.online": "Опубликован и доступен.", "registry.state.online": "Опубликован и доступен.",
"registry.state.retrying": "Реестр недоступен, пробую снова. На ваш реалм это не влияет.", "registry.state.retrying": "Реестр недоступен, пробую снова. На ваш реалм это не влияет.",
"registry.state.rejected": "Реестр отклонил этот реалм. Публикация остановлена.", "registry.state.rejected": "Реестр отклонил этот реалм. Публикация остановлена.",
@@ -1162,6 +1165,8 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"join.err.unavailable": "Сервер сейчас не смог этого сделать.", "join.err.unavailable": "Сервер сейчас не смог этого сделать.",
"access.title": "Вход игроков", "access.title": "Вход игроков",
"access.text": "Как попадают на этот мир игроки, нашедшие его в списке.", "access.text": "Как попадают на этот мир игроки, нашедшие его в списке.",
"access.portableEnabled": "Разрешить вход переносимым персонажам (переносимая игра)",
"access.portableEnabledHint": "Позволяет игрокам с сохранёнными в Менеджере персонажами играть на этом сервере (PortableSession.Enable).",
"access.existingOnly": "Не создавать аккаунты для новых игроков", "access.existingOnly": "Не создавать аккаунты для новых игроков",
"access.existingOnlyHint": "Игроки смогут только привязать аккаунт, который у них уже есть на этом мире.", "access.existingOnlyHint": "Игроки смогут только привязать аккаунт, который у них уже есть на этом мире.",
"access.route": "Адрес для подключения игроков", "access.route": "Адрес для подключения игроков",
+4
View File
@@ -380,6 +380,10 @@
"t": "Autospeicher-Intervall", "t": "Autospeicher-Intervall",
"d": "Wie oft Spielercharaktere gespeichert werden, solange sie online sind." "d": "Wie oft Spielercharaktere gespeichert werden, solange sie online sind."
}, },
"PortableSession.Enable": {
"t": "Portable Charaktere (portables Spiel)",
"d": "Erlaubt Spielern, mit ihren im Manager gespeicherten portablen Charakteren auf diesem Realm zu spielen."
},
"CharactersPerAccount": { "CharactersPerAccount": {
"t": "Charaktere pro Konto", "t": "Charaktere pro Konto",
"d": "Maximale Zahl an Charakteren, die ein Konto über alle Realms hat." "d": "Maximale Zahl an Charakteren, die ein Konto über alle Realms hat."
+4
View File
@@ -380,6 +380,10 @@
"t": "Intervalo de autoguardado", "t": "Intervalo de autoguardado",
"d": "Con qué frecuencia se guardan los personajes de los jugadores mientras están conectados." "d": "Con qué frecuencia se guardan los personajes de los jugadores mientras están conectados."
}, },
"PortableSession.Enable": {
"t": "Personajes portátiles (juego portátil)",
"d": "Permite a los jugadores jugar en este reino con personajes portátiles guardados en su Manager."
},
"CharactersPerAccount": { "CharactersPerAccount": {
"t": "Personajes por cuenta", "t": "Personajes por cuenta",
"d": "Máximo de personajes que puede tener una cuenta entre todos los reinos." "d": "Máximo de personajes que puede tener una cuenta entre todos los reinos."
+4
View File
@@ -380,6 +380,10 @@
"t": "Intervalle de sauvegarde automatique", "t": "Intervalle de sauvegarde automatique",
"d": "Fréquence de sauvegarde des personnages des joueurs pendant qu’ils sont en ligne." "d": "Fréquence de sauvegarde des personnages des joueurs pendant qu’ils sont en ligne."
}, },
"PortableSession.Enable": {
"t": "Personnages portables (jeu portable)",
"d": "Permet aux joueurs de jouer sur ce royaume avec des personnages portables enregistrés dans leur Manager."
},
"CharactersPerAccount": { "CharactersPerAccount": {
"t": "Personnages par compte", "t": "Personnages par compte",
"d": "Nombre maximal de personnages qu’un compte peut avoir sur tous les royaumes." "d": "Nombre maximal de personnages qu’un compte peut avoir sur tous les royaumes."
+4
View File
@@ -380,6 +380,10 @@
"t": "Интервал автосохранения", "t": "Интервал автосохранения",
"d": "Как часто персонажи игроков сохраняются, пока они в сети." "d": "Как часто персонажи игроков сохраняются, пока они в сети."
}, },
"PortableSession.Enable": {
"t": "Переносимые персонажи (переносимая игра)",
"d": "Разрешает игрокам заходить на этот сервер переносимыми персонажами, сохранёнными в их Менеджере."
},
"CharactersPerAccount": { "CharactersPerAccount": {
"t": "Персонажей на учётную запись", "t": "Персонажей на учётную запись",
"d": "Наибольшее число персонажей на учётной записи во всех мирах." "d": "Наибольшее число персонажей на учётной записи во всех мирах."
+4
View File
@@ -380,6 +380,10 @@
"t": "角色数据自动保存间隔", "t": "角色数据自动保存间隔",
"d": "在线玩家角色数据写入数据库的时间间隔。" "d": "在线玩家角色数据写入数据库的时间间隔。"
}, },
"PortableSession.Enable": {
"t": "便携式角色(跨服漫游)",
"d": "允许玩家使用保存在管理器中的便携式角色进入此服务器进行游玩。"
},
"CharactersPerAccount": { "CharactersPerAccount": {
"t": "每个账号最大角色数", "t": "每个账号最大角色数",
"d": "单个账号在所有服务器可创建的角色总数上限。" "d": "单个账号在所有服务器可创建的角色总数上限。"
+2
View File
@@ -117,6 +117,7 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:
<h2 className="truncate text-lg font-semibold" data-testid="drawer-title">{summary.display_name}</h2> <h2 className="truncate text-lg font-semibold" data-testid="drawer-title">{summary.display_name}</h2>
<p className="mt-0.5 text-sm text-muted"><Mode ruleset={summary.ruleset} /> · {t("browse.cap")} {summary.level_cap ?? "—"}</p> <p className="mt-0.5 text-sm text-muted"><Mode ruleset={summary.ruleset} /> · {t("browse.cap")} {summary.level_cap ?? "—"}</p>
<p className="text-sm"><Online p={summary.population} />{summary.population.capacity !== null && <span className="text-muted"> · {t("browse.capacity", { n: summary.population.capacity })}</span>}</p> <p className="text-sm"><Online p={summary.population} />{summary.population.capacity !== null && <span className="text-muted"> · {t("browse.capacity", { n: summary.population.capacity })}</span>}</p>
{!summary.online && <span className="mt-1 inline-block rounded bg-bad/15 px-2 py-0.5 text-xs font-medium text-bad">{t("browse.realmOffline")}</span>}
</div> </div>
<button onClick={onClose} aria-label={t("portable.close")} className="cursor-pointer text-muted hover:text-ink"><X className="h-5 w-5" /></button> <button onClick={onClose} aria-label={t("portable.close")} className="cursor-pointer text-muted hover:text-ink"><X className="h-5 w-5" /></button>
</div> </div>
@@ -180,6 +181,7 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:
<p className="font-medium">{blocked ? t("browse.cannotPlay") : t("browse.canPlay")}</p> <p className="font-medium">{blocked ? t("browse.cannotPlay") : t("browse.canPlay")}</p>
{pre.projection && <Projection from={pre.projection[0]} to={pre.projection[1]} cap={summary.level_cap} />} {pre.projection && <Projection from={pre.projection[0]} to={pre.projection[1]} cap={summary.level_cap} />}
<Notes notes={pre.notes} /> <Notes notes={pre.notes} />
{!summary.online && <p className="mt-2 border-t border-line/40 pt-2 text-xs text-warn">{t("browse.offlineWarning")}</p>}
</div>} </div>}
</>} </>}
</section> </section>
+31 -2
View File
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useT, type Key } from "@/i18n"; import { useT, type Key } from "@/i18n";
import { asUiError } from "@/lib/api"; import { asUiError, api } from "@/lib/api";
import { realmRegistry, useRegistryStatus } from "@/lib/registry"; import { realmRegistry, useRegistryStatus } from "@/lib/registry";
import { asControlError, control, type ControlStatus } from "@/lib/control"; import { asControlError, control, type ControlStatus } from "@/lib/control";
@@ -25,12 +25,25 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [allowPortable, setAllowPortable] = useState(false); const [allowPortable, setAllowPortable] = useState(false);
const [existingOnly, setExistingOnly] = useState(false); const [existingOnly, setExistingOnly] = useState(false);
const [portableEnabled, setPortableEnabled] = useState(true);
const [route, setRoute] = useState(""); const [route, setRoute] = useState("");
const [accessSaved, setAccessSaved] = useState(false); const [accessSaved, setAccessSaved] = useState(false);
const [ctl, setCtl] = useState<ControlStatus | null>(null); const [ctl, setCtl] = useState<ControlStatus | null>(null);
const host = ctl?.hosting.find((h) => h.local_id === localId); const host = ctl?.hosting.find((h) => h.local_id === localId);
const link = host?.link; const link = host?.link;
useEffect(() => {
let active = true;
void api.allSettings(serverId).then((items) => {
if (!active) return;
const item = items.find((i) => i.key === "PortableSession.Enable");
if (item) {
setPortableEnabled(item.value === "1" || item.value.toLowerCase() === "true");
}
}).catch(() => {});
return () => { active = false; };
}, [serverId]);
useEffect(() => { useEffect(() => {
const load = () => void control.status().then(setCtl).catch(() => {}); const load = () => void control.status().then(setCtl).catch(() => {});
load(); load();
@@ -41,7 +54,21 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server
async function saveAccess() { async function saveAccess() {
setBusy(true); setError(null); setAccessSaved(false); setBusy(true); setError(null); setAccessSaved(false);
try { await control.setAccess(localId, existingOnly, allowPortable, route.trim() === "" ? null : route.trim()); await refresh(); setAccessSaved(true); } catch (e) { setError(asControlError(e).message); } finally { setBusy(false); } try {
await Promise.all([
control.setAccess(localId, existingOnly, allowPortable, route.trim() === "" ? null : route.trim()),
api.allSettingsSave(serverId, {
"PortableSession.Enable": portableEnabled ? "1" : "0",
...(portableEnabled ? { "CharacterDatabase.WorkerThreads": "1" } : {}),
}),
]);
await refresh();
setAccessSaved(true);
} catch (e) {
setError(asControlError(e).message);
} finally {
setBusy(false);
}
} }
useEffect(() => { useEffect(() => {
@@ -85,6 +112,7 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server
{mine && (published || mine.state === "unpublishing") && <div className="mt-4 rounded-md border border-line p-3 text-sm" role="status"> {mine && (published || mine.state === "unpublishing") && <div className="mt-4 rounded-md border border-line p-3 text-sm" role="status">
<p className={cn("font-medium", tone)} data-state={mine.state}>{t(`registry.state.${mine.state}` as Key)}</p> <p className={cn("font-medium", tone)} data-state={mine.state}>{t(`registry.state.${mine.state}` as Key)}</p>
<p className="mt-1 text-xs text-muted">{mine.display_name}{mine.metadata_revision ? ` · ${t("registry.revision", { n: mine.metadata_revision })}` : ""}</p> <p className="mt-1 text-xs text-muted">{mine.display_name}{mine.metadata_revision ? ` · ${t("registry.revision", { n: mine.metadata_revision })}` : ""}</p>
{mine.state === "realm_stopped" && <p className="mt-2 text-xs text-warn">{t("registry.realmStoppedHint")}</p>}
{mine.last_error && <p className="mt-1 text-xs text-warn">{mine.last_error}</p>} {mine.last_error && <p className="mt-1 text-xs text-warn">{mine.last_error}</p>}
{mine.state === "retrying" && mine.retry_in_secs !== null && <p className="mt-1 text-xs text-muted">{t("registry.retryIn", { s: mine.retry_in_secs })}</p>} {mine.state === "retrying" && mine.retry_in_secs !== null && <p className="mt-1 text-xs text-muted">{t("registry.retryIn", { s: mine.retry_in_secs })}</p>}
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
@@ -95,6 +123,7 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server
{mine && (published || mine.state === "unpublishing") && <div className="mt-4 rounded-md border border-line p-3 text-sm" data-testid="access-options"> {mine && (published || mine.state === "unpublishing") && <div className="mt-4 rounded-md border border-line p-3 text-sm" data-testid="access-options">
<p className="font-medium">{t("access.title")}</p> <p className="font-medium">{t("access.title")}</p>
<p className="mt-1 text-xs text-muted">{t("access.text")}</p> <p className="mt-1 text-xs text-muted">{t("access.text")}</p>
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={portableEnabled} onChange={(e) => setPortableEnabled(e.target.checked)} className="mt-1" data-testid="portable-enabled" /><span>{t("access.portableEnabled")}<span className="block text-xs text-muted">{t("access.portableEnabledHint")}</span></span></label>
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={existingOnly} onChange={(e) => setExistingOnly(e.target.checked)} className="mt-1" data-testid="existing-only" /><span>{t("access.existingOnly")}<span className="block text-xs text-muted">{t("access.existingOnlyHint")}</span></span></label> <label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={existingOnly} onChange={(e) => setExistingOnly(e.target.checked)} className="mt-1" data-testid="existing-only" /><span>{t("access.existingOnly")}<span className="block text-xs text-muted">{t("access.existingOnlyHint")}</span></span></label>
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={allowPortable} onChange={(e) => setAllowPortable(e.target.checked)} className="mt-1" data-testid="allow-portable" /><span>{t("access.allowPortable")}<span className="block text-xs text-muted">{t("access.allowPortableHint")}</span></span></label> <label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={allowPortable} onChange={(e) => setAllowPortable(e.target.checked)} className="mt-1" data-testid="allow-portable" /><span>{t("access.allowPortable")}<span className="block text-xs text-muted">{t("access.allowPortableHint")}</span></span></label>
<label htmlFor="access-route" className="mt-3 block font-medium">{t("access.route")}</label> <label htmlFor="access-route" className="mt-3 block font-medium">{t("access.route")}</label>