diff --git a/backend/Cargo.lock b/backend/Cargo.lock index ea79ba8..ee0fb38 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -3982,9 +3982,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.3" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 66d124f..c37e4fa 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -14,7 +14,7 @@ dashmap = "7.0.0-rc2" futures = "0.3.*" -tokio = { version = "1.53.*", features = ["full"] } +tokio = { version = "1.54.*", features = ["full"] } serde = { version = "1.0.*", features = ["derive"] } serde_json = "1.0.*" diff --git a/frontend/app/core/lib/servers-dat.test.ts b/frontend/app/core/lib/servers-dat.test.ts new file mode 100644 index 0000000..8e39093 --- /dev/null +++ b/frontend/app/core/lib/servers-dat.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest" +import { + buildServersDat, + orderServersForExport, + toServersDatAddress, + type SortableServer, +} from "./servers-dat" + +// Minimal NBT reader, just enough to validate the generated servers.dat. +function readU16BE(bytes: Uint8Array, offset: number): number { + return (bytes[offset]! * 256 + bytes[offset + 1]!) >>> 0 +} + +function readI32BE(bytes: Uint8Array, offset: number): number { + return ( + ((bytes[offset]! << 24) | + (bytes[offset + 1]! << 16) | + (bytes[offset + 2]! << 8) | + bytes[offset + 3]!) >>> + 0 + ) +} + +const decoder = new TextDecoder() + +function readString(bytes: Uint8Array, offset: number): { value: string; next: number } { + const len = readU16BE(bytes, offset) + const value = decoder.decode(bytes.slice(offset + 2, offset + 2 + len)) + return { value, next: offset + 2 + len } +} + +function parseServersDat(bytes: Uint8Array): { name: string; ip: string }[] { + let o = 0 + // Root TAG_Compound with empty name. + expect(bytes[o++]).toBe(0x0a) + expect(readU16BE(bytes, o)).toBe(0) + o += 2 + // TAG_List "servers" of compounds. + expect(bytes[o++]).toBe(0x09) + const listName = readString(bytes, o) + expect(listName.value).toBe("servers") + o = listName.next + expect(bytes[o++]).toBe(0x0a) + const count = readI32BE(bytes, o) + o += 4 + const servers: { name: string; ip: string }[] = [] + for (let i = 0; i < count; i++) { + const fields: Record = {} + for (;;) { + const tag = bytes[o++]! + if (tag === 0x00) break + expect(tag).toBe(0x08) + const field = readString(bytes, o) + o = field.next + const val = readString(bytes, o) + o = val.next + fields[field.value] = val.value + } + servers.push({ name: fields["name"]!, ip: fields["ip"]! }) + } + // Root TAG_End + nothing trailing. + expect(bytes[o++]).toBe(0x00) + expect(o).toBe(bytes.length) + return servers +} + +describe("orderServersForExport", () => { + const servers: SortableServer[] = [ + { id: 1, name: "Bravo", last_status: "online", last_connected: 50 }, + { id: 2, name: "Alpha", last_status: "online", last_connected: 200 }, + { id: 3, name: "Charlie", last_status: "offline", last_connected: 999 }, + { id: 4, name: "Delta", last_status: "offline", last_connected: 10 }, + ] + const names = (list: SortableServer[]) => list.map((s) => s.name) + + it("puts online servers first by default, sorted by players desc", () => { + expect(names(orderServersForExport(servers))).toEqual([ + "Alpha", + "Bravo", + "Delta", + "Charlie", + ]) + }) + + it("supports ascending popularity", () => { + expect(names(orderServersForExport(servers, { sort: "popularityAsc" }))).toEqual([ + "Bravo", + "Alpha", + "Delta", + "Charlie", + ]) + }) + + it("places offline servers first when requested", () => { + expect( + names(orderServersForExport(servers, { offlinePlacement: "first" })) + ).toEqual(["Delta", "Charlie", "Alpha", "Bravo"]) + }) + + it("drops offline servers with ignoreOffline", () => { + expect(names(orderServersForExport(servers, { ignoreOffline: true }))).toEqual([ + "Alpha", + "Bravo", + ]) + }) + + it("supports name and recent sorts", () => { + expect(names(orderServersForExport(servers, { sort: "name" }))).toEqual([ + "Alpha", + "Bravo", + "Charlie", + "Delta", + ]) + expect(names(orderServersForExport(servers, { sort: "recent" }))).toEqual([ + "Alpha", + "Bravo", + "Delta", + "Charlie", + ]) + }) +}) +describe("toServersDatAddress", () => { + it("omits the default Java port", () => { + expect(toServersDatAddress("mc.example.com", 25565)).toBe("mc.example.com") + }) + + it("keeps custom ports", () => { + expect(toServersDatAddress("mc.example.com", 25566)).toBe("mc.example.com:25566") + }) +}) + +describe("buildServersDat", () => { + it("writes a valid servers.dat round-trip", () => { + const parsed = parseServersDat( + buildServersDat([ + { name: "Hypixel", ip: "mc.hypixel.net", port: 25565 }, + { name: "Custom", ip: "play.example.com", port: 25566 }, + ]) + ) + expect(parsed).toEqual([ + { name: "Hypixel", ip: "mc.hypixel.net" }, + { name: "Custom", ip: "play.example.com:25566" }, + ]) + }) + + it("falls back to the ip when the name is blank", () => { + const parsed = parseServersDat( + buildServersDat([{ name: " ", ip: "mc.example.com", port: 25565 }]) + ) + expect(parsed).toEqual([{ name: "mc.example.com", ip: "mc.example.com" }]) + }) + + it("handles unicode names and an empty list", () => { + const parsed = parseServersDat( + buildServersDat([{ name: "Érisium ⚔", ip: "mc.erisium.fr", port: 25565 }]) + ) + expect(parsed).toEqual([{ name: "Érisium ⚔", ip: "mc.erisium.fr" }]) + expect(parseServersDat(buildServersDat([]))).toEqual([]) + }) +}) diff --git a/frontend/app/core/lib/servers-dat.ts b/frontend/app/core/lib/servers-dat.ts new file mode 100644 index 0000000..b2144f9 --- /dev/null +++ b/frontend/app/core/lib/servers-dat.ts @@ -0,0 +1,180 @@ +/** + * Client-side builder for Minecraft Java Edition `servers.dat` files. + * + * `servers.dat` is an uncompressed NBT file: a root TAG_Compound (empty + * name) containing a TAG_List named "servers" of TAG_Compound entries, each + * with a TAG_String "name" and a TAG_String "ip" ("host:port", the port being + * omitted for the 25565 default). + * + * Everything runs in the browser: no new dependency, no server round-trip. + * Bedrock servers are excluded by the caller (servers.dat is Java-only). + */ + +export interface ServersDatEntry { + name: string + ip: string + port: number +} + +export const SERVERS_DAT_DEFAULT_PORT = 25565 + +/** Minimal shape needed to order servers before export. */ +export interface SortableServer { + id: number + name: string + last_status?: string | null + last_connected?: number | null +} + +export type ServersDatSort = "popularity" | "popularityAsc" | "name" | "recent" +export type ServersDatOfflinePlacement = "last" | "first" + +export interface ServersDatExportOptions { + sort?: ServersDatSort + offlinePlacement?: ServersDatOfflinePlacement + ignoreOffline?: boolean +} + +/** Online servers rank by live player count, offline ones always last. */ +function playerCountForSort(server: SortableServer): number { + return server.last_status === "online" ? (server.last_connected ?? 0) : -1 +} + +function sortServerGroup(list: T[], sort: ServersDatSort): T[] { + const arr = [...list] + switch (sort) { + case "popularityAsc": + arr.sort( + (a, b) => + playerCountForSort(a) - playerCountForSort(b) || b.id - a.id + ) + break + case "name": + arr.sort((a, b) => a.name.localeCompare(b.name)) + break + case "recent": + arr.sort((a, b) => b.id - a.id) + break + case "popularity": + default: + arr.sort( + (a, b) => + playerCountForSort(b) - playerCountForSort(a) || b.id - a.id + ) + break + } + return arr +} + +/** + * Orders servers for export: each status group (online/offline) is sorted + * with the chosen criterion, then concatenated according to + * `offlinePlacement` — or offline servers are dropped with `ignoreOffline`. + */ +export function orderServersForExport( + servers: T[], + options: ServersDatExportOptions = {} +): T[] { + const { sort = "popularity", offlinePlacement = "last", ignoreOffline = false } = options + const online = sortServerGroup( + servers.filter((s) => s.last_status === "online"), + sort + ) + if (ignoreOffline) return online + const offline = sortServerGroup( + servers.filter((s) => s.last_status !== "online"), + sort + ) + return offlinePlacement === "first" ? [...offline, ...online] : [...online, ...offline] +} + +/** "host:port" address as expected by the Minecraft client. */ +export function toServersDatAddress(ip: string, port: number): string { + return port === SERVERS_DAT_DEFAULT_PORT ? ip : `${ip}:${port}` +} + +const encoder = new TextEncoder() + +function pushU16BE(out: number[], value: number): void { + out.push((value >>> 8) & 0xff, value & 0xff) +} + +function pushI32BE(out: number[], value: number): void { + out.push( + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff + ) +} + +function pushStringPayload(out: number[], value: string): void { + const bytes = encoder.encode(value) + pushU16BE(out, bytes.length) + for (const b of bytes) out.push(b) +} + +/** TAG_String with its field name, e.g. (0x08, "name", value). */ +function pushNamedString(out: number[], fieldName: string, value: string): void { + out.push(0x08) + const nameBytes = encoder.encode(fieldName) + pushU16BE(out, nameBytes.length) + for (const b of nameBytes) out.push(b) + pushStringPayload(out, value) +} + +/** + * Builds an uncompressed NBT `servers.dat` payload for the given servers. + * Returns the raw file bytes, ready to be saved as "servers.dat". + */ +export function buildServersDat(servers: ServersDatEntry[]): Uint8Array { + const out: number[] = [] + + // Root TAG_Compound with an empty name. + out.push(0x0a, 0x00, 0x00) + + // TAG_List "servers" of TAG_Compound (element type 0x0a). + out.push(0x09) + const listName = encoder.encode("servers") + pushU16BE(out, listName.length) + for (const b of listName) out.push(b) + out.push(0x0a) + pushI32BE(out, servers.length) + + for (const server of servers) { + const name = server.name.trim() || server.ip + pushNamedString(out, "name", name) + pushNamedString(out, "ip", toServersDatAddress(server.ip, server.port)) + // TAG_End: closes the server compound. + out.push(0x00) + } + + // TAG_End: closes the root compound. + out.push(0x00) + + return new Uint8Array(out) +} + +/** + * Triggers the download of a `servers.dat` file in the browser. + * Returns the number of servers written. + */export function downloadServersDat( + servers: ServersDatEntry[], + filename = "servers.dat" +): number { + const bytes = buildServersDat(servers) + // Copy into a fresh ArrayBuffer: `bytes.buffer` may be a shared pool + // whose byteOffset/length upset some Blob implementations. + const buffer = new Uint8Array(bytes.length) + buffer.set(bytes) + const blob = new Blob([buffer], { type: "application/octet-stream" }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) + return servers.length +} diff --git a/frontend/app/locales/de.json b/frontend/app/locales/de.json index d46675f..307865b 100644 --- a/frontend/app/locales/de.json +++ b/frontend/app/locales/de.json @@ -94,6 +94,25 @@ "noSearchResults": "Keine Server entsprechen Ihrer Suche \"{{query}}\".", "noFilterResults": "Keine Server entsprechen diesen Filtern.", "error": "Fehler beim Laden der Server", + "exportServersDat": "Alle Server exportieren", + "exportServersDatTitle": "Liste als servers.dat exportieren (Minecraft Java)", + "exported": "{{count}} Server in servers.dat exportiert", + "exportEmpty": "Keine Java-Server zum Exportieren", + "exportError": "Export von servers.dat fehlgeschlagen", + "exportTitle": "Als servers.dat exportieren", + "exportDescription": "Wähle die Reihenfolge der Server in deiner Minecraft-Multiplayer-Liste.", + "exportSortLabel": "Serverreihenfolge", + "exportSortPopularity": "Meiste Spieler zuerst", + "exportSortPopularityAsc": "Wenigste Spieler zuerst", + "exportSortName": "Name (A–Z)", + "exportSortRecent": "Zuletzt hinzugefügt zuerst", + "exportOfflineLabel": "Offline-Server", + "exportOfflineLast": "Zuletzt", + "exportOfflineFirst": "Zuerst", + "exportIgnoreOffline": "Offline-Server überspringen", + "exportPreview": "{{count}} Server werden exportiert", + "exportBedrockNote": "{{skipped}} Bedrock-Server ausgeschlossen (nur Java)", + "exportConfirm": "Exportieren", "filters": { "all": "Alle", "online": "Online", diff --git a/frontend/app/locales/en.json b/frontend/app/locales/en.json index b9491c3..a2c7f87 100644 --- a/frontend/app/locales/en.json +++ b/frontend/app/locales/en.json @@ -94,6 +94,25 @@ "noSearchResults": "No servers match your search \"{{query}}\".", "noFilterResults": "No servers match these filters.", "error": "Failed to load servers", + "exportServersDat": "Export all servers", + "exportServersDatTitle": "Export the list as servers.dat (Minecraft Java)", + "exported": "{{count}} servers exported to servers.dat", + "exportEmpty": "No Java servers to export", + "exportError": "Failed to export servers.dat", + "exportTitle": "Export as servers.dat", + "exportDescription": "Choose the order of servers in your Minecraft multiplayer list.", + "exportSortLabel": "Server order", + "exportSortPopularity": "Most players first", + "exportSortPopularityAsc": "Fewest players first", + "exportSortName": "Name (A to Z)", + "exportSortRecent": "Recently added first", + "exportOfflineLabel": "Offline servers", + "exportOfflineLast": "Last", + "exportOfflineFirst": "First", + "exportIgnoreOffline": "Skip offline servers", + "exportPreview": "{{count}} servers will be exported", + "exportBedrockNote": "{{skipped}} Bedrock servers excluded (Java only)", + "exportConfirm": "Export", "filters": { "all": "All", "online": "Online", diff --git a/frontend/app/locales/es.json b/frontend/app/locales/es.json index 5480d14..455e083 100644 --- a/frontend/app/locales/es.json +++ b/frontend/app/locales/es.json @@ -94,6 +94,25 @@ "noSearchResults": "No hay servidores que coincidan con tu búsqueda \"{{query}}\".", "noFilterResults": "No hay servidores que coincidan con estos filtros.", "error": "Error al cargar los servidores", + "exportServersDat": "Exportar todos los servidores", + "exportServersDatTitle": "Exportar la lista como servers.dat (Minecraft Java)", + "exported": "{{count}} servidores exportados a servers.dat", + "exportEmpty": "No hay servidores Java para exportar", + "exportError": "Error al exportar servers.dat", + "exportTitle": "Exportar como servers.dat", + "exportDescription": "Elige el orden de los servidores en tu lista multijugador de Minecraft.", + "exportSortLabel": "Orden de los servidores", + "exportSortPopularity": "Más jugadores primero", + "exportSortPopularityAsc": "Menos jugadores primero", + "exportSortName": "Nombre (A-Z)", + "exportSortRecent": "Añadidos recientemente primero", + "exportOfflineLabel": "Servidores sin conexión", + "exportOfflineLast": "Al final", + "exportOfflineFirst": "Al principio", + "exportIgnoreOffline": "Omitir servidores sin conexión", + "exportPreview": "{{count}} servidores se exportarán", + "exportBedrockNote": "{{skipped}} servidores Bedrock excluidos (solo Java)", + "exportConfirm": "Exportar", "filters": { "all": "Todos", "online": "En línea", diff --git a/frontend/app/locales/fr.json b/frontend/app/locales/fr.json index 600bd02..538c434 100644 --- a/frontend/app/locales/fr.json +++ b/frontend/app/locales/fr.json @@ -94,6 +94,25 @@ "noSearchResults": "Aucun serveur ne correspond à votre recherche \"{{query}}\".", "noFilterResults": "Aucun serveur ne correspond à ces filtres.", "error": "Impossible de charger les serveurs", + "exportServersDat": "Exporter tous les serveurs", + "exportServersDatTitle": "Exporter la liste en servers.dat (Minecraft Java)", + "exported": "{{count}} serveurs exportés en servers.dat", + "exportEmpty": "Aucun serveur Java à exporter", + "exportError": "Échec de l'export en servers.dat", + "exportTitle": "Exporter en servers.dat", + "exportDescription": "Choisis l'ordre des serveurs dans ta liste multijoueur Minecraft.", + "exportSortLabel": "Ordre des serveurs", + "exportSortPopularity": "Plus de joueurs d'abord", + "exportSortPopularityAsc": "Moins de joueurs d'abord", + "exportSortName": "Nom (A → Z)", + "exportSortRecent": "Ajouts récents d'abord", + "exportOfflineLabel": "Serveurs hors ligne", + "exportOfflineLast": "En dernier", + "exportOfflineFirst": "En premier", + "exportIgnoreOffline": "Ignorer les serveurs hors ligne", + "exportPreview": "{{count}} serveurs seront exportés", + "exportBedrockNote": "{{skipped}} serveurs Bedrock exclus (Java uniquement)", + "exportConfirm": "Exporter", "filters": { "all": "Tous", "online": "En ligne", diff --git a/frontend/app/locales/it.json b/frontend/app/locales/it.json index 7738473..2fb42bd 100644 --- a/frontend/app/locales/it.json +++ b/frontend/app/locales/it.json @@ -94,6 +94,25 @@ "noSearchResults": "Nessun server corrisponde alla ricerca \"{{query}}\".", "noFilterResults": "Nessun server corrisponde a questi filtri.", "error": "Impossibile caricare i server", + "exportServersDat": "Esporta tutti i server", + "exportServersDatTitle": "Esporta la lista come servers.dat (Minecraft Java)", + "exported": "{{count}} server esportati in servers.dat", + "exportEmpty": "Nessun server Java da esportare", + "exportError": "Esportazione di servers.dat non riuscita", + "exportTitle": "Esporta come servers.dat", + "exportDescription": "Scegli l'ordine dei server nella tua lista multigiocatore di Minecraft.", + "exportSortLabel": "Ordine dei server", + "exportSortPopularity": "Più giocatori prima", + "exportSortPopularityAsc": "Meno giocatori prima", + "exportSortName": "Nome (A-Z)", + "exportSortRecent": "Aggiunti di recente prima", + "exportOfflineLabel": "Server offline", + "exportOfflineLast": "Alla fine", + "exportOfflineFirst": "All'inizio", + "exportIgnoreOffline": "Ignora i server offline", + "exportPreview": "{{count}} server verranno esportati", + "exportBedrockNote": "{{skipped}} server Bedrock esclusi (solo Java)", + "exportConfirm": "Esporta", "filters": { "all": "Tutti", "online": "Online", diff --git a/frontend/app/locales/ja.json b/frontend/app/locales/ja.json index 8b5592f..86a08a8 100644 --- a/frontend/app/locales/ja.json +++ b/frontend/app/locales/ja.json @@ -94,6 +94,25 @@ "noSearchResults": "検索条件 \"{{query}}\" に一致するサーバーはありません。", "noFilterResults": "これらのフィルターに一致するサーバーはありません。", "error": "サーバーの読み込みに失敗しました", + "exportServersDat": "すべてのサーバーをエクスポート", + "exportServersDatTitle": "リストを servers.dat としてエクスポート(Minecraft Java)", + "exported": "{{count}} 件のサーバーを servers.dat にエクスポートしました", + "exportEmpty": "エクスポートできる Java サーバーがありません", + "exportError": "servers.dat のエクスポートに失敗しました", + "exportTitle": "servers.dat としてエクスポート", + "exportDescription": "Minecraft マルチプレイリストでのサーバーの順序を選択。", + "exportSortLabel": "サーバーの順序", + "exportSortPopularity": "プレイヤー数が多い順", + "exportSortPopularityAsc": "プレイヤー数が少ない順", + "exportSortName": "名前順 (A-Z)", + "exportSortRecent": "最近追加された順", + "exportOfflineLabel": "オフラインサーバー", + "exportOfflineLast": "最後に", + "exportOfflineFirst": "最初に", + "exportIgnoreOffline": "オフラインサーバーを除外", + "exportPreview": "{{count}} 件のサーバーをエクスポート", + "exportBedrockNote": "Bedrock サーバー {{skipped}} 件を除外(Java のみ)", + "exportConfirm": "エクスポート", "filters": { "all": "すべて", "online": "オンライン", diff --git a/frontend/app/locales/ko.json b/frontend/app/locales/ko.json index 75f0470..4a5dff2 100644 --- a/frontend/app/locales/ko.json +++ b/frontend/app/locales/ko.json @@ -94,6 +94,25 @@ "noSearchResults": "\"{{query}}\" 검색어와 일치하는 서버가 없습니다.", "noFilterResults": "이 필터와 일치하는 서버가 없습니다.", "error": "서버 목록을 불러오지 못했습니다", + "exportServersDat": "모든 서버 내보내기", + "exportServersDatTitle": "목록을 servers.dat로 내보내기 (Minecraft Java)", + "exported": "{{count}}개의 서버를 servers.dat로 내보냈습니다", + "exportEmpty": "내보낼 Java 서버가 없습니다", + "exportError": "servers.dat 내보내기에 실패했습니다", + "exportTitle": "servers.dat로 내보내기", + "exportDescription": "Minecraft 멀티플레이어 목록의 서버 순서를 선택하세요.", + "exportSortLabel": "서버 순서", + "exportSortPopularity": "플레이어 많은 순", + "exportSortPopularityAsc": "플레이어 적은 순", + "exportSortName": "이름순 (A-Z)", + "exportSortRecent": "최근 추가된 순", + "exportOfflineLabel": "오프라인 서버", + "exportOfflineLast": "마지막에", + "exportOfflineFirst": "맨 앞에", + "exportIgnoreOffline": "오프라인 서버 제외", + "exportPreview": "{{count}}개의 서버를 내보냅니다", + "exportBedrockNote": "Bedrock 서버 {{skipped}}개 제외 (Java만 지원)", + "exportConfirm": "내보내기", "filters": { "all": "전체", "online": "온라인", diff --git a/frontend/app/locales/nl.json b/frontend/app/locales/nl.json index db23076..c53c4e3 100644 --- a/frontend/app/locales/nl.json +++ b/frontend/app/locales/nl.json @@ -94,6 +94,25 @@ "noSearchResults": "Geen servers komen overeen met je zoekopdracht \"{{query}}\".", "noFilterResults": "Geen servers komen overeen met deze filters.", "error": "Kan servers niet laden", + "exportServersDat": "Alle servers exporteren", + "exportServersDatTitle": "Lijst exporteren als servers.dat (Minecraft Java)", + "exported": "{{count}} servers geëxporteerd naar servers.dat", + "exportEmpty": "Geen Java-servers om te exporteren", + "exportError": "Exporteren van servers.dat mislukt", + "exportTitle": "Exporteren als servers.dat", + "exportDescription": "Kies de volgorde van servers in je Minecraft-multiplayerlijst.", + "exportSortLabel": "Servervolgorde", + "exportSortPopularity": "Meeste spelers eerst", + "exportSortPopularityAsc": "Minste spelers eerst", + "exportSortName": "Naam (A-Z)", + "exportSortRecent": "Recent toegevoegd eerst", + "exportOfflineLabel": "Offlineservers", + "exportOfflineLast": "Achteraan", + "exportOfflineFirst": "Vooraan", + "exportIgnoreOffline": "Offlineservers overslaan", + "exportPreview": "{{count}} servers worden geëxporteerd", + "exportBedrockNote": "{{skipped}} Bedrock-servers uitgesloten (alleen Java)", + "exportConfirm": "Exporteren", "filters": { "all": "Alles", "online": "Online", diff --git a/frontend/app/locales/pl.json b/frontend/app/locales/pl.json index 0059150..4f423b7 100644 --- a/frontend/app/locales/pl.json +++ b/frontend/app/locales/pl.json @@ -94,6 +94,25 @@ "noSearchResults": "Żaden serwer nie pasuje do wyszukiwania \"{{query}}\".", "noFilterResults": "Żaden serwer nie pasuje do tych filtrów.", "error": "Nie udało się załadować serwerów", + "exportServersDat": "Eksportuj wszystkie serwery", + "exportServersDatTitle": "Eksportuj listę jako servers.dat (Minecraft Java)", + "exported": "Wyeksportowano {{count}} serwerów do servers.dat", + "exportEmpty": "Brak serwerów Java do wyeksportowania", + "exportError": "Nie udało się wyeksportować servers.dat", + "exportTitle": "Eksportuj jako servers.dat", + "exportDescription": "Wybierz kolejność serwerów na liście multiplayer Minecraft.", + "exportSortLabel": "Kolejność serwerów", + "exportSortPopularity": "Najpierw z największą liczbą graczy", + "exportSortPopularityAsc": "Najpierw z najmniejszą liczbą graczy", + "exportSortName": "Nazwa (A–Z)", + "exportSortRecent": "Najpierw ostatnio dodane", + "exportOfflineLabel": "Serwery offline", + "exportOfflineLast": "Na końcu", + "exportOfflineFirst": "Na początku", + "exportIgnoreOffline": "Pomiń serwery offline", + "exportPreview": "{{count}} serwerów zostanie wyeksportowanych", + "exportBedrockNote": "Pominięto {{skipped}} serwerów Bedrock (tylko Java)", + "exportConfirm": "Eksportuj", "filters": { "all": "Wszystkie", "online": "Online", diff --git a/frontend/app/locales/pt.json b/frontend/app/locales/pt.json index bb8ba3d..74fb61a 100644 --- a/frontend/app/locales/pt.json +++ b/frontend/app/locales/pt.json @@ -94,6 +94,25 @@ "noSearchResults": "Nenhum servidor corresponde à sua busca \"{{query}}\".", "noFilterResults": "Nenhum servidor corresponde a estes filtros.", "error": "Falha ao carregar servidores", + "exportServersDat": "Exportar todos os servidores", + "exportServersDatTitle": "Exportar a lista como servers.dat (Minecraft Java)", + "exported": "{{count}} servidores exportados para servers.dat", + "exportEmpty": "Nenhum servidor Java para exportar", + "exportError": "Falha ao exportar servers.dat", + "exportTitle": "Exportar como servers.dat", + "exportDescription": "Escolhe a ordem dos servidores na tua lista multijogador do Minecraft.", + "exportSortLabel": "Ordem dos servidores", + "exportSortPopularity": "Mais jogadores primeiro", + "exportSortPopularityAsc": "Menos jogadores primeiro", + "exportSortName": "Nome (A-Z)", + "exportSortRecent": "Adicionados recentemente primeiro", + "exportOfflineLabel": "Servidores offline", + "exportOfflineLast": "No fim", + "exportOfflineFirst": "No início", + "exportIgnoreOffline": "Ignorar servidores offline", + "exportPreview": "{{count}} servidores serão exportados", + "exportBedrockNote": "{{skipped}} servidores Bedrock excluídos (apenas Java)", + "exportConfirm": "Exportar", "filters": { "all": "Todos", "online": "Online", diff --git a/frontend/app/locales/ru.json b/frontend/app/locales/ru.json index 8db1caa..9a2b49d 100644 --- a/frontend/app/locales/ru.json +++ b/frontend/app/locales/ru.json @@ -94,6 +94,25 @@ "noSearchResults": "По вашему запросу \"{{query}}\" ничего не найдено.", "noFilterResults": "Нет серверов, соответствующих этим фильтрам.", "error": "Не удалось загрузить серверы", + "exportServersDat": "Экспортировать все серверы", + "exportServersDatTitle": "Экспортировать список в servers.dat (Minecraft Java)", + "exported": "{{count}} серверов экспортировано в servers.dat", + "exportEmpty": "Нет Java-серверов для экспорта", + "exportError": "Не удалось экспортировать servers.dat", + "exportTitle": "Экспорт в servers.dat", + "exportDescription": "Выбери порядок серверов в списке сетевой игры Minecraft.", + "exportSortLabel": "Порядок серверов", + "exportSortPopularity": "Сначала с большим онлайном", + "exportSortPopularityAsc": "Сначала с меньшим онлайном", + "exportSortName": "По названию (А–Я)", + "exportSortRecent": "Сначала недавно добавленные", + "exportOfflineLabel": "Офлайн-серверы", + "exportOfflineLast": "В конце", + "exportOfflineFirst": "В начале", + "exportIgnoreOffline": "Пропустить офлайн-серверы", + "exportPreview": "Будет экспортировано серверов: {{count}}", + "exportBedrockNote": "Исключено Bedrock-серверов: {{skipped}} (только Java)", + "exportConfirm": "Экспортировать", "filters": { "all": "Все", "online": "Онлайн", diff --git a/frontend/app/locales/zh-CN.json b/frontend/app/locales/zh-CN.json index 6078cdc..2bb0b1c 100644 --- a/frontend/app/locales/zh-CN.json +++ b/frontend/app/locales/zh-CN.json @@ -94,6 +94,25 @@ "noSearchResults": "没有符合您搜索“{{query}}”的服务器。", "noFilterResults": "没有匹配这些筛选条件的服务器。", "error": "加载服务器失败", + "exportServersDat": "导出所有服务器", + "exportServersDatTitle": "将列表导出为 servers.dat(Minecraft Java 版)", + "exported": "已将 {{count}} 个服务器导出到 servers.dat", + "exportEmpty": "没有可导出的 Java 服务器", + "exportError": "导出 servers.dat 失败", + "exportTitle": "导出为 servers.dat", + "exportDescription": "选择 Minecraft 多人游戏列表中的服务器顺序。", + "exportSortLabel": "服务器排序", + "exportSortPopularity": "玩家数多的优先", + "exportSortPopularityAsc": "玩家数少的优先", + "exportSortName": "按名称 (A-Z)", + "exportSortRecent": "最近添加优先", + "exportOfflineLabel": "离线服务器", + "exportOfflineLast": "排在最后", + "exportOfflineFirst": "排在最前", + "exportIgnoreOffline": "跳过离线服务器", + "exportPreview": "将导出 {{count}} 个服务器", + "exportBedrockNote": "已排除 {{skipped}} 个基岩版服务器(仅支持 Java 版)", + "exportConfirm": "导出", "filters": { "all": "全部", "online": "在线", diff --git a/frontend/app/pages/home/components/ExportServersDatButton.tsx b/frontend/app/pages/home/components/ExportServersDatButton.tsx new file mode 100644 index 0000000..4d2721b --- /dev/null +++ b/frontend/app/pages/home/components/ExportServersDatButton.tsx @@ -0,0 +1,198 @@ +import { useMemo, useState } from "react" +import { Download, Loader2 } from "lucide-react" +import { cn } from "@/core/lib/utils" +import { useLanguage } from "@/core/contexts/LanguageContext" +import { useToast } from "@/core/hooks/useToast" +import type { Server } from "@/core/lib/api" +import { downloadServersDat, orderServersForExport } from "@/core/lib/servers-dat" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/ui/components/dialog" +import { Button } from "@/ui/components/button" +import { Label } from "@/ui/components/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/ui/components/select" +import { Checkbox } from "@/ui/components/checkbox" + +type ExportSort = "popularity" | "popularityAsc" | "name" | "recent" +type OfflinePlacement = "last" | "first" + +/** + * Opens a configuration modal, then exports the (filtered) server list as a + * Minecraft Java Edition `servers.dat` file. Place it in your `.minecraft` + * folder and every server shows up in the multiplayer list. + */ +export function ExportServersDatButton({ servers }: { servers: Server[] }) { + const { t } = useLanguage() + const { showToast } = useToast() + const [open, setOpen] = useState(false) + const [exporting, setExporting] = useState(false) + const [sort, setSort] = useState("popularity") + const [offlinePlacement, setOfflinePlacement] = useState("last") + const [ignoreOffline, setIgnoreOffline] = useState(false) + + // servers.dat is Java Edition only: Bedrock entries are skipped. + const javaServers = useMemo( + () => servers.filter((s) => (s.type ?? "java") === "java" && s.ip), + [servers] + ) + const bedrockSkipped = useMemo( + () => servers.filter((s) => (s.type ?? "java") !== "java").length, + [servers] + ) + + const orderedServers = useMemo( + () => + orderServersForExport(javaServers, { + sort, + offlinePlacement, + ignoreOffline, + }), + [javaServers, sort, ignoreOffline, offlinePlacement] + ) + + const handleExport = () => { + if (exporting) return + setExporting(true) + try { + if (orderedServers.length === 0) { + showToast("warning", t("serverList.exportEmpty")) + return + } + const count = downloadServersDat( + orderedServers.map((s) => ({ name: s.name, ip: s.ip, port: s.port })) + ) + showToast("success", t("serverList.exported", { count: String(count) })) + setOpen(false) + } catch (e) { + console.error("Failed to export servers.dat:", e) + showToast("error", t("serverList.exportError")) + } finally { + setExporting(false) + } + } + + return ( + + + + + + + {t("serverList.exportTitle")} + + {t("serverList.exportDescription")} + + +
+
+ + +
+ {!ignoreOffline && ( +
+ + +
+ )} +
+ setIgnoreOffline(checked === true)} + /> + +
+
+

