From bc2fb593ec7e7c244730c7c201fe68e7594404f2 Mon Sep 17 00:00:00 2001 From: dockfries Date: Tue, 27 Oct 2026 09:57:15 +0800 Subject: [PATCH 1/6] move server details to the query string and localize the relative time /servers?ip= keeps the served document pre-rendered, so the client tree matches the server HTML; legacy /servers/ links are forwarded there. The last-updated label is built from Intl.NumberFormat units in the page locale, so it fits each language's existing sentence template, and it is computed after mount and refreshed every minute. Unknown servers now report a not-found message instead of loading forever. --- frontend/docusaurus.config.ts | 48 ++++---- frontend/i18n/fa/code.json | 3 + frontend/i18n/id/code.json | 3 + frontend/i18n/pt-BR/code.json | 3 + frontend/i18n/th/code.json | 3 + frontend/i18n/tr/code.json | 3 + frontend/i18n/uk/code.json | 3 + frontend/i18n/zh-CN/code.json | 3 + frontend/src/components/ServerInfoPage.tsx | 123 +++++++++++---------- frontend/src/components/ServerRow.tsx | 2 +- frontend/src/lib/time.ts | 123 +++++++++++++++++++++ frontend/src/pages/servers/index.tsx | 21 ++-- 12 files changed, 247 insertions(+), 91 deletions(-) create mode 100644 frontend/src/lib/time.ts diff --git a/frontend/docusaurus.config.ts b/frontend/docusaurus.config.ts index f94699fd26c7..720188185822 100644 --- a/frontend/docusaurus.config.ts +++ b/frontend/docusaurus.config.ts @@ -105,28 +105,6 @@ const config: Config = { staticDirectories: ["public"], - plugins: [ - async function serverRoutesPlugin(context, options) { - return { - name: "server-routes-plugin", - async contentLoaded({ actions }) { - const { addRoute } = actions; - const { i18n: { locales, defaultLocale } } = context; - - // Add the dynamic server detail route. - locales.forEach((locale) => { - const prefix = locale === defaultLocale ? '' : `/${locale}`; - addRoute({ - path: `${prefix}/servers/`, - component: "@site/src/pages/servers/index", - exact: false, - }); - }) - }, - }; - }, - ], - presets: [ [ "classic", @@ -244,7 +222,29 @@ const config: Config = { var locales = ["en","es","ru","ar","bs","de","fil","fr","fa","hu","id","it","nl","pl","pt-BR","ro","sl","sr","ta","th","tr","uk","vi","zh-CN","zh-TW"]; var path = window.location.pathname; var firstSegment = path.split("/")[1]; - + + var pageLocale = document.documentElement.getAttribute("lang") || "en"; + var lastSegment = path.split("/").pop(); + var is404Page = lastSegment === "404" || lastSegment === "404.html"; + + var localePrefix = locales.indexOf(firstSegment) !== -1 ? "/" + firstSegment : ""; + var rest = path.slice(localePrefix.length); + var serversPrefix = "/servers/"; + + if(rest.indexOf(serversPrefix) === 0 && rest.length > serversPrefix.length) { + var address = rest.slice(serversPrefix.length); + if(address.charAt(address.length - 1) === "/") address = address.slice(0, -1); + try { address = decodeURIComponent(address); } catch (e) {} + window.location.replace(localePrefix + "/servers?ip=" + encodeURIComponent(address)); + return; + } + + if(!is404Page && firstSegment !== pageLocale && locales.indexOf(firstSegment) !== -1) { + localStorage.setItem("omp-locale", firstSegment); + window.location.replace("/" + firstSegment + "/404"); + return; + } + if(locales.indexOf(firstSegment) !== -1) { localStorage.setItem("omp-locale", firstSegment); return; @@ -272,7 +272,7 @@ const config: Config = { if(locales.indexOf(target) === -1 && navLang.indexOf("-") !== -1) { target = navLang.split("-")[0]; } - + if(locales.indexOf(target) !== -1 && target !== "en") { localStorage.setItem("omp-locale", target); window.location.href = "/" + target + path + window.location.search + window.location.hash; diff --git a/frontend/i18n/fa/code.json b/frontend/i18n/fa/code.json index 919a7f5528ff..282f11537a20 100644 --- a/frontend/i18n/fa/code.json +++ b/frontend/i18n/fa/code.json @@ -283,6 +283,9 @@ "serverInfo.lastUpdated": { "message": "{time} پیش به‌روزرسانی شده" }, + "serverInfo.notFound": { + "message": "این سرور پیدا نشد. ممکن است آفلاین باشد یا دیگر در فهرست نباشد." + }, "theme.footer.title.Documentations": { "message": "مستندات" }, diff --git a/frontend/i18n/id/code.json b/frontend/i18n/id/code.json index df87aeb41514..22a445c49bc9 100644 --- a/frontend/i18n/id/code.json +++ b/frontend/i18n/id/code.json @@ -289,6 +289,9 @@ "serverInfo.lastUpdated": { "message": "Diperbarui {time} yang lalu" }, + "serverInfo.notFound": { + "message": "Server ini tidak ditemukan. Mungkin server sedang offline atau tidak lagi terdaftar." + }, "downloads.server.pageTitle": { "message": "Server open.mp" }, diff --git a/frontend/i18n/pt-BR/code.json b/frontend/i18n/pt-BR/code.json index 10ecf08397a2..a58aed644377 100644 --- a/frontend/i18n/pt-BR/code.json +++ b/frontend/i18n/pt-BR/code.json @@ -296,6 +296,9 @@ "serverInfo.lastUpdated": { "message": "Atualizado há {time}" }, + "serverInfo.notFound": { + "message": "Este servidor não foi encontrado. Ele pode estar offline ou não estar mais na lista." + }, "downloads.server.pageTitle": { "message": "Servidor open.mp" }, diff --git a/frontend/i18n/th/code.json b/frontend/i18n/th/code.json index 3188e886cb7b..f3a6723f17c1 100644 --- a/frontend/i18n/th/code.json +++ b/frontend/i18n/th/code.json @@ -289,6 +289,9 @@ "serverInfo.lastUpdated": { "message": "อัปเดตเมื่อ {time} ที่แล้ว" }, + "serverInfo.notFound": { + "message": "ไม่พบเซิร์ฟเวอร์นี้ อาจออฟไลน์อยู่หรือถูกนำออกจากรายการแล้ว" + }, "downloads.server.pageTitle": { "message": "เซิร์ฟเวอร์ open.mp" }, diff --git a/frontend/i18n/tr/code.json b/frontend/i18n/tr/code.json index 939018bf5a48..27c1f64841cb 100644 --- a/frontend/i18n/tr/code.json +++ b/frontend/i18n/tr/code.json @@ -283,6 +283,9 @@ "serverInfo.lastUpdated": { "message": "{time} önce güncellendi" }, + "serverInfo.notFound": { + "message": "Bu sunucu bulunamadı. Çevrimdışı olabilir veya artık listelenmiyor olabilir." + }, "theme.footer.title.Documentations": { "message": "Dokümantasyon" }, diff --git a/frontend/i18n/uk/code.json b/frontend/i18n/uk/code.json index dd55c7b6d6e8..96f9666c8da0 100644 --- a/frontend/i18n/uk/code.json +++ b/frontend/i18n/uk/code.json @@ -283,6 +283,9 @@ "serverInfo.lastUpdated": { "message": "Оновлено {time} тому" }, + "serverInfo.notFound": { + "message": "Сервер не знайдено. Можливо, він офлайн або його більше немає у списку." + }, "theme.footer.title.Documentations": { "message": "Документація" }, diff --git a/frontend/i18n/zh-CN/code.json b/frontend/i18n/zh-CN/code.json index 023c80757907..20aeda8e61e3 100644 --- a/frontend/i18n/zh-CN/code.json +++ b/frontend/i18n/zh-CN/code.json @@ -296,6 +296,9 @@ "serverInfo.lastUpdated": { "message": "{time}前更新" }, + "serverInfo.notFound": { + "message": "找不到该服务器,它可能已离线或不再列出。" + }, "downloads.server.pageTitle": { "message": "open.mp 服务器" }, diff --git a/frontend/src/components/ServerInfoPage.tsx b/frontend/src/components/ServerInfoPage.tsx index 897feb9b7074..13f20745cda9 100644 --- a/frontend/src/components/ServerInfoPage.tsx +++ b/frontend/src/components/ServerInfoPage.tsx @@ -1,11 +1,12 @@ import Link from "@docusaurus/Link"; +import Translate from "@docusaurus/Translate"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import LoadingBanner from "@site/src/components/LoadingBanner"; import { API_ADDRESS } from "@site/src/constants"; +import { formatElapsed } from "@site/src/lib/time"; import { ServerAllData } from "@site/src/types"; import Layout from "@theme/Layout"; -import Translate from "@docusaurus/Translate"; import { useEffect, useState } from "react"; -import BrowserOnly from "@docusaurus/BrowserOnly"; const API_SERVER = (ip: string) => `${API_ADDRESS}/servers/${ip}`; @@ -72,6 +73,45 @@ const ServerAddressCopy = ({ address }: { address: string }) => { ); }; +const useNow = (intervalMs = 60_000): number | null => { + const [now, setNow] = useState(null); + + useEffect(() => { + const tick = () => setNow(Date.now()); + tick(); + const id = window.setInterval(tick, intervalMs); + return () => window.clearInterval(id); + }, [intervalMs]); + + return now; +}; + +const UpdatedFooter = ({ lastUpdated }: { lastUpdated: string }) => { + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const now = useNow(); + const timestamp = new Date(lastUpdated).getTime(); + + if (now === null || Number.isNaN(timestamp)) { + return null; + } + + return ( +
+ +
+ ); +}; + const Info = ({ data }: { data: ServerAllData }) => (
@@ -159,26 +199,7 @@ const Info = ({ data }: { data: ServerAllData }) => (
- - {() => ( -
- -
- )} -
+
@@ -189,17 +210,39 @@ const Content = ({ ip }: { ip: string }) => { const [data, setData] = useState(undefined); useEffect(() => { + let cancelled = false; + setLoading(true); getServer(ip).then((server) => { - setLoading(false); + if (cancelled) { + return; + } setData(server); + setLoading(false); }); + + return () => { + cancelled = true; + }; }, [ip]); - if (!data || loading) { + if (loading) { return ; } + if (!data) { + return ( +

+ + This server could not be found. It may be offline or no longer listed. + +

+ ); + } + return ; }; @@ -221,35 +264,3 @@ const ServerInfoPage = ({ ip }: { ip: string }) => { }; export default ServerInfoPage; - -const formatDistance = ( - date: Date | string | number, - baseDate: Date | string | number = new Date() -): string => { - const compareDate = date instanceof Date ? date : new Date(date); - const baseDateObj = baseDate instanceof Date ? baseDate : new Date(baseDate); - - const diff = baseDateObj.getTime() - compareDate.getTime(); - const absDiff = Math.abs(diff); - - const seconds = Math.floor(absDiff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - const months = Math.floor(days / 30); - const years = Math.floor(days / 365); - - if (years > 0) { - return years === 1 ? "1 year" : `${years} years`; - } else if (months > 0) { - return months === 1 ? "1 month" : `${months} months`; - } else if (days > 0) { - return days === 1 ? "1 day" : `${days} days`; - } else if (hours > 0) { - return hours === 1 ? "1 hour" : `${hours} hours`; - } else if (minutes > 0) { - return minutes === 1 ? "1 minute" : `${minutes} minutes`; - } else { - return "less than a minute"; - } -}; diff --git a/frontend/src/components/ServerRow.tsx b/frontend/src/components/ServerRow.tsx index 3188d352297d..f289a708c9d9 100644 --- a/frontend/src/components/ServerRow.tsx +++ b/frontend/src/components/ServerRow.tsx @@ -41,7 +41,7 @@ const ServerRow = ({ server }: { server: CoreServerData }) => {

{server.hn}

diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts new file mode 100644 index 000000000000..78f8e1b0679a --- /dev/null +++ b/frontend/src/lib/time.ts @@ -0,0 +1,123 @@ +const UNIT_STEPS = [ + ["year", 365 * 24 * 60 * 60], + ["month", 30 * 24 * 60 * 60], + ["day", 24 * 60 * 60], + ["hour", 60 * 60], + ["minute", 60], +] as const; + +type TimeUnit = (typeof UNIT_STEPS)[number][0]; + +const ENGLISH_UNITS: Record = { + year: ["year", "years"], + month: ["month", "months"], + day: ["day", "days"], + hour: ["hour", "hours"], + minute: ["minute", "minutes"], +}; + +const formatters = new Map(); + +let unitStyleSupport: boolean | undefined; + +function supportsUnitStyle(): boolean { + if (unitStyleSupport === undefined) { + try { + unitStyleSupport = + new Intl.NumberFormat("en", { + style: "unit", + unit: "hour", + unitDisplay: "long", + }).format(1) === "1 hour"; + } catch { + unitStyleSupport = false; + } + } + + return unitStyleSupport; +} + +function resolveLocale(locale: string): string { + try { + const [supported] = Intl.NumberFormat.supportedLocalesOf([locale]); + return supported ?? "en"; + } catch { + return "en"; + } +} + +function getUnitFormatter( + locale: string, + unit: TimeUnit, +): Intl.NumberFormat | undefined { + if (!supportsUnitStyle()) { + return undefined; + } + + const resolved = resolveLocale(locale); + const cacheKey = `${resolved}|${unit}`; + const cached = formatters.get(cacheKey); + + if (cached) { + return cached; + } + + const formatter = new Intl.NumberFormat(resolved, { + style: "unit", + unit, + unitDisplay: "long", + maximumFractionDigits: 0, + }); + + formatters.set(cacheKey, formatter); + return formatter; +} + +function formatEnglishUnit(value: number, unit: TimeUnit): string { + const [singular, plural] = ENGLISH_UNITS[unit]; + return `${value} ${value === 1 ? singular : plural}`; +} + +export function formatElapsed( + locale: string, + timestamp: number, + now: number, +): string { + const seconds = Math.max(0, Math.round((now - timestamp) / 1000)); + const parts: string[] = []; + let remaining = seconds; + + for (const [unit, size] of UNIT_STEPS) { + const value = Math.floor(remaining / size); + remaining -= value * size; + + if (value === 0) { + continue; + } + + parts.push( + getUnitFormatter(locale, unit)?.format(value) ?? + formatEnglishUnit(value, unit), + ); + + if (parts.length === 3) { + break; + } + } + + if (parts.length === 0) { + return ( + getUnitFormatter(locale, "minute")?.format(1) ?? + formatEnglishUnit(1, "minute") + ); + } + + try { + return new Intl.ListFormat(resolveLocale(locale), { + style: "long", + type: "unit", + }).format(parts); + } catch { + return parts.join(" "); + } +} diff --git a/frontend/src/pages/servers/index.tsx b/frontend/src/pages/servers/index.tsx index bd970e4c20e4..6c593590b362 100644 --- a/frontend/src/pages/servers/index.tsx +++ b/frontend/src/pages/servers/index.tsx @@ -19,15 +19,8 @@ import { default as Translate, translate } from "@docusaurus/Translate"; const API_SERVERS = `${API_ADDRESS}/servers/`; -const getServerAddressFromPathname = (pathname: string) => { - const match = pathname.match(/\/servers\/(.+)$/); - - if (!match) { - return undefined; - } - - return decodeURIComponent(match[1].replace(/\/$/, "")); -}; +const getServerAddressFromQuery = (search: string): string => + new URLSearchParams(search).get("ip") ?? ""; const getServers = async () => { try { @@ -545,6 +538,10 @@ const ServersListPage = (): ReactNode => { const [data, setData] = useState([]); useEffect(() => { + if (getServerAddressFromQuery(window.location.search)) { + return; + } + getServers().then((servers) => { setLoading(false); setData(servers); @@ -586,7 +583,11 @@ const ServersListPage = (): ReactNode => { const Page = (): ReactNode => { const location = useLocation(); - const serverAddress = getServerAddressFromPathname(location.pathname); + const [serverAddress, setServerAddress] = useState(null); + + useEffect(() => { + setServerAddress(getServerAddressFromQuery(location.search)); + }, [location.search]); if (serverAddress) { return ; From af029144d2d9aeff549aa994bbc62d272a93bb95 Mon Sep 17 00:00:00 2001 From: dockfries Date: Tue, 27 Oct 2026 10:11:23 +0800 Subject: [PATCH 2/6] return to the partners list from a server page carry the origin of the click as a from query parameter, and use it for the back link label and target. --- frontend/i18n/fa/code.json | 3 +++ frontend/i18n/id/code.json | 3 +++ frontend/i18n/pt-BR/code.json | 3 +++ frontend/i18n/th/code.json | 3 +++ frontend/i18n/tr/code.json | 3 +++ frontend/i18n/uk/code.json | 3 +++ frontend/i18n/zh-CN/code.json | 3 +++ frontend/src/components/ServerInfoPage.tsx | 12 +++++++++--- frontend/src/components/ServerRow.tsx | 4 ++-- frontend/src/pages/partners.tsx | 2 +- frontend/src/pages/servers/index.tsx | 7 +++++-- 11 files changed, 38 insertions(+), 8 deletions(-) diff --git a/frontend/i18n/fa/code.json b/frontend/i18n/fa/code.json index 282f11537a20..1b2eb6f7969e 100644 --- a/frontend/i18n/fa/code.json +++ b/frontend/i18n/fa/code.json @@ -259,6 +259,9 @@ "serverInfo.backToServers": { "message": "→ بازگشت به سرورها" }, + "serverInfo.backToPartners": { + "message": "← بازگشت به سرورهای شریک" + }, "serverInfo.quickJoin": { "message": "ورود سریع" }, diff --git a/frontend/i18n/id/code.json b/frontend/i18n/id/code.json index 22a445c49bc9..9acda2ec2802 100644 --- a/frontend/i18n/id/code.json +++ b/frontend/i18n/id/code.json @@ -265,6 +265,9 @@ "serverInfo.backToServers": { "message": "← Kembali ke Server" }, + "serverInfo.backToPartners": { + "message": "← Kembali ke Server Mitra" + }, "serverInfo.quickJoin": { "message": "Bergabung Cepat" }, diff --git a/frontend/i18n/pt-BR/code.json b/frontend/i18n/pt-BR/code.json index a58aed644377..17aab3ff007e 100644 --- a/frontend/i18n/pt-BR/code.json +++ b/frontend/i18n/pt-BR/code.json @@ -272,6 +272,9 @@ "serverInfo.backToServers": { "message": "← Voltar aos Servidores" }, + "serverInfo.backToPartners": { + "message": "← Voltar aos Servidores Parceiros" + }, "serverInfo.quickJoin": { "message": "Entrar Rapidamente" }, diff --git a/frontend/i18n/th/code.json b/frontend/i18n/th/code.json index f3a6723f17c1..71e31469735b 100644 --- a/frontend/i18n/th/code.json +++ b/frontend/i18n/th/code.json @@ -265,6 +265,9 @@ "serverInfo.backToServers": { "message": "← กลับไปที่เซิร์ฟเวอร์" }, + "serverInfo.backToPartners": { + "message": "← กลับไปที่เซิร์ฟเวอร์พันธมิตร" + }, "serverInfo.quickJoin": { "message": "เข้าร่วมอย่างรวดเร็ว" }, diff --git a/frontend/i18n/tr/code.json b/frontend/i18n/tr/code.json index 27c1f64841cb..b1c7f481eaf5 100644 --- a/frontend/i18n/tr/code.json +++ b/frontend/i18n/tr/code.json @@ -259,6 +259,9 @@ "serverInfo.backToServers": { "message": "← Sunuculara Geri Dön" }, + "serverInfo.backToPartners": { + "message": "← İş Ortağı Sunuculara Dön" + }, "serverInfo.quickJoin": { "message": "Hızlı Katıl" }, diff --git a/frontend/i18n/uk/code.json b/frontend/i18n/uk/code.json index 96f9666c8da0..1d7f1da5a859 100644 --- a/frontend/i18n/uk/code.json +++ b/frontend/i18n/uk/code.json @@ -259,6 +259,9 @@ "serverInfo.backToServers": { "message": "← Назад до серверів" }, + "serverInfo.backToPartners": { + "message": "← Назад до серверів-партнерів" + }, "serverInfo.quickJoin": { "message": "Швидкий вхід" }, diff --git a/frontend/i18n/zh-CN/code.json b/frontend/i18n/zh-CN/code.json index 20aeda8e61e3..31f1d6a57f43 100644 --- a/frontend/i18n/zh-CN/code.json +++ b/frontend/i18n/zh-CN/code.json @@ -272,6 +272,9 @@ "serverInfo.backToServers": { "message": "← 返回服务器列表" }, + "serverInfo.backToPartners": { + "message": "← 返回合作伙伴服务器" + }, "serverInfo.quickJoin": { "message": "快速加入" }, diff --git a/frontend/src/components/ServerInfoPage.tsx b/frontend/src/components/ServerInfoPage.tsx index 13f20745cda9..850038bd6da3 100644 --- a/frontend/src/components/ServerInfoPage.tsx +++ b/frontend/src/components/ServerInfoPage.tsx @@ -21,9 +21,15 @@ const getServer = async (ip: string): Promise => { }; const BackLink: React.FC<{ to: string }> = ({ to }) => { + const isPartners = to === "/partners"; + return ( - ← Back to Servers + {isPartners ? ( + ← Back to Partner Servers + ) : ( + ← Back to Servers + )} ); }; @@ -246,7 +252,7 @@ const Content = ({ ip }: { ip: string }) => { return ; }; -const ServerInfoPage = ({ ip }: { ip: string }) => { +const ServerInfoPage = ({ ip, from }: { ip: string; from?: string }) => { if (!ip) { return ; } @@ -255,7 +261,7 @@ const ServerInfoPage = ({ ip }: { ip: string }) => {
- +
diff --git a/frontend/src/components/ServerRow.tsx b/frontend/src/components/ServerRow.tsx index f289a708c9d9..11b46801b8d2 100644 --- a/frontend/src/components/ServerRow.tsx +++ b/frontend/src/components/ServerRow.tsx @@ -36,12 +36,12 @@ const CopyBadge = ({ text }: { text: string }) => { ); }; -const ServerRow = ({ server }: { server: CoreServerData }) => { +const ServerRow = ({ server, from }: { server: CoreServerData; from?: string }) => { return (

{server.hn}

diff --git a/frontend/src/pages/partners.tsx b/frontend/src/pages/partners.tsx index 5f4608ce9850..069582628b21 100644 --- a/frontend/src/pages/partners.tsx +++ b/frontend/src/pages/partners.tsx @@ -223,7 +223,7 @@ const List = ({ data }: { data: CoreServerData[] }) => { const server = filteredData[index]; return (
- +
); }; diff --git a/frontend/src/pages/servers/index.tsx b/frontend/src/pages/servers/index.tsx index 6c593590b362..3f3d1bfe4966 100644 --- a/frontend/src/pages/servers/index.tsx +++ b/frontend/src/pages/servers/index.tsx @@ -584,13 +584,16 @@ const ServersListPage = (): ReactNode => { const Page = (): ReactNode => { const location = useLocation(); const [serverAddress, setServerAddress] = useState(null); + const [from, setFrom] = useState(""); useEffect(() => { - setServerAddress(getServerAddressFromQuery(location.search)); + const params = new URLSearchParams(location.search); + setServerAddress(params.get("ip") ?? ""); + setFrom(params.get("from") ?? ""); }, [location.search]); if (serverAddress) { - return ; + return ; } return ; From 6a41f7cbfc087f6b4fba688d9e39457732acf74b Mon Sep 17 00:00:00 2001 From: dockfries Date: Tue, 27 Oct 2026 10:56:56 +0800 Subject: [PATCH 3/6] document locale options and local previews adds locale examples for the dev server and build commands, a section on previewing a build, and switches the deploy examples from yarn to npm. --- frontend/README.md | 59 +++++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index 4620fb955e7d..4ecf8547e73a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -16,25 +16,41 @@ We welcome contributions to our documentation! Whether you're fixing a typo or a To preview your changes locally: -1. **Navigate to the frontend directory:** - ```bash - cd frontend - ``` -2. **Install dependencies:** - ```bash - npm install - ``` -3. **Start the development server:** - ```bash - npm start - ``` - The site will be available at `http://localhost:3000`. Most changes to Markdown files will live-reload in the browser. +1. **Navigate to the frontend directory:** + + ```bash + cd frontend + ``` + +2. **Install dependencies:** + + ```bash + npm install + ``` + +3. **Start the development server:** + + ```bash + npm start + ``` + + The site will be available at `http://localhost:3000`. Most changes to Markdown files will live-reload in the browser. + + #### Choosing a locale + + ```bash + npm start -- --locale pt-BR # http://localhost:3000/pt-BR/ + npm start -- --locale zh-CN # http://localhost:3000/zh-CN/ + ``` + + `start` accepts a **single** locale — a second `--locale` is silently ignored (the last one wins) — so run one dev server per language on different ports. ### Adding or Editing Pages - To **edit** an existing page, find the corresponding `.md` file in `docs/` and make your changes. - To **add** a new page, create a new `.md` file in the appropriate subdirectory of `docs/`. - Remember to update the **metadata** (front matter) at the top of the file: + ```markdown --- title: Page Title @@ -58,29 +74,38 @@ npm i ### Local Development ```bash -npm start +npm start # default locale +npm start -- --locale pt-BR # specific locale ``` ### Build ```bash -npm run build +npm run build # all locales +npm run build -- --locale pt-BR # one locale +npm run build -- --locale en --locale pt-BR --locale zh-CN # several locales ``` This command generates static content into the `build` directory. +### Local Preview + +```bash +npm run serve +``` + ### Deployment Using SSH: ``` -$ USE_SSH=true yarn deploy +$ USE_SSH=true npm run deploy ``` Not using SSH: ``` -$ GIT_USER= yarn deploy +$ GIT_USER= npm run deploy ``` If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. From 0fe720b5576f720085b65bcff132febea2df5154 Mon Sep 17 00:00:00 2001 From: dockfries Date: Mon, 2 Nov 2026 12:07:09 +0800 Subject: [PATCH 4/6] only redirect to a localized 404 from an actual 404 page render a marker meta on the 404 page and check it in the head script (retrying on DOMContentLoaded), so a locale mismatch alone can no longer trigger the redirect. --- frontend/docusaurus.config.ts | 9 +++++++-- frontend/src/theme/NotFound/Content/index.tsx | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/docusaurus.config.ts b/frontend/docusaurus.config.ts index 720188185822..e44240a8e7d2 100644 --- a/frontend/docusaurus.config.ts +++ b/frontend/docusaurus.config.ts @@ -239,12 +239,17 @@ const config: Config = { return; } - if(!is404Page && firstSegment !== pageLocale && locales.indexOf(firstSegment) !== -1) { + function redirectLocale404() { + if(is404Page || firstSegment === pageLocale || locales.indexOf(firstSegment) === -1) return; + if(!document.querySelector("meta[name=omp-not-found]")) return; + localStorage.setItem("omp-locale", firstSegment); window.location.replace("/" + firstSegment + "/404"); - return; } + redirectLocale404(); + document.addEventListener("DOMContentLoaded", redirectLocale404); + if(locales.indexOf(firstSegment) !== -1) { localStorage.setItem("omp-locale", firstSegment); return; diff --git a/frontend/src/theme/NotFound/Content/index.tsx b/frontend/src/theme/NotFound/Content/index.tsx index d46927eeaa3f..175aae54f577 100644 --- a/frontend/src/theme/NotFound/Content/index.tsx +++ b/frontend/src/theme/NotFound/Content/index.tsx @@ -1,3 +1,4 @@ +import Head from "@docusaurus/Head"; import Link from "@docusaurus/Link"; import Translate, { translate } from "@docusaurus/Translate"; import type { Props } from "@theme/NotFound/Content"; @@ -9,6 +10,9 @@ import styles from "./styles.module.css"; export default function NotFoundContent({ className }: Props): ReactNode { return (
+ + +

From 868c8f03f14bc3c8ae0b2363aa6bc65b7d165e3f Mon Sep 17 00:00:00 2001 From: dockfries Date: Tue, 3 Nov 2026 13:10:11 +0800 Subject: [PATCH 5/6] add types for nprogress --- frontend/package-lock.json | 68 +++++--------------------------------- frontend/package.json | 1 + 2 files changed, 9 insertions(+), 60 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 244be1fac96a..97d845b20214 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "@docusaurus/module-type-aliases": "^3.10.1", "@docusaurus/tsconfig": "^3.10.1", "@docusaurus/types": "^3.10.1", + "@types/nprogress": "^0.2.3", "typescript": "~5.6.2" }, "engines": { @@ -5173,9 +5174,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5189,9 +5187,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5205,9 +5200,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5221,9 +5213,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5712,9 +5701,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5731,9 +5717,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5750,9 +5733,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5769,9 +5749,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5788,9 +5765,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5807,9 +5781,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5954,9 +5925,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5973,9 +5941,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5992,9 +5957,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6011,9 +5973,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6030,9 +5989,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6049,9 +6005,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6340,6 +6293,13 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/nprogress": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@types/nprogress/-/nprogress-0.2.3.tgz", + "integrity": "sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -11446,9 +11406,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11469,9 +11426,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11492,9 +11446,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11515,9 +11466,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/package.json b/frontend/package.json index dec2e2a9f6e4..8e74bc91cb51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "@docusaurus/module-type-aliases": "^3.10.1", "@docusaurus/tsconfig": "^3.10.1", "@docusaurus/types": "^3.10.1", + "@types/nprogress": "^0.2.3", "typescript": "~5.6.2" }, "browserslist": { From 099a121e818b08fbe7784dacfab28afaaac69b00 Mon Sep 17 00:00:00 2001 From: dockfries Date: Tue, 3 Nov 2026 13:10:13 +0800 Subject: [PATCH 6/6] scroll server lists back to the top when the filters change --- frontend/src/pages/partners.tsx | 69 +++++++++++++--------- frontend/src/pages/servers/index.tsx | 85 +++++++++++++++++----------- 2 files changed, 95 insertions(+), 59 deletions(-) diff --git a/frontend/src/pages/partners.tsx b/frontend/src/pages/partners.tsx index 069582628b21..4445f013b47f 100644 --- a/frontend/src/pages/partners.tsx +++ b/frontend/src/pages/partners.tsx @@ -2,7 +2,11 @@ import Layout from "@theme/Layout"; import { ReactNode, useEffect, useMemo, useState } from "react"; import clsx from "clsx"; import { default as Translate, translate } from "@docusaurus/Translate"; -import { List as FixedSizeList, RowComponentProps } from "react-window"; +import { + List as FixedSizeList, + RowComponentProps, + useListRef, +} from "react-window"; import LoadingBanner from "../components/LoadingBanner"; import Admonition from "../components/Admonition"; import ServerRow from "../components/ServerRow"; @@ -199,23 +203,25 @@ const StatsComponent = ({ stats: { players, servers } }: { stats: Stats }) => { ); }; +const initialQuery: Query = { + search: "", + showEmpty: true, + showPassworded: true, + language: "", + sort: "relevance", +}; + // List Component const List = ({ data }: { data: CoreServerData[] }) => { - const [search, setSearch] = useState(""); - const [showEmpty, setShowEmpty] = useState(true); - const [showPassworded, setShowPassworded] = useState(true); - const [language, setLanguage] = useState(""); - const [sort, setSort] = useState("relevance"); - - const filteredData = useMemo(() => { - return filterServers(data, { - search, - showEmpty, - showPassworded, - language, - sort, - }); - }, [data, search, showEmpty, showPassworded, language, sort]); + const [query, setQuery] = useState(initialQuery); + + const filteredData = useMemo(() => filterServers(data, query), [data, query]); + + const listRef = useListRef(null); + + useEffect(() => { + listRef.current?.element?.scrollTo({ top: 0, behavior: "smooth" }); + }, [query]); const rowHeight = 134; @@ -233,8 +239,10 @@ const List = ({ data }: { data: CoreServerData[] }) => {
setShowEmpty(e.target.checked)} + checked={query.showEmpty} + onChange={(e) => + setQuery((q) => ({ ...q, showEmpty: e.target.checked })) + } className="servers-checkbox" /> @@ -323,8 +337,10 @@ const List = ({ data }: { data: CoreServerData[] }) => {