From 431d6b2d21ee2fa84cec515002d81e966292ac61 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 30 Sep 2026 06:13:25 -0400 Subject: [PATCH 1/2] feature: access card to restrict who can join a community server --- components/events/EventSearch.vue | 153 +++++ components/servers/ServerAccess.vue | 529 ++++++++++++++++++ i18n/locales/ar_SA.json | 23 + i18n/locales/da_DK.json | 23 + i18n/locales/de_DE.json | 23 + i18n/locales/en.json | 23 + i18n/locales/es_ES.json | 23 + i18n/locales/fr_FR.json | 23 + i18n/locales/it_IT.json | 23 + i18n/locales/ja_JP.json | 23 + i18n/locales/ko_KR.json | 23 + i18n/locales/pl_PL.json | 23 + i18n/locales/pt_BR.json | 23 + i18n/locales/ru_RU.json | 23 + i18n/locales/sv_SE.json | 23 + i18n/locales/tr_TR.json | 23 + i18n/locales/uk_UA.json | 23 + i18n/locales/zh_Hans.json | 23 + i18n/locales/zh_Hant.json | 23 + pages/dedicated-servers/[id].vue | 24 +- tests/components/servers/ServerAccess.spec.ts | 98 ++++ 21 files changed, 1193 insertions(+), 2 deletions(-) create mode 100644 components/events/EventSearch.vue create mode 100644 components/servers/ServerAccess.vue create mode 100644 tests/components/servers/ServerAccess.spec.ts diff --git a/components/events/EventSearch.vue b/components/events/EventSearch.vue new file mode 100644 index 00000000..113b67e2 --- /dev/null +++ b/components/events/EventSearch.vue @@ -0,0 +1,153 @@ + + + diff --git a/components/servers/ServerAccess.vue b/components/servers/ServerAccess.vue new file mode 100644 index 00000000..e88d09a6 --- /dev/null +++ b/components/servers/ServerAccess.vue @@ -0,0 +1,529 @@ + + + diff --git a/i18n/locales/ar_SA.json b/i18n/locales/ar_SA.json index 0ebf1403..b61930e4 100644 --- a/i18n/locales/ar_SA.json +++ b/i18n/locales/ar_SA.json @@ -3751,6 +3751,29 @@ "browse": "دليل الإضافات", "saved": "تم حفظ الإضافات. تتم إعادة تشغيل الخادم.", "saved_disabled": "تم حفظ الإضافات. ستُطبَّق عند تفعيل الخادم." + }, + "access": { + "title": "الوصول", + "description": "اختر من يمكنه الاتصال. يُخفى الخادم المقيّد عن الجميع، وترفضهم إضافة Player Management عند الاتصال.", + "open": "مفتوح", + "restricted": "مقيّد", + "open_hint": "يمكن لأي شخص العثور على هذا الخادم والانضمام إليه.", + "plugin_missing": "لم تتواصل إضافة Player Management من هذا الخادم خلال آخر 3 دقائق، لذا لا يُمنع أحد بعد.", + "staff": "يمكن للمشرفين ومن فوقهم الانضمام دائمًا.", + "min_role": "الحد الأدنى للدور", + "no_role": "بلا قاعدة دور", + "role_and_up": "{role} فما فوق", + "players": "اللاعبون", + "add_player": "إضافة لاعب", + "already_added": "موجود في القائمة بالفعل", + "events": "الفعاليات", + "add_event": "إضافة فعالية", + "events_hint": "يمكن لأعضاء الفعالية الانضمام فقط أثناء انعقادها.", + "no_events": "لم يتم العثور على فعاليات", + "remove": "إزالة", + "save": "حفظ", + "save_hint": "يُطبَّق خلال ثوانٍ. يُزال اللاعبون غير المسموح لهم.", + "saved": "تم حفظ الوصول. سيطبقه الخادم خلال ثوانٍ." } } }, diff --git a/i18n/locales/da_DK.json b/i18n/locales/da_DK.json index 153d3d11..159d8c9d 100644 --- a/i18n/locales/da_DK.json +++ b/i18n/locales/da_DK.json @@ -3751,6 +3751,29 @@ "browse": "Plugin-oversigt", "saved": "Plugins gemt. Serveren genstarter.", "saved_disabled": "Plugins gemt. De gælder, når serveren er slået til." + }, + "access": { + "title": "Adgang", + "description": "Vælg hvem der kan forbinde. En begrænset server er skjult for alle andre, og Player Management-pluginnet afviser dem ved forbindelse.", + "open": "Åben", + "restricted": "Begrænset", + "open_hint": "Alle kan finde og joine denne server.", + "plugin_missing": "Player Management-pluginnet har ikke meldt sig fra denne server de sidste 3 minutter, så ingen holdes ude endnu.", + "staff": "Moderatorer og derover kan altid joine.", + "min_role": "Minimumsrolle", + "no_role": "Ingen rolleregel", + "role_and_up": "{role} og derover", + "players": "Spillere", + "add_player": "Tilføj en spiller", + "already_added": "Allerede på listen", + "events": "Events", + "add_event": "Tilføj et event", + "events_hint": "Eventmedlemmer kan kun joine, mens eventet er i gang.", + "no_events": "Ingen events fundet", + "remove": "Fjern", + "save": "Gem", + "save_hint": "Træder i kraft inden for sekunder. Spillere uden adgang fjernes.", + "saved": "Adgang gemt. Serveren anvender den inden for sekunder." } } }, diff --git a/i18n/locales/de_DE.json b/i18n/locales/de_DE.json index a2970d88..a2746cc5 100644 --- a/i18n/locales/de_DE.json +++ b/i18n/locales/de_DE.json @@ -3751,6 +3751,29 @@ "browse": "Plugin-Verzeichnis", "saved": "Plugins gespeichert. Der Server startet neu.", "saved_disabled": "Plugins gespeichert. Sie gelten, sobald der Server aktiviert ist." + }, + "access": { + "title": "Zugang", + "description": "Lege fest, wer sich verbinden darf. Ein eingeschränkter Server ist für alle anderen ausgeblendet, und das Player-Management-Plugin weist sie beim Verbinden ab.", + "open": "Offen", + "restricted": "Eingeschränkt", + "open_hint": "Jeder kann diesen Server finden und beitreten.", + "plugin_missing": "Das Player-Management-Plugin hat sich in den letzten 3 Minuten nicht von diesem Server gemeldet, daher wird noch niemand ferngehalten.", + "staff": "Moderatoren und höher können immer beitreten.", + "min_role": "Mindestrolle", + "no_role": "Keine Rollenregel", + "role_and_up": "{role} und höher", + "players": "Spieler", + "add_player": "Spieler hinzufügen", + "already_added": "Bereits auf der Liste", + "events": "Events", + "add_event": "Event hinzufügen", + "events_hint": "Event-Mitglieder können nur beitreten, solange das Event läuft.", + "no_events": "Keine Events gefunden", + "remove": "Entfernen", + "save": "Speichern", + "save_hint": "Gilt innerhalb von Sekunden. Nicht zugelassene Spieler werden entfernt.", + "saved": "Zugang gespeichert. Der Server übernimmt ihn innerhalb von Sekunden." } } }, diff --git a/i18n/locales/en.json b/i18n/locales/en.json index b12a1424..e0109973 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -3751,6 +3751,29 @@ "browse": "Plugin Directory", "saved": "Plugins saved. The server is restarting.", "saved_disabled": "Plugins saved. They apply when the server is enabled." + }, + "access": { + "title": "Access", + "description": "Choose who can connect. A restricted server is hidden from everyone else, and the Player Management plugin refuses them at connect.", + "open": "Open", + "restricted": "Restricted", + "open_hint": "Anyone can find and join this server.", + "plugin_missing": "The Player Management plugin hasn't checked in from this server in the last 3 minutes, so nobody is kept out yet.", + "staff": "Moderators and above can always join.", + "min_role": "Minimum Role", + "no_role": "No role rule", + "role_and_up": "{role} and above", + "players": "Players", + "add_player": "Add a player", + "already_added": "Already on the list", + "events": "Events", + "add_event": "Add an event", + "events_hint": "Event members can join only while the event is live.", + "no_events": "No events found", + "remove": "Remove", + "save": "Save", + "save_hint": "Applies within seconds. Players who aren't allowed are removed.", + "saved": "Access saved. The server applies it within seconds." } } }, diff --git a/i18n/locales/es_ES.json b/i18n/locales/es_ES.json index a1fcfbd7..0a2e8312 100644 --- a/i18n/locales/es_ES.json +++ b/i18n/locales/es_ES.json @@ -3751,6 +3751,29 @@ "browse": "Directorio de plugins", "saved": "Plugins guardados. El servidor se está reiniciando.", "saved_disabled": "Plugins guardados. Se aplicarán cuando el servidor esté activado." + }, + "access": { + "title": "Acceso", + "description": "Elige quién puede conectarse. Un servidor restringido se oculta a todos los demás y el plugin Player Management los rechaza al conectar.", + "open": "Abierto", + "restricted": "Restringido", + "open_hint": "Cualquiera puede encontrar este servidor y unirse.", + "plugin_missing": "El plugin Player Management no ha dado señales desde este servidor en los últimos 3 minutos, así que todavía no se deja fuera a nadie.", + "staff": "Los moderadores y superiores siempre pueden unirse.", + "min_role": "Rol mínimo", + "no_role": "Sin regla de rol", + "role_and_up": "{role} y superiores", + "players": "Jugadores", + "add_player": "Añadir un jugador", + "already_added": "Ya está en la lista", + "events": "Eventos", + "add_event": "Añadir un evento", + "events_hint": "Los miembros del evento solo pueden unirse mientras el evento está en curso.", + "no_events": "No se encontraron eventos", + "remove": "Quitar", + "save": "Guardar", + "save_hint": "Se aplica en segundos. Los jugadores no permitidos son expulsados.", + "saved": "Acceso guardado. El servidor lo aplica en segundos." } } }, diff --git a/i18n/locales/fr_FR.json b/i18n/locales/fr_FR.json index 353e4054..d2542c6f 100644 --- a/i18n/locales/fr_FR.json +++ b/i18n/locales/fr_FR.json @@ -3751,6 +3751,29 @@ "browse": "Répertoire des plugins", "saved": "Plugins enregistrés. Le serveur redémarre.", "saved_disabled": "Plugins enregistrés. Ils s'appliqueront quand le serveur sera activé." + }, + "access": { + "title": "Accès", + "description": "Choisissez qui peut se connecter. Un serveur restreint est masqué pour tous les autres, et le plugin Player Management les refuse à la connexion.", + "open": "Ouvert", + "restricted": "Restreint", + "open_hint": "Tout le monde peut trouver ce serveur et le rejoindre.", + "plugin_missing": "Le plugin Player Management ne s'est pas manifesté depuis ce serveur ces 3 dernières minutes, personne n'est donc encore tenu à l'écart.", + "staff": "Les modérateurs et au-dessus peuvent toujours rejoindre.", + "min_role": "Rôle minimum", + "no_role": "Aucune règle de rôle", + "role_and_up": "{role} et au-dessus", + "players": "Joueurs", + "add_player": "Ajouter un joueur", + "already_added": "Déjà dans la liste", + "events": "Événements", + "add_event": "Ajouter un événement", + "events_hint": "Les membres d'un événement ne peuvent rejoindre que pendant l'événement.", + "no_events": "Aucun événement trouvé", + "remove": "Retirer", + "save": "Enregistrer", + "save_hint": "S'applique en quelques secondes. Les joueurs non autorisés sont retirés.", + "saved": "Accès enregistré. Le serveur l'applique en quelques secondes." } } }, diff --git a/i18n/locales/it_IT.json b/i18n/locales/it_IT.json index 7777cb39..94ea7a56 100644 --- a/i18n/locales/it_IT.json +++ b/i18n/locales/it_IT.json @@ -3751,6 +3751,29 @@ "browse": "Elenco plugin", "saved": "Plugin salvati. Il server si sta riavviando.", "saved_disabled": "Plugin salvati. Si applicheranno quando il server sarà attivato." + }, + "access": { + "title": "Accesso", + "description": "Scegli chi può connettersi. Un server riservato è nascosto a tutti gli altri e il plugin Player Management li rifiuta alla connessione.", + "open": "Aperto", + "restricted": "Riservato", + "open_hint": "Chiunque può trovare questo server ed entrare.", + "plugin_missing": "Il plugin Player Management non si è fatto vivo da questo server negli ultimi 3 minuti, quindi nessuno viene ancora tenuto fuori.", + "staff": "I moderatori e superiori possono sempre entrare.", + "min_role": "Ruolo minimo", + "no_role": "Nessuna regola sul ruolo", + "role_and_up": "{role} e superiori", + "players": "Giocatori", + "add_player": "Aggiungi un giocatore", + "already_added": "Già nella lista", + "events": "Eventi", + "add_event": "Aggiungi un evento", + "events_hint": "I membri dell'evento possono entrare solo mentre l'evento è in corso.", + "no_events": "Nessun evento trovato", + "remove": "Rimuovi", + "save": "Salva", + "save_hint": "Si applica in pochi secondi. I giocatori non ammessi vengono rimossi.", + "saved": "Accesso salvato. Il server lo applica in pochi secondi." } } }, diff --git a/i18n/locales/ja_JP.json b/i18n/locales/ja_JP.json index c6917a2b..1ca22d75 100644 --- a/i18n/locales/ja_JP.json +++ b/i18n/locales/ja_JP.json @@ -3751,6 +3751,29 @@ "browse": "プラグインディレクトリ", "saved": "プラグインを保存しました。サーバーを再起動しています。", "saved_disabled": "プラグインを保存しました。サーバーを有効にすると適用されます。" + }, + "access": { + "title": "アクセス", + "description": "接続できるプレイヤーを選びます。制限付きサーバーはそれ以外の人には表示されず、Player Management プラグインが接続時に拒否します。", + "open": "オープン", + "restricted": "制限付き", + "open_hint": "誰でもこのサーバーを見つけて参加できます。", + "plugin_missing": "Player Management プラグインがこのサーバーから過去 3 分間応答していないため、まだ誰も締め出されていません。", + "staff": "モデレーター以上はいつでも参加できます。", + "min_role": "最低ロール", + "no_role": "ロールの条件なし", + "role_and_up": "{role} 以上", + "players": "プレイヤー", + "add_player": "プレイヤーを追加", + "already_added": "すでにリストにあります", + "events": "イベント", + "add_event": "イベントを追加", + "events_hint": "イベントのメンバーはイベント開催中のみ参加できます。", + "no_events": "イベントが見つかりません", + "remove": "削除", + "save": "保存", + "save_hint": "数秒で適用されます。許可されていないプレイヤーは退出させられます。", + "saved": "アクセスを保存しました。サーバーは数秒で適用します。" } } }, diff --git a/i18n/locales/ko_KR.json b/i18n/locales/ko_KR.json index 1ee12192..372ae911 100644 --- a/i18n/locales/ko_KR.json +++ b/i18n/locales/ko_KR.json @@ -3751,6 +3751,29 @@ "browse": "플러그인 디렉터리", "saved": "플러그인을 저장했습니다. 서버를 재시작하는 중입니다.", "saved_disabled": "플러그인을 저장했습니다. 서버를 활성화하면 적용됩니다." + }, + "access": { + "title": "접근", + "description": "접속할 수 있는 사람을 선택합니다. 제한된 서버는 다른 모든 사람에게 숨겨지며, Player Management 플러그인이 접속 시 거부합니다.", + "open": "공개", + "restricted": "제한됨", + "open_hint": "누구나 이 서버를 찾아 참가할 수 있습니다.", + "plugin_missing": "Player Management 플러그인이 지난 3분 동안 이 서버에서 응답하지 않아 아직 아무도 차단되지 않습니다.", + "staff": "모더레이터 이상은 항상 참가할 수 있습니다.", + "min_role": "최소 역할", + "no_role": "역할 조건 없음", + "role_and_up": "{role} 이상", + "players": "플레이어", + "add_player": "플레이어 추가", + "already_added": "이미 목록에 있음", + "events": "이벤트", + "add_event": "이벤트 추가", + "events_hint": "이벤트 멤버는 이벤트가 진행 중일 때만 참가할 수 있습니다.", + "no_events": "이벤트를 찾을 수 없습니다", + "remove": "제거", + "save": "저장", + "save_hint": "몇 초 안에 적용됩니다. 허용되지 않은 플레이어는 퇴장됩니다.", + "saved": "접근 설정을 저장했습니다. 서버가 몇 초 안에 적용합니다." } } }, diff --git a/i18n/locales/pl_PL.json b/i18n/locales/pl_PL.json index b0f18b59..5c931cbe 100644 --- a/i18n/locales/pl_PL.json +++ b/i18n/locales/pl_PL.json @@ -3751,6 +3751,29 @@ "browse": "Katalog pluginów", "saved": "Zapisano pluginy. Serwer uruchamia się ponownie.", "saved_disabled": "Zapisano pluginy. Zadziałają po włączeniu serwera." + }, + "access": { + "title": "Dostęp", + "description": "Wybierz, kto może się łączyć. Serwer z ograniczonym dostępem jest ukryty dla wszystkich innych, a plugin Player Management odrzuca ich przy łączeniu.", + "open": "Otwarty", + "restricted": "Ograniczony", + "open_hint": "Każdy może znaleźć ten serwer i dołączyć.", + "plugin_missing": "Plugin Player Management nie odezwał się z tego serwera przez ostatnie 3 minuty, więc nikt nie jest jeszcze blokowany.", + "staff": "Moderatorzy i wyżej mogą zawsze dołączyć.", + "min_role": "Minimalna rola", + "no_role": "Brak reguły roli", + "role_and_up": "{role} i wyżej", + "players": "Gracze", + "add_player": "Dodaj gracza", + "already_added": "Już na liście", + "events": "Wydarzenia", + "add_event": "Dodaj wydarzenie", + "events_hint": "Członkowie wydarzenia mogą dołączyć tylko w trakcie wydarzenia.", + "no_events": "Nie znaleziono wydarzeń", + "remove": "Usuń", + "save": "Zapisz", + "save_hint": "Działa w ciągu kilku sekund. Gracze bez dostępu są usuwani.", + "saved": "Zapisano dostęp. Serwer zastosuje go w ciągu kilku sekund." } } }, diff --git a/i18n/locales/pt_BR.json b/i18n/locales/pt_BR.json index a9ebc6ef..872e2c26 100644 --- a/i18n/locales/pt_BR.json +++ b/i18n/locales/pt_BR.json @@ -3751,6 +3751,29 @@ "browse": "Diretório de plugins", "saved": "Plugins salvos. O servidor está reiniciando.", "saved_disabled": "Plugins salvos. Eles valem quando o servidor for ativado." + }, + "access": { + "title": "Acesso", + "description": "Escolha quem pode se conectar. Um servidor restrito fica oculto para todos os outros, e o plugin Player Management os recusa ao conectar.", + "open": "Aberto", + "restricted": "Restrito", + "open_hint": "Qualquer pessoa pode encontrar e entrar neste servidor.", + "plugin_missing": "O plugin Player Management não deu sinal deste servidor nos últimos 3 minutos, então ninguém está sendo barrado ainda.", + "staff": "Moderadores e acima sempre podem entrar.", + "min_role": "Cargo mínimo", + "no_role": "Sem regra de cargo", + "role_and_up": "{role} e acima", + "players": "Jogadores", + "add_player": "Adicionar um jogador", + "already_added": "Já está na lista", + "events": "Eventos", + "add_event": "Adicionar um evento", + "events_hint": "Membros do evento só podem entrar enquanto o evento estiver ao vivo.", + "no_events": "Nenhum evento encontrado", + "remove": "Remover", + "save": "Salvar", + "save_hint": "Vale em segundos. Jogadores sem permissão são removidos.", + "saved": "Acesso salvo. O servidor aplica em segundos." } } }, diff --git a/i18n/locales/ru_RU.json b/i18n/locales/ru_RU.json index 6ffec69b..3bd6c8ba 100644 --- a/i18n/locales/ru_RU.json +++ b/i18n/locales/ru_RU.json @@ -3751,6 +3751,29 @@ "browse": "Каталог плагинов", "saved": "Плагины сохранены. Сервер перезапускается.", "saved_disabled": "Плагины сохранены. Они применятся, когда сервер будет включён." + }, + "access": { + "title": "Доступ", + "description": "Выберите, кто может подключаться. Сервер с ограниченным доступом скрыт от всех остальных, а плагин Player Management не пускает их при подключении.", + "open": "Открыт", + "restricted": "Ограничен", + "open_hint": "Любой может найти этот сервер и подключиться.", + "plugin_missing": "Плагин Player Management не выходил на связь с этого сервера последние 3 минуты, поэтому пока никого не блокирует.", + "staff": "Модераторы и выше могут подключаться всегда.", + "min_role": "Минимальная роль", + "no_role": "Без правила роли", + "role_and_up": "{role} и выше", + "players": "Игроки", + "add_player": "Добавить игрока", + "already_added": "Уже в списке", + "events": "События", + "add_event": "Добавить событие", + "events_hint": "Участники события могут подключаться только во время события.", + "no_events": "События не найдены", + "remove": "Удалить", + "save": "Сохранить", + "save_hint": "Применяется за секунды. Игроков без доступа удаляет с сервера.", + "saved": "Доступ сохранён. Сервер применит его за секунды." } } }, diff --git a/i18n/locales/sv_SE.json b/i18n/locales/sv_SE.json index e5335cf8..26ebddb9 100644 --- a/i18n/locales/sv_SE.json +++ b/i18n/locales/sv_SE.json @@ -3751,6 +3751,29 @@ "browse": "Pluginkatalog", "saved": "Plugins sparade. Servern startas om.", "saved_disabled": "Plugins sparade. De gäller när servern är aktiverad." + }, + "access": { + "title": "Åtkomst", + "description": "Välj vem som får ansluta. En begränsad server döljs för alla andra, och Player Management-pluginet nekar dem vid anslutning.", + "open": "Öppen", + "restricted": "Begränsad", + "open_hint": "Vem som helst kan hitta och gå med på den här servern.", + "plugin_missing": "Player Management-pluginet har inte hört av sig från den här servern de senaste 3 minuterna, så ingen hålls ute ännu.", + "staff": "Moderatorer och uppåt kan alltid gå med.", + "min_role": "Lägsta roll", + "no_role": "Ingen rollregel", + "role_and_up": "{role} och uppåt", + "players": "Spelare", + "add_player": "Lägg till en spelare", + "already_added": "Redan på listan", + "events": "Evenemang", + "add_event": "Lägg till ett evenemang", + "events_hint": "Evenemangsmedlemmar kan bara gå med medan evenemanget pågår.", + "no_events": "Inga evenemang hittades", + "remove": "Ta bort", + "save": "Spara", + "save_hint": "Gäller inom några sekunder. Spelare utan åtkomst tas bort.", + "saved": "Åtkomst sparad. Servern tillämpar den inom några sekunder." } } }, diff --git a/i18n/locales/tr_TR.json b/i18n/locales/tr_TR.json index 37785837..fa4f0dd9 100644 --- a/i18n/locales/tr_TR.json +++ b/i18n/locales/tr_TR.json @@ -3751,6 +3751,29 @@ "browse": "Eklenti Dizini", "saved": "Eklentiler kaydedildi. Sunucu yeniden başlatılıyor.", "saved_disabled": "Eklentiler kaydedildi. Sunucu etkinleştirildiğinde uygulanır." + }, + "access": { + "title": "Erişim", + "description": "Kimlerin bağlanabileceğini seç. Kısıtlı bir sunucu diğer herkesten gizlenir ve Player Management eklentisi onları bağlanırken reddeder.", + "open": "Açık", + "restricted": "Kısıtlı", + "open_hint": "Herkes bu sunucuyu bulup katılabilir.", + "plugin_missing": "Player Management eklentisi son 3 dakikadır bu sunucudan haber vermedi, bu yüzden henüz kimse dışarıda tutulmuyor.", + "staff": "Moderatörler ve üstü her zaman katılabilir.", + "min_role": "En Düşük Rol", + "no_role": "Rol kuralı yok", + "role_and_up": "{role} ve üstü", + "players": "Oyuncular", + "add_player": "Oyuncu ekle", + "already_added": "Zaten listede", + "events": "Etkinlikler", + "add_event": "Etkinlik ekle", + "events_hint": "Etkinlik üyeleri yalnızca etkinlik sürerken katılabilir.", + "no_events": "Etkinlik bulunamadı", + "remove": "Kaldır", + "save": "Kaydet", + "save_hint": "Saniyeler içinde uygulanır. İzni olmayan oyuncular çıkarılır.", + "saved": "Erişim kaydedildi. Sunucu bunu saniyeler içinde uygular." } } }, diff --git a/i18n/locales/uk_UA.json b/i18n/locales/uk_UA.json index 972d0dc6..051dbd64 100644 --- a/i18n/locales/uk_UA.json +++ b/i18n/locales/uk_UA.json @@ -3751,6 +3751,29 @@ "browse": "Каталог плагінів", "saved": "Плагіни збережено. Сервер перезапускається.", "saved_disabled": "Плагіни збережено. Вони застосуються, коли сервер буде увімкнено." + }, + "access": { + "title": "Доступ", + "description": "Виберіть, хто може підключатися. Сервер з обмеженим доступом прихований від усіх інших, а плагін Player Management не пускає їх під час підключення.", + "open": "Відкритий", + "restricted": "Обмежений", + "open_hint": "Будь-хто може знайти цей сервер і приєднатися.", + "plugin_missing": "Плагін Player Management не виходив на зв'язок з цього сервера останні 3 хвилини, тож поки нікого не блокує.", + "staff": "Модератори й вище можуть приєднуватися завжди.", + "min_role": "Мінімальна роль", + "no_role": "Без правила ролі", + "role_and_up": "{role} і вище", + "players": "Гравці", + "add_player": "Додати гравця", + "already_added": "Вже у списку", + "events": "Події", + "add_event": "Додати подію", + "events_hint": "Учасники події можуть приєднатися лише під час події.", + "no_events": "Подій не знайдено", + "remove": "Вилучити", + "save": "Зберегти", + "save_hint": "Діє за кілька секунд. Гравців без доступу буде видалено.", + "saved": "Доступ збережено. Сервер застосує його за кілька секунд." } } }, diff --git a/i18n/locales/zh_Hans.json b/i18n/locales/zh_Hans.json index f1156637..d09fe242 100644 --- a/i18n/locales/zh_Hans.json +++ b/i18n/locales/zh_Hans.json @@ -3751,6 +3751,29 @@ "browse": "插件目录", "saved": "插件已保存,服务器正在重启。", "saved_disabled": "插件已保存,将在服务器启用后生效。" + }, + "access": { + "title": "访问权限", + "description": "选择谁可以连接。受限服务器对其他所有人隐藏,Player Management 插件会在连接时拒绝他们。", + "open": "开放", + "restricted": "受限", + "open_hint": "任何人都可以找到并加入此服务器。", + "plugin_missing": "Player Management 插件在过去 3 分钟内没有从此服务器报到,因此目前还没有阻止任何人。", + "staff": "版主及以上角色始终可以加入。", + "min_role": "最低角色", + "no_role": "无角色规则", + "role_and_up": "{role} 及以上", + "players": "玩家", + "add_player": "添加玩家", + "already_added": "已在列表中", + "events": "活动", + "add_event": "添加活动", + "events_hint": "活动成员只能在活动进行期间加入。", + "no_events": "未找到活动", + "remove": "移除", + "save": "保存", + "save_hint": "几秒内生效。不被允许的玩家会被移出。", + "saved": "访问权限已保存,服务器将在几秒内应用。" } } }, diff --git a/i18n/locales/zh_Hant.json b/i18n/locales/zh_Hant.json index cf2913b3..f141bb1d 100644 --- a/i18n/locales/zh_Hant.json +++ b/i18n/locales/zh_Hant.json @@ -3751,6 +3751,29 @@ "browse": "外掛目錄", "saved": "外掛已儲存,伺服器正在重新啟動。", "saved_disabled": "外掛已儲存,將在伺服器啟用後生效。" + }, + "access": { + "title": "存取權限", + "description": "選擇誰可以連線。受限伺服器對其他所有人隱藏,Player Management 外掛會在連線時拒絕他們。", + "open": "開放", + "restricted": "受限", + "open_hint": "任何人都可以找到並加入此伺服器。", + "plugin_missing": "Player Management 外掛在過去 3 分鐘內沒有從此伺服器回報,因此目前還沒有阻擋任何人。", + "staff": "版主及以上角色一律可以加入。", + "min_role": "最低角色", + "no_role": "無角色規則", + "role_and_up": "{role} 及以上", + "players": "玩家", + "add_player": "新增玩家", + "already_added": "已在清單中", + "events": "活動", + "add_event": "新增活動", + "events_hint": "活動成員只能在活動進行期間加入。", + "no_events": "找不到活動", + "remove": "移除", + "save": "儲存", + "save_hint": "幾秒內生效。不被允許的玩家會被移出。", + "saved": "存取權限已儲存,伺服器將在幾秒內套用。" } } }, diff --git a/pages/dedicated-servers/[id].vue b/pages/dedicated-servers/[id].vue index 8e7513e7..d4be3d3b 100644 --- a/pages/dedicated-servers/[id].vue +++ b/pages/dedicated-servers/[id].vue @@ -40,6 +40,7 @@ import RconCommander from "~/components/servers/RconCommander.vue"; import ServerPlayerManagement from "~/components/servers/ServerPlayerManagement.vue"; import ServerMapRotation from "~/components/servers/ServerMapRotation.vue"; import ServerPlugins from "~/components/servers/ServerPlugins.vue"; +import ServerAccess from "~/components/servers/ServerAccess.vue"; import { Eye, EyeOff } from "lucide-vue-next"; import Clipboard from "~/components/ClipBoard.vue"; import ServerStatus from "~/components/servers/ServerStatus.vue"; @@ -241,7 +242,7 @@ const titleClasses = - + - +
+ + +
+ + + + ({ data: null as any })); + +vi.mock("@vue/apollo-composable", async (importOriginal) => { + const { ref } = await import("vue"); + return { + ...(await importOriginal()), + useQuery: () => ({ result: ref(state.data), refetch: vi.fn(async () => {}) }), + useApolloClient: () => ({ + client: { query: vi.fn(async () => ({ data: { events: [] } })) }, + }), + }; +}); + +import ServerAccess from "~/components/servers/ServerAccess.vue"; + +const hour = 60 * 60 * 1000; + +function server(extra: Record = {}) { + return { + servers_by_pk: { + id: "server-1", + access_restricted: true, + access_min_role: null, + player_management_seen_at: new Date().toISOString(), + access_players: [ + { + steam_id: "76561198000000001", + player: { steam_id: "76561198000000001", name: "Dana", avatar_url: null, country: null }, + }, + ], + access_events: [ + { + event: { + id: "event-1", + name: "Prophunt Night", + starts_at: new Date(Date.now() - hour).toISOString(), + ends_at: new Date(Date.now() + hour).toISOString(), + }, + }, + ], + ...extra, + }, + }; +} + +let unmount: (() => void) | undefined; + +afterEach(() => { + unmount?.(); + unmount = undefined; +}); + +async function mountCard() { + const wrapper = await mountSuspended(ServerAccess, { + props: { serverId: "server-1" }, + }); + unmount = () => wrapper.unmount(); + return wrapper; +} + +describe("ServerAccess", () => { + it("lists who a restricted server lets in", async () => { + state.data = server(); + + const wrapper = await mountCard(); + + expect(wrapper.text()).toContain("Dana"); + expect(wrapper.text()).toContain("Prophunt Night"); + expect(wrapper.text()).toContain("Moderators and above can always join"); + expect(wrapper.text()).not.toContain("hasn't checked in"); + }); + + it("says anyone can join an open server", async () => { + state.data = server({ access_restricted: false }); + + const wrapper = await mountCard(); + + expect(wrapper.text()).toContain("Anyone can find and join this server"); + expect(wrapper.text()).not.toContain("Prophunt Night"); + }); + + // A restriction nobody enforces would only hide the server in the panel. + it("warns when the Player Management plugin has not checked in", async () => { + for (const seenAt of [null, new Date(Date.now() - hour).toISOString()]) { + state.data = server({ player_management_seen_at: seenAt }); + + const wrapper = await mountCard(); + + expect(wrapper.text()).toContain("hasn't checked in"); + + unmount?.(); + unmount = undefined; + } + }); +}); From b0f9b8be10b2616024d0651421824991aee1f02d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 1 Oct 2026 08:59:14 -0400 Subject: [PATCH 2/2] feature: server settings console with side tabs and one save (#650) --- components/servers/ServerAccess.vue | 566 ++++++++--------- components/servers/ServerMapRotation.vue | 585 +++++++----------- components/servers/ServerPlugins.vue | 388 +++++------- components/servers/ServerSettings.vue | 263 ++++++++ i18n/locales/ar_SA.json | 47 +- i18n/locales/da_DK.json | 47 +- i18n/locales/de_DE.json | 47 +- i18n/locales/en.json | 47 +- i18n/locales/es_ES.json | 47 +- i18n/locales/fr_FR.json | 47 +- i18n/locales/it_IT.json | 47 +- i18n/locales/ja_JP.json | 47 +- i18n/locales/ko_KR.json | 47 +- i18n/locales/pl_PL.json | 47 +- i18n/locales/pt_BR.json | 47 +- i18n/locales/ru_RU.json | 47 +- i18n/locales/sv_SE.json | 47 +- i18n/locales/tr_TR.json | 47 +- i18n/locales/uk_UA.json | 47 +- i18n/locales/zh_Hans.json | 47 +- i18n/locales/zh_Hant.json | 47 +- pages/dedicated-servers/[id].vue | 42 +- .../components/servers/ServerSettings.spec.ts | 113 ++++ 23 files changed, 1551 insertions(+), 1205 deletions(-) create mode 100644 components/servers/ServerSettings.vue create mode 100644 tests/components/servers/ServerSettings.spec.ts diff --git a/components/servers/ServerAccess.vue b/components/servers/ServerAccess.vue index e88d09a6..496ecc31 100644 --- a/components/servers/ServerAccess.vue +++ b/components/servers/ServerAccess.vue @@ -3,16 +3,9 @@ import { computed, onBeforeUnmount, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import { useQuery } from "@vue/apollo-composable"; import gql from "graphql-tag"; -import { - CalendarDays, - Globe, - Lock, - ShieldCheck, - TriangleAlert, - X, -} from "lucide-vue-next"; -import { Badge } from "~/components/ui/badge"; +import { X } from "lucide-vue-next"; import { Button } from "~/components/ui/button"; +import { Switch } from "~/components/ui/switch"; import { Select, SelectContent, @@ -20,16 +13,13 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; -import AnimatedFilters from "~/components/common/AnimatedFilters.vue"; -import SettingsSaveBar from "~/components/settings/SettingsSaveBar.vue"; +import SettingsSection from "~/components/settings/SettingsSection.vue"; import PlayerSearch from "~/components/PlayerSearch.vue"; import PlayerDisplay from "~/components/PlayerDisplay.vue"; import EventSearch from "~/components/events/EventSearch.vue"; -import { toast } from "@/components/ui/toast"; import { eventPhase, formatEventDate, - phaseBadgeVariant, phaseLabelKey, } from "~/utilities/eventDisplay"; @@ -57,7 +47,6 @@ type Draft = { const props = defineProps<{ serverId: string }>(); const { t } = useI18n(); -const nuxtApp = useNuxtApp(); // Moderators and up always get in, so only the roles below them are useful // as a floor. @@ -65,8 +54,8 @@ const MIN_ROLES = ["user", "verified_user", "streamer"]; const NO_ROLE = "none"; const PLUGIN_STALE_MS = 3 * 60 * 1000; -// Raw documents: the access tables and setServerAccess are newer than -// ~/generated/zeus until codegen runs against a migrated database. +// Raw documents: the access tables are newer than ~/generated/zeus until +// codegen runs against a migrated database. const QUERY = gql` query ServerAccess($serverId: uuid!) { servers_by_pk(id: $serverId) { @@ -95,41 +84,28 @@ const QUERY = gql` } `; -const SAVE = gql` - mutation SetServerAccess( - $serverId: uuid! - $restricted: Boolean! - $minRole: String - $steamIds: [String!]! - $eventIds: [uuid!]! - ) { - setServerAccess( - server_id: $serverId - restricted: $restricted - min_role: $minRole - steam_ids: $steamIds - event_ids: $eventIds - ) { - success - } - } -`; - const { result, refetch } = useQuery( QUERY, () => ({ serverId: props.serverId }), { fetchPolicy: "cache-and-network" }, ); -const draft = ref({ - restricted: false, - minRole: NO_ROLE, - players: [], - events: [], -}); -const saved = ref(snapshot(draft.value)); -const revision = ref(0); -const submitting = ref(false); +function emptyDraft(): Draft { + return { restricted: false, minRole: NO_ROLE, players: [], events: [] }; +} + +// The entries themselves are never edited, only added or dropped, so copying +// the lists is a full copy; structuredClone refuses Vue's reactive proxies. +function copy(value: Draft): Draft { + return { + ...value, + players: [...value.players], + events: [...value.events], + }; +} + +const draft = ref(emptyDraft()); +const savedDraft = ref(emptyDraft()); const now = ref(Date.now()); const clock = setInterval(() => { @@ -140,6 +116,52 @@ onBeforeUnmount(() => clearInterval(clock)); const server = computed(() => (result.value as any)?.servers_by_pk); +const steamIdsOf = (value: Draft) => + value.players.map((player) => player.steam_id); +const eventIdsOf = (value: Draft) => value.events.map((event) => event.id); + +function countMissing(from: Array, other: Array): number { + return from.filter((id) => !other.includes(id)).length; +} + +const changes = computed>(() => { + const before = savedDraft.value; + const after = draft.value; + const list: Array = []; + const key = "pages.dedicated_servers.detail.settings.changes"; + + if (after.restricted !== before.restricted) { + list.push(t(after.restricted ? `${key}.restricted` : `${key}.opened`)); + } + + if (after.minRole !== before.minRole) { + list.push(t(`${key}.min_role`)); + } + + const playersAdded = countMissing(steamIdsOf(after), steamIdsOf(before)); + const playersRemoved = countMissing(steamIdsOf(before), steamIdsOf(after)); + const eventsAdded = countMissing(eventIdsOf(after), eventIdsOf(before)); + const eventsRemoved = countMissing(eventIdsOf(before), eventIdsOf(after)); + + if (playersAdded) { + list.push(t(`${key}.players_added`, { count: playersAdded })); + } + + if (playersRemoved) { + list.push(t(`${key}.players_removed`, { count: playersRemoved })); + } + + if (eventsAdded) { + list.push(t(`${key}.events_added`, { count: eventsAdded })); + } + + if (eventsRemoved) { + list.push(t(`${key}.events_removed`, { count: eventsRemoved })); + } + + return list.map((text) => ({ text, restart: false })); +}); + function fromServer(): Draft { return { restricted: server.value?.access_restricted ?? false, @@ -153,111 +175,76 @@ function fromServer(): Draft { }; } -function snapshot(value: Draft): string { - return JSON.stringify({ - restricted: value.restricted, - minRole: value.minRole, - players: value.players.map((player) => player.steam_id).sort(), - events: value.events.map((event) => event.id).sort(), - }); -} - -const dirty = computed(() => snapshot(draft.value) !== saved.value); - function reset() { - draft.value = fromServer(); - saved.value = snapshot(draft.value); + savedDraft.value = fromServer(); + draft.value = copy(savedDraft.value); } watch( result, () => { - if (!dirty.value) { + if (!changes.value.length) { reset(); } }, { immediate: true }, ); -function touch() { - revision.value++; -} - -const modeModel = computed({ - get: () => (draft.value.restricted ? "restricted" : "open"), - set: (value: string) => { - draft.value.restricted = value === "restricted"; - touch(); - }, -}); - -const modeOptions = computed(() => [ - { - key: "open", - label: t("pages.dedicated_servers.detail.access.open"), - icon: Globe, - }, - { - key: "restricted", - label: t("pages.dedicated_servers.detail.access.restricted"), - icon: Lock, - }, -]); - const minRoleModel = computed({ get: () => draft.value.minRole, set: (value: string) => { - draft.value.minRole = value; - touch(); + draft.value = { ...draft.value, minRole: value }; }, }); -const pickedSteamIds = computed(() => - draft.value.players.map((player) => player.steam_id), -); - const ineligiblePlayers = computed(() => Object.fromEntries( - pickedSteamIds.value.map((steamId) => [ + steamIdsOf(draft.value).map((steamId) => [ steamId, t("pages.dedicated_servers.detail.access.already_added"), ]), ), ); +function setRestricted(restricted: boolean) { + draft.value = { ...draft.value, restricted }; +} + function addPlayer(player: AccessPlayer) { - if (pickedSteamIds.value.includes(String(player.steam_id))) { + const steamId = String(player.steam_id); + + if (steamIdsOf(draft.value).includes(steamId)) { return; } - draft.value.players = [ - ...draft.value.players, - { ...player, steam_id: String(player.steam_id) }, - ]; - touch(); + draft.value = { + ...draft.value, + players: [...draft.value.players, { ...player, steam_id: steamId }], + }; } function removePlayer(steamId: string) { - draft.value.players = draft.value.players.filter( - (player) => player.steam_id !== steamId, - ); - touch(); + draft.value = { + ...draft.value, + players: draft.value.players.filter( + (player) => player.steam_id !== steamId, + ), + }; } function addEvent(event: AccessEvent) { - if (draft.value.events.some((existing) => existing.id === event.id)) { + if (eventIdsOf(draft.value).includes(event.id)) { return; } - draft.value.events = [...draft.value.events, event]; - touch(); + draft.value = { ...draft.value, events: [...draft.value.events, event] }; } function removeEvent(eventId: string) { - draft.value.events = draft.value.events.filter( - (event) => event.id !== eventId, - ); - touch(); + draft.value = { + ...draft.value, + events: draft.value.events.filter((event) => event.id !== eventId), + }; } function eventWindow(event: AccessEvent): string { @@ -279,251 +266,184 @@ const pluginMissing = computed(() => { return !seenAt || now.value - new Date(seenAt).getTime() >= PLUGIN_STALE_MS; }); -async function save() { - if (submitting.value) { - return; - } - - submitting.value = true; - const startedAt = revision.value; - const saving = snapshot(draft.value); - - try { - await nuxtApp.$apollo.defaultClient.mutate({ - mutation: SAVE, - variables: { - serverId: props.serverId, - restricted: draft.value.restricted, - minRole: - draft.value.minRole === NO_ROLE ? null : draft.value.minRole, - steamIds: pickedSteamIds.value, - eventIds: draft.value.events.map((event) => event.id), - }, - }); - - toast({ title: t("pages.dedicated_servers.detail.access.saved") }); - - saved.value = saving; - - // Edits made while saving stay pending. - if (revision.value === startedAt) { - await refetch(); - } - } finally { - submitting.value = false; - } +function payload() { + return { + restricted: draft.value.restricted, + min_role: draft.value.minRole === NO_ROLE ? null : draft.value.minRole, + steam_ids: steamIdsOf(draft.value), + event_ids: eventIdsOf(draft.value), + }; } -const cardClasses = - "relative isolate overflow-hidden rounded-lg border border-border [background:linear-gradient(180deg,hsl(var(--card)/0.2)_0%,hsl(var(--card)/0.04)_100%)]"; +async function saved() { + savedDraft.value = copy(draft.value); -const microLabelClasses = - "font-mono text-[0.6rem] font-bold uppercase tracking-[0.18em] text-muted-foreground"; + await refetch(); +} -const chipClasses = - "inline-flex items-center gap-1.5 rounded border px-1.5 py-0.5 font-mono text-[0.58rem] font-bold uppercase leading-none tracking-[0.12em]"; +defineExpose({ changes, payload, reset, saved }); diff --git a/components/servers/ServerMapRotation.vue b/components/servers/ServerMapRotation.vue index e7661b18..32189328 100644 --- a/components/servers/ServerMapRotation.vue +++ b/components/servers/ServerMapRotation.vue @@ -6,17 +6,14 @@ import gql from "graphql-tag"; import { ChevronDown, ChevronUp, - Download, GripVertical, - ListOrdered, Map as MapIcon, - Plus, - Shuffle, - TriangleAlert, X, } from "lucide-vue-next"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Switch } from "~/components/ui/switch"; import { Select, SelectContent, @@ -26,8 +23,7 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; -import AnimatedFilters from "~/components/common/AnimatedFilters.vue"; -import SettingsSaveBar from "~/components/settings/SettingsSaveBar.vue"; +import SettingsSection from "~/components/settings/SettingsSection.vue"; import { toast } from "@/components/ui/toast"; type RotationMap = { @@ -38,7 +34,7 @@ type RotationMap = { workshop_map_id: string | null; }; -const props = defineProps<{ serverId: string; enabled?: boolean }>(); +const props = defineProps<{ serverId: string }>(); const { t } = useI18n(); const nuxtApp = useNuxtApp(); @@ -93,22 +89,6 @@ const QUERY = gql` } `; -const SAVE = gql` - mutation SetServerMapRotation( - $serverId: uuid! - $mapIds: [uuid!]! - $shuffle: Boolean! - ) { - setServerMapRotation( - server_id: $serverId - map_ids: $mapIds - shuffle: $shuffle - ) { - success - } - } -`; - const IMPORT = gql` mutation ImportWorkshopCollection($collection: String!) { importWorkshopCollection(collection: $collection) { @@ -132,9 +112,10 @@ const { result, refetch } = useQuery( const entries = ref>([]); const shuffle = ref(true); -const dirty = ref(false); -const revision = ref(0); -const submitting = ref(false); +const baseline = ref<{ ids: Array; shuffle: boolean }>({ + ids: [], + shuffle: true, +}); const importing = ref(false); const selectedMapId = ref(); const collection = ref(""); @@ -143,6 +124,46 @@ const brokenPosters = ref(new Set()); const mapKey = (map: RotationMap) => map.workshop_map_id || map.name; const mapLabel = (map: RotationMap) => map.label || map.name; +const changes = computed>(() => { + const before = baseline.value.ids; + const after = entries.value.map((map) => map.id); + const added = after.filter((id) => !before.includes(id)).length; + const removed = before.filter((id) => !after.includes(id)).length; + const list: Array = []; + + if (added) { + list.push( + t("pages.dedicated_servers.detail.settings.changes.maps_added", { + count: added, + }), + ); + } + + if (removed) { + list.push( + t("pages.dedicated_servers.detail.settings.changes.maps_removed", { + count: removed, + }), + ); + } + + if (!added && !removed && after.join() !== before.join()) { + list.push(t("pages.dedicated_servers.detail.settings.changes.map_order")); + } + + if (shuffle.value !== baseline.value.shuffle) { + list.push( + t( + shuffle.value + ? "pages.dedicated_servers.detail.settings.changes.shuffle_on" + : "pages.dedicated_servers.detail.settings.changes.shuffle_off", + ), + ); + } + + return list.map((text) => ({ text, restart: true })); +}); + function reset() { const server = (result.value as any)?.servers_by_pk; @@ -150,13 +171,16 @@ function reset() { (row: { map: RotationMap }) => row.map, ); shuffle.value = server?.map_rotation_shuffle ?? true; - dirty.value = false; + baseline.value = { + ids: entries.value.map((map) => map.id), + shuffle: shuffle.value, + }; } watch( result, () => { - if (!dirty.value) { + if (!changes.value.length) { reset(); } }, @@ -212,43 +236,12 @@ const warnWorkshopOrder = computed( entries.value.some((map) => map.workshop_map_id), ); -const orderModel = computed({ - get: () => (shuffle.value ? "shuffle" : "ordered"), - set: (value: string) => { - shuffle.value = value === "shuffle"; - touch(); - }, -}); - -const orderOptions = computed(() => [ - { - key: "shuffle", - label: t("pages.dedicated_servers.detail.map_rotation.shuffle"), - icon: Shuffle, - title: t("pages.dedicated_servers.detail.map_rotation.shuffle"), - desc: t("pages.dedicated_servers.detail.map_rotation.shuffle_hint"), - }, - { - key: "ordered", - label: t("pages.dedicated_servers.detail.map_rotation.in_order"), - icon: ListOrdered, - title: t("pages.dedicated_servers.detail.map_rotation.in_order"), - desc: t("pages.dedicated_servers.detail.map_rotation.in_order_hint"), - }, -]); - -function touch() { - dirty.value = true; - revision.value++; -} - function add(map: RotationMap) { if (entries.value.some((entry) => mapKey(entry) === mapKey(map))) { return; } - entries.value.push(map); - touch(); + entries.value = [...entries.value, map]; } function addSelected() { @@ -268,14 +261,14 @@ function moveTo(from: number, to: number) { return; } - const [map] = entries.value.splice(from, 1); - entries.value.splice(to, 0, map); - touch(); + const next = [...entries.value]; + const [map] = next.splice(from, 1); + next.splice(to, 0, map); + entries.value = next; } function remove(index: number) { - entries.value.splice(index, 1); - touch(); + entries.value = entries.value.filter((_, position) => position !== index); } const dragIndex = ref(null); @@ -365,289 +358,179 @@ async function importCollection() { await refetch(); } -async function save() { - if (submitting.value) { - return; - } - - submitting.value = true; - const startedAt = revision.value; - - try { - await nuxtApp.$apollo.defaultClient.mutate({ - mutation: SAVE, - variables: { - serverId: props.serverId, - mapIds: entries.value.map((map) => map.id), - shuffle: shuffle.value, - }, - }); - - toast({ - title: t( - props.enabled === false - ? "pages.dedicated_servers.detail.map_rotation.saved_disabled" - : "pages.dedicated_servers.detail.map_rotation.saved", - ), - }); - - // Changes made while the server was restarting stay pending. - if (revision.value === startedAt) { - dirty.value = false; - await refetch(); - } - } finally { - submitting.value = false; - } +function payload() { + return { + map_ids: entries.value.map((map) => map.id), + shuffle: shuffle.value, + }; } -const cardClasses = - "relative isolate overflow-hidden rounded-lg border border-border [background:linear-gradient(180deg,hsl(var(--card)/0.2)_0%,hsl(var(--card)/0.04)_100%)]"; +async function saved() { + baseline.value = { + ids: entries.value.map((map) => map.id), + shuffle: shuffle.value, + }; -const microLabelClasses = - "font-mono text-[0.6rem] font-bold uppercase tracking-[0.18em] text-muted-foreground"; + await refetch(); +} -const chipClasses = - "inline-flex items-center rounded border px-1.5 py-0.5 font-mono text-[0.58rem] font-bold uppercase leading-none tracking-[0.12em]"; +defineExpose({ changes, payload, reset, saved });