From 90207a85bcad15a016dbebc68265fca313b5a632 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 29 Jul 2026 15:11:57 +0300 Subject: [PATCH 1/2] Pin the public map, fit the site to a phone, and stop refetching heads (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A world can be pinned.** The public map followed whichever dimension people were standing in and showed nothing when nobody was — so on a quiet server there was nothing to open. An operator can now choose one world, picked from those that exist on disk rather than typed from memory, and the site always shows it: with nobody online, and whatever the query string says. The visitor gets no dimension switcher with it, because a control that cannot change the answer is not a control. Following the players stays the default. The chosen name becomes a path segment when the tiles are read, so it is restricted where it is stored rather than trusted where it is used. **The header lines up, and the page fits a phone.** The right-hand controls — language and the account chip — were loose children of the nav row, so a narrow window dealt them out among the links. They are one group now, the brand shrinks instead of shoving everything out of the row, and the links get their own full-width scrollable strip below. A second breakpoint at phone width drops the brand *name* (the logo still says which server this is), tightens the buttons, and stacks the profile: smaller head, wrapping stat and coordinate chips, a denser inventory grid, and a map toolbar that fits. **Player heads stop reloading.** Three things were causing it: the requested size was baked into the URL, so the 46px list avatar and the 72px detail avatar were two separate images of the same player; the uuid was preferred over the name, and on an offline-mode server that uuid is the derived one no skin service has ever seen — the same trap as the map heads in #116; and a failure lived only in component state, so every remount asked again and showed a gap while it failed. One size is fetched and CSS scales it, the name decides, and the outcome is remembered for the session. A head that is missing or still coming is a drawn placeholder rather than a hole. Asserted: the pin survives the config, overrides the query, reports itself and offers exactly one dimension; an unpinned map still follows the query; and every shape of bad world name — traversal, a slash, a dot, a space, an over-long one — is refused at the boundary. --- src/main/smoke.ts | 41 ++++++++ src/main/web/publicSiteHtml.ts | 43 +++++++- src/main/web/server.ts | 12 ++- src/main/web/site.ts | 9 ++ src/renderer/src/components/PlayerAvatar.tsx | 103 ++++++++++++++----- src/renderer/src/locales/en.ts | 4 + src/renderer/src/locales/tr.ts | 4 + src/renderer/src/views/SiteView.tsx | 41 ++++++++ src/shared/livemap.ts | 12 ++- src/shared/mapUi.ts | 3 + src/shared/web.ts | 2 + 11 files changed, 238 insertions(+), 36 deletions(-) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index a6a731d..67e0a10 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -4946,6 +4946,47 @@ export async function runWebSmoke(): Promise { if (itemLabel('') !== '?') return fail('an empty item id has no label') } + // ---- pinning the public map to one world (#137) ---- + { + const before = siteMod.getSiteConfig().map + try { + siteMod.setSiteConfig({ map: { ...before, enabled: true, serverId: id, fixedDim: 'nether' } }) + const cfg = siteMod.publicMapConfig() + if (cfg?.fixedDim !== 'nether') return fail('the pinned world did not survive the config') + + // A dimension name becomes a path segment when the tiles are read, so + // anything that is not a plain name is refused at the boundary rather + // than trusted at the point of use. + for (const bad of ['../../etc', 'a/b', '..', '.', 'x'.repeat(80), 'a b']) { + siteMod.setSiteConfig({ map: { ...before, enabled: true, serverId: id, fixedDim: bad } }) + const got = siteMod.getSiteConfig().map.fixedDim + if (got !== '') return fail('a bad pinned world was accepted: ' + JSON.stringify(bad) + ' -> ' + got) + } + + // Pinned, the feed answers with that world whatever the caller asks + // for, and offers exactly one — a switcher that cannot change the + // answer is not a switcher. + siteMod.setSiteConfig({ map: { ...before, enabled: true, serverId: id, fixedDim: 'end' } }) + const pr2 = await sget('/api/public/map?dim=overworld') + if (pr2.status !== 200) return fail('the pinned public map expected 200, got ' + pr2.status) + const feed = (await pr2.json()) as { dimension: string; dimensions: string[]; pinned: boolean } + if (feed.dimension !== 'end') return fail('the pin did not override the query: ' + feed.dimension) + if (!feed.pinned) return fail('the feed did not say it was pinned') + if (feed.dimensions.length !== 1 || feed.dimensions[0] !== 'end') { + return fail('a pinned feed offered a switcher: ' + JSON.stringify(feed.dimensions)) + } + + // Unpinned, it follows the query again. + siteMod.setSiteConfig({ map: { ...before, enabled: true, serverId: id, fixedDim: '' } }) + const pr3 = await sget('/api/public/map?dim=nether') + const feed3 = (await pr3.json()) as { dimension: string; pinned: boolean } + if (feed3.dimension !== 'nether') return fail('an unpinned map ignored the query') + if (feed3.pinned) return fail('an unpinned map claimed to be pinned') + } finally { + siteMod.setSiteConfig({ map: before }) + } + } + console.log('WEB-SMOKE: profile visibility OK (' + checked + ' checks, omitted not hidden, per-field toggles)') // ---- the refresh budget (#117) ---- diff --git a/src/main/web/publicSiteHtml.ts b/src/main/web/publicSiteHtml.ts index 695343a..c79e122 100644 --- a/src/main/web/publicSiteHtml.ts +++ b/src/main/web/publicSiteHtml.ts @@ -42,7 +42,12 @@ img{max-width:100%;display:block} /* header */ header{position:sticky;top:0;z-index:40;backdrop-filter:blur(16px) saturate(140%);background:color-mix(in srgb,var(--bg) 76%,transparent);border-bottom:1px solid var(--line)} .nav{display:flex;align-items:center;gap:8px;height:70px} -.brand{display:flex;align-items:center;gap:11px;font-weight:850;font-size:19.5px;letter-spacing:-.3px;margin-right:18px} +/* min-width:0 so a long server name shortens instead of shoving the nav links + and the account chip out of the row (#137). */ +.brand{display:flex;align-items:center;gap:11px;font-weight:850;font-size:19.5px;letter-spacing:-.3px; + margin-right:18px;min-width:0;flex-shrink:0} +.brand span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.navend{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto} .brand img{width:36px;height:36px;border-radius:10px;object-fit:cover;box-shadow:0 0 0 1px var(--line),0 6px 18px -6px var(--glow)} .brand svg{width:36px;height:36px;filter:drop-shadow(0 4px 14px var(--glow))} .navlinks{display:flex;gap:2px;flex:1} @@ -257,17 +262,45 @@ body.classic .hero .desc{margin-left:0} body.classic .hero h1{letter-spacing:-1.2px} @media(max-width:820px){.stat{flex:1;min-width:130px}} @media(max-width:720px){.nav{height:auto;flex-wrap:wrap;padding:10px 0;gap:6px} - .navlinks{order:3;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch} + /* Brand and the account controls share the top row; the links get their own + full-width, scrollable strip underneath. */ + .brand{margin-right:0;font-size:17px} + .navend{order:2} + .navlinks{order:3;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch; + flex:0 0 100%;padding-bottom:2px;scrollbar-width:none} + .navlinks::-webkit-scrollbar{display:none} + .navlink{white-space:nowrap;padding:8px 11px;font-size:14px} .hero{padding:64px 0 48px}.hero.image{padding:88px 0 62px} .section{padding:44px 0}.stats{gap:10px}.stat{padding:13px 16px;min-width:0} - .stat b{font-size:22px}.foot{flex-direction:column;text-align:center}} + .stat b{font-size:22px}.foot{flex-direction:column;text-align:center} + /* The profile stacks rather than running off the side. */ + .phero{gap:14px}.phero .big{width:72px;height:72px} + .pid h2{font-size:25px} + .pmeta>div,.pcoords>div{flex:1 1 128px;min-width:0} + .inv{grid-template-columns:repeat(auto-fill,minmax(72px,1fr))} + .ihead{gap:8px} + .mp-bar{gap:6px}.mp-bar select,.mp-bar button{font-size:12.5px;padding:7px 9px}} +@media(max-width:460px){ + /* At phone width the brand name is the first thing worth dropping — the logo + still says which server this is, and the account chip must not be pushed + off the screen for a word. */ + .brand span:last-child{display:none} + .btn{padding:9px 14px;font-size:13.5px} + .wrap{padding:0 14px} + .hero h1{font-size:clamp(28px,9vw,44px)} + .inv{grid-template-columns:repeat(auto-fill,minmax(64px,1fr))} + .whoami span{max-width:88px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
diff --git a/src/main/web/server.ts b/src/main/web/server.ts index 51daaf7..700cb9b 100644 --- a/src/main/web/server.ts +++ b/src/main/web/server.ts @@ -621,12 +621,20 @@ async function handlePublic( // `path` here is already stripped of the query, so the dimension is read // from the raw url. A missing or unparseable one is the overworld. const q = new URL(req.url ?? '/', 'http://localhost').searchParams - const dim = normalizeDimension(q.get('dim') ?? 'overworld') + // A pinned world overrides what the visitor asks for and what the players + // are standing in: the point of pinning is that the site always shows the + // same place, with nobody online and whatever the query says (#137). + const dim = cfg.fixedDim + ? normalizeDimension(cfg.fixedDim) + : normalizeDimension(q.get('dim') ?? 'overworld') const players = redactPlayers(all.filter((p) => p.dim === dim), cfg) return sendJson(res, 200, { bridge: rt ? bridgeFresh(rt.bridge, now) : false, dimension: dim, - dimensions: [...new Set(all.map((p) => p.dim))].sort(), + // One entry when pinned, so the page has nothing to offer a switcher — + // a control that cannot change the answer is not a control. + dimensions: cfg.fixedDim ? [dim] : [...new Set(all.map((p) => p.dim))].sort(), + pinned: !!cfg.fixedDim, players, // Bounds from the ROUNDED positions. Deriving them from the exact ones // would publish a tighter box than the dots inside it, and the corner of diff --git a/src/main/web/site.ts b/src/main/web/site.ts index 5dceb0f..619bdda 100644 --- a/src/main/web/site.ts +++ b/src/main/web/site.ts @@ -146,6 +146,12 @@ export function setSiteConfig(patch: Partial): SiteConfig { if (m.world !== undefined) s.map.world = !!m.world if (m.structures !== undefined) s.map.structures = !!m.structures if (m.loadAhead !== undefined) s.map.loadAhead = !!m.loadAhead + if (m.fixedDim !== undefined) { + // A dimension name becomes a PATH SEGMENT when the tiles are read, so it + // is restricted here rather than trusted at the point of use. + const d = String(m.fixedDim ?? '').trim() + s.map.fixedDim = /^[A-Za-z0-9_.-]{0,64}$/.test(d) && d !== '.' && d !== '..' ? d : '' + } } if (patch.profile) { // Field by field and coerced, for the reason above: "false" is truthy, and @@ -319,6 +325,9 @@ export function publicSite(): PublicSite { mapHeads: s.map.enabled && s.map.heads, mapWorld: s.map.enabled && s.map.world, mapStructures: s.map.enabled && s.map.structures, + // A pinned map is worth showing with nobody online — that is the point of + // pinning it — so the nav link cannot be gated on there being players. + mapPinned: s.map.enabled && !!s.map.fixedDim, mapLoadAhead: s.map.enabled && s.map.loadAhead, // A profile needs a server to read a roster from. Without one the page // would render a name and a head and nothing else. diff --git a/src/renderer/src/components/PlayerAvatar.tsx b/src/renderer/src/components/PlayerAvatar.tsx index e49ae79..c8149a3 100644 --- a/src/renderer/src/components/PlayerAvatar.tsx +++ b/src/renderer/src/components/PlayerAvatar.tsx @@ -1,10 +1,35 @@ -import { useState, useEffect } from 'react' +import { useEffect, useState } from 'react' +import { avatarUrl } from '@shared/profile' /** - * Minecraft head avatar from an online, always-updatable source (minotar.net). - * Works with a UUID (premium/online-mode) or a username (offline). Falls back to - * a coloured initial box if the network image can't load. + * A player's head. + * + * Three things were making these flash empty and re-fetch on every render + * (#137): + * + * - the requested size was baked into the URL, so the 46px list avatar and the + * 72px detail avatar were two different images of the same player, fetched + * separately and cached separately; + * - the uuid was preferred over the name, and on an offline-mode server that + * uuid is the derived one no skin service has ever seen — the same trap as + * the map heads in #116; + * - a failure was remembered only in component state, so every remount asked + * for the same missing image again and showed a gap while it failed. + * + * One size is requested and CSS scales it, the name decides, and the outcome is + * remembered for the session. */ + +/** Requested once at this size; anything larger is upscaled by CSS. */ +const FETCH_PX = 64 + +/** + * Module-level, deliberately: a head that failed once will fail again, and the + * point is not to ask a second time. Keyed by name, which is what the URL is + * keyed by. + */ +const outcome = new Map() + export function PlayerAvatar({ uuid, name, @@ -14,31 +39,53 @@ export function PlayerAvatar({ name: string size?: number }): JSX.Element { - const key = uuid && uuid.replace(/-/g, '').length >= 32 ? uuid : name - const url = `https://minotar.net/helm/${encodeURIComponent(key)}/${size * 2}` - const [err, setErr] = useState(false) - useEffect(() => setErr(false), [key]) + // `uuid` is still accepted so callers do not have to change, and deliberately + // unused: see the note above. + void uuid + const url = avatarUrl(name, FETCH_PX) + const [state, setState] = useState<'ok' | 'fail' | 'loading'>(() => outcome.get(name) ?? 'loading') + + useEffect(() => setState(outcome.get(name) ?? 'loading'), [name]) + + // A placeholder while it comes and if it never does, rather than a hole. The + // initial when there is a name to take one from, a question mark when there + // is not. + const placeholder = ( +
+ {(name[0] || '?').toUpperCase()} +
+ ) + if (!name || state === 'fail') return placeholder - if (err || !key) { - return ( -
- {(name[0] || '?').toUpperCase()} -
- ) - } return ( - {name} setErr(true)} - style={{ imageRendering: 'pixelated' }} - /> + <> + {state === 'loading' && placeholder} + {name} { + outcome.set(name, 'ok') + setState('ok') + }} + onError={() => { + outcome.set(name, 'fail') + setState('fail') + }} + style={{ + imageRendering: 'pixelated', + // Kept in the tree while loading so the browser starts the request, + // but out of the layout so it does not sit next to its own + // placeholder. + display: state === 'ok' ? undefined : 'none' + }} + /> + ) } diff --git a/src/renderer/src/locales/en.ts b/src/renderer/src/locales/en.ts index 42cf247..9e1e973 100644 --- a/src/renderer/src/locales/en.ts +++ b/src/renderer/src/locales/en.ts @@ -741,6 +741,10 @@ export default { mapNames: 'Show names', mapHeads: 'Draw skin heads', mapWorld: 'Show the terrain', + mapFixed: 'World shown', + mapFollowPlayers: 'Follow the players', + mapFixedHint: + 'Pinning a world makes the map worth opening on a quiet server: it renders with nobody online, and visitors get no dimension switcher because you have already chosen.', mapStructures: 'Show villages and dungeons', mapLoadAhead: 'Load ahead of the view', mapLoadHint: diff --git a/src/renderer/src/locales/tr.ts b/src/renderer/src/locales/tr.ts index 1f037cc..a7c9f97 100644 --- a/src/renderer/src/locales/tr.ts +++ b/src/renderer/src/locales/tr.ts @@ -745,6 +745,10 @@ const tr: typeof en = { mapNames: 'İsimleri göster', mapHeads: 'Oyuncu kafalarını çiz', mapWorld: 'Araziyi göster', + mapFixed: 'Gösterilecek dünya', + mapFollowPlayers: 'Oyuncuları takip et', + mapFixedHint: + 'Bir dünyayı sabitlemek, sessiz bir sunucuda haritayı açmaya değer kılar: kimse çevrimiçi değilken de çizilir ve ziyaretçiye boyut seçici gösterilmez, çünkü seçimi siz yaptınız.', mapStructures: 'Köyleri ve zindanları göster', mapLoadAhead: 'Görünümün ötesini önceden yükle', mapLoadHint: diff --git a/src/renderer/src/views/SiteView.tsx b/src/renderer/src/views/SiteView.tsx index 41a5d8a..9686eb8 100644 --- a/src/renderer/src/views/SiteView.tsx +++ b/src/renderer/src/views/SiteView.tsx @@ -36,10 +36,28 @@ export function SiteView(): JSX.Element { setCfg(await window.msms.getSiteConfig()) setWeb(await window.msms.getWebStatus()) } + // Worlds that actually exist on the map server, so the pin is a choice from + // what is there rather than a name typed from memory. + const [mapWorlds, setMapWorlds] = useState(['overworld', 'nether', 'end']) + useEffect(() => { void refresh() }, []) + useEffect(() => { + const sid = cfg?.map.serverId + if (!sid) return + window.msms + .listWorlds(sid) + .then((ws) => { + // The three dimensions of the active world are always offered; a + // separate world folder is offered by its own name. + const extra = ws.map((w) => w.name).filter((n) => n && !['overworld', 'nether', 'end'].includes(n)) + setMapWorlds(['overworld', 'nether', 'end', ...extra]) + }) + .catch(() => setMapWorlds(['overworld', 'nether', 'end'])) + }, [cfg?.map.serverId]) + if (!cfg) return
const siteBase = (): string => @@ -218,6 +236,29 @@ export function SiteView(): JSX.Element { {t('site.mapLoadAhead')}
+ {/* Pinning is what makes an empty server's map worth opening: it + renders with nobody online, and the visitor gets no switcher + because the operator already chose (#137). */} +
+
+
+ {t('site.mapFixed')} +
+ +
+
+

{t('site.mapFixedHint')}

{t('site.mapHint')}

{t('site.mapLoadHint')}

diff --git a/src/shared/livemap.ts b/src/shared/livemap.ts index bb6794b..cb4793e 100644 --- a/src/shared/livemap.ts +++ b/src/shared/livemap.ts @@ -252,6 +252,15 @@ export interface PublicMapConfig { * says where the loot is, and an operator can want one without the other. */ structures: boolean + /** + * Always show this world, whoever is online (#137). + * + * Empty means the default: follow whichever dimension people are standing in, + * and show nothing when nobody is. Set, the site shows one place — which is + * what makes an empty server's map worth opening, and is why the visitor gets + * no dimension switcher with it. + */ + fixedDim: string /** * Read a ring of chunks around the viewport as well, so panning is already * drawn. Costs more parsing; changes nothing about the world. @@ -278,7 +287,8 @@ export const PUBLIC_MAP_DEFAULTS: PublicMapConfig = { // live map was the thing that made the feature look broken (#135). world: true, structures: false, - loadAhead: false + loadAhead: false, + fixedDim: '' } export const MAX_MAP_ROUND = 512 diff --git a/src/shared/mapUi.ts b/src/shared/mapUi.ts index 222c411..c0cfa65 100644 --- a/src/shared/mapUi.ts +++ b/src/shared/mapUi.ts @@ -542,6 +542,9 @@ function mapDraw(){ visitor two switches that do nothing. */ var hb=document.getElementById('mpHeatBtn'); if(hb)hb.style.display=d.heatmap?'':'none'; + /* Pinned: the operator chose the world, so there is nothing to switch to. */ + var ds=document.getElementById('mpDim'); + if(ds)ds.style.display=d.pinned?'none':''; var db=document.getElementById('mpHeadsBtn'); if(db)db.style.display=(d.heads===false)?'none':''; /* The structure controls belong to whoever may change the answer. On the diff --git a/src/shared/web.ts b/src/shared/web.ts index c568e51..77938d3 100644 --- a/src/shared/web.ts +++ b/src/shared/web.ts @@ -316,6 +316,8 @@ export interface PublicSite { mapWorld: boolean /** Whether villages, dungeons and temples are published (#131). */ mapStructures: boolean + /** The map shows one fixed world, so there is no dimension switcher (#137). */ + mapPinned: boolean /** Whether the public map reads ahead of the viewport (#131). */ mapLoadAhead: boolean /** Whether a profile page is worth offering at all. */ From 4b33260750978ca5f4b762b9e9f625b3afacd091 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 29 Jul 2026 15:15:09 +0300 Subject: [PATCH 2/2] Self-review: a pinned custom world would not have been found on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalizeDimension` lower-cases everything, and for a custom world that string becomes a folder name — so a world called `MyWorld` was looked up as `myworld/region`. That happens to work on Windows and does not on Linux, where a Minecraft server is at least as likely to run, and the failure would have been "pinning my world shows nothing" with no way to see why. The three real dimensions still canonicalise; anything else keeps the case it was given. Also dropped `mapPinned` from the public payload. I added it and then never read it — the feed's own `pinned` flag already tells the page what it needs, and an unread field is the pattern I hit in #107. --- src/main/smoke.ts | 11 +++++++++++ src/main/web/site.ts | 3 --- src/shared/livemap.ts | 9 +++++++-- src/shared/web.ts | 2 -- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 67e0a10..07235fb 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -4976,6 +4976,17 @@ export async function runWebSmoke(): Promise { return fail('a pinned feed offered a switcher: ' + JSON.stringify(feed.dimensions)) } + // A custom world keeps its case: the name becomes a folder name, and + // lower-casing it finds `myworld/region` for a folder called + // `MyWorld` — which works on Windows and does not on Linux. + if (normalizeDimension('MyWorld') !== 'MyWorld') { + return fail('a custom world name was case-folded: ' + normalizeDimension('MyWorld')) + } + // ...while the three real dimensions still canonicalise. + if (normalizeDimension('THE_END') !== 'end') return fail('THE_END did not canonicalise') + if (normalizeDimension('minecraft:the_nether') !== 'nether') return fail('the nether id did not canonicalise') + if (normalizeDimension('') !== 'overworld') return fail('an empty dimension is not the overworld') + // Unpinned, it follows the query again. siteMod.setSiteConfig({ map: { ...before, enabled: true, serverId: id, fixedDim: '' } }) const pr3 = await sget('/api/public/map?dim=nether') diff --git a/src/main/web/site.ts b/src/main/web/site.ts index 619bdda..4983461 100644 --- a/src/main/web/site.ts +++ b/src/main/web/site.ts @@ -325,9 +325,6 @@ export function publicSite(): PublicSite { mapHeads: s.map.enabled && s.map.heads, mapWorld: s.map.enabled && s.map.world, mapStructures: s.map.enabled && s.map.structures, - // A pinned map is worth showing with nobody online — that is the point of - // pinning it — so the nav link cannot be gated on there being players. - mapPinned: s.map.enabled && !!s.map.fixedDim, mapLoadAhead: s.map.enabled && s.map.loadAhead, // A profile needs a server to read a roster from. Without one the page // would render a name and a head and nothing else. diff --git a/src/shared/livemap.ts b/src/shared/livemap.ts index cb4793e..b94f5ef 100644 --- a/src/shared/livemap.ts +++ b/src/shared/livemap.ts @@ -31,12 +31,17 @@ export interface LivePlayer { * vanilla ones are canonicalised. */ export function normalizeDimension(dim: unknown): string { - const d = typeof dim === 'string' ? dim.trim().toLowerCase() : '' + const raw = typeof dim === 'string' ? dim.trim() : '' + const d = raw.toLowerCase() if (!d) return 'overworld' if (d === 'normal' || d === 'overworld' || d === 'minecraft:overworld') return 'overworld' if (d === 'nether' || d === 'the_nether' || d === 'minecraft:the_nether') return 'nether' if (d === 'the_end' || d === 'end' || d === 'minecraft:the_end') return 'end' - return d.replace(/^minecraft:/, '') + // A name that is not one of the three is a CUSTOM WORLD, and it becomes a + // folder name when its regions are read. Lower-casing it here found + // `myworld/region` for a folder called `MyWorld` — which happens to work on + // Windows and does not on Linux, where a server is at least as likely to run. + return raw.replace(/^minecraft:/i, '') } /** diff --git a/src/shared/web.ts b/src/shared/web.ts index 77938d3..c568e51 100644 --- a/src/shared/web.ts +++ b/src/shared/web.ts @@ -316,8 +316,6 @@ export interface PublicSite { mapWorld: boolean /** Whether villages, dungeons and temples are published (#131). */ mapStructures: boolean - /** The map shows one fixed world, so there is no dimension switcher (#137). */ - mapPinned: boolean /** Whether the public map reads ahead of the viewport (#131). */ mapLoadAhead: boolean /** Whether a profile page is worth offering at all. */