+ {t("serverList.exportPreview", { + count: String(orderedServers.length), + })} +

+ {bedrockSkipped > 0 && ( +

+ {t("serverList.exportBedrockNote", { + skipped: String(bedrockSkipped), + })} +

+ )} +
+
+ + + +
+
+ ) +} diff --git a/frontend/app/pages/home/components/ServerListFilters.tsx b/frontend/app/pages/home/components/ServerListFilters.tsx index 53692d9..0b5792e 100644 --- a/frontend/app/pages/home/components/ServerListFilters.tsx +++ b/frontend/app/pages/home/components/ServerListFilters.tsx @@ -31,6 +31,8 @@ import { LabyLogo } from "@/ui/components/LabyLogo" import { JavaLogo } from "@/ui/components/JavaLogo" import { BedrockLogo } from "@/ui/components/BedrockLogo" import { ToggleGroup, ToggleGroupItem } from "@/ui/components/toggle-group" +import { ExportServersDatButton } from "@/pages/home/components/ExportServersDatButton" +import type { Server } from "@/core/lib/api" interface ServerListFiltersProps { activeTab: "all" | "online" | "offline" | "hidden" @@ -51,6 +53,8 @@ interface ServerListFiltersProps { onResetFilters: () => void onRefresh?: () => void isRefreshing?: boolean + /** Full filtered list (before pagination) used for the servers.dat export. */ + serversForExport?: Server[] } /** Quick filter button (status tabs) */ @@ -239,6 +243,7 @@ export function ServerListFilters({ onResetFilters, onRefresh, isRefreshing, + serversForExport, }: ServerListFiltersProps) { const { t } = useLanguage() const isDesktop = useMediaQuery("(min-width: 640px)") @@ -413,8 +418,9 @@ export function ServerListFilters({ )} - {/* Actions: Refresh & Filters/Sort */} + {/* Actions: Refresh, Export & Filters/Sort */}
+ {serversForExport && } {onRefresh && (