diff --git a/src/main/smoke.ts b/src/main/smoke.ts index a6a731d..07235fb 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -4946,6 +4946,58 @@ 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)) + } + + // 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') + 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..4983461 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 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..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, '') } /** @@ -252,6 +257,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 +292,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