diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 0f4f676..e0d4834 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -60,9 +60,9 @@ export const chatMessages = pgTable("chat_messages", { // A web-placed marker, owned by a linked account (anonymous visitors keep their marker list in // browser localStorage only — see the plan's "Feature: web markers..." section — so there's -// nothing to persist here for them). Placement is 2D-only (x/z picked on the Leaflet map); `y` is -// resolved once at creation time from `chunkColumns`' heightmap and stored, not recomputed later, -// so a marker doesn't silently move if the terrain above it changes afterward. +// nothing to persist here for them). x/y/z are all set directly by the player in the web UI's +// click-to-place/edit popup (x/z pre-filled from the click, y defaulting to 60), not derived +// server-side, and can be changed later via the same popup (see markers.ts's updateMarker). export const markers = pgTable("markers", { id: uuid("id").defaultRandom().primaryKey(), accountId: uuid("account_id") diff --git a/api/src/index.test.ts b/api/src/index.test.ts index 0029d88..f4d963c 100644 --- a/api/src/index.test.ts +++ b/api/src/index.test.ts @@ -5,7 +5,6 @@ import { meshPointers, tilePointers } from "./db/schema"; import { minio, TILE_BUCKET } from "./minio"; import { createTestServer, deleteTestServer, createTestSession } from "./test-helpers"; import { storeLinkCode } from "./link"; -import { chunkColumns } from "./db/schema"; // Elysia's `.handle()` drives the app in-process against a plain Request/Response, without // binding a real port — avoids racing a real running instance for the port (see MCMapper's @@ -28,6 +27,16 @@ function del(path: string, headers?: Record) { return app.handle(new Request(`http://localhost${path}`, { method: "DELETE", headers })); } +function patch(path: string, body?: unknown, headers?: Record) { + return app.handle( + new Request(`http://localhost${path}`, { + method: "PATCH", + headers: { "content-type": "application/json", ...headers }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }), + ); +} + describe("GET /health", () => { test("reports ok", async () => { const res = await get("/health"); @@ -215,15 +224,6 @@ describe("marker routes", () => { beforeAll(async () => { server = await createTestServer("markers-route"); ({ sessionToken } = await createTestSession("MarkerRouteTester")); - await db.insert(chunkColumns).values({ - serverId: server.id, - dimension: 0, - x: 42, - z: 43, - blockId: 2, - blockMeta: 0, - height: 75, - }); }); afterAll(async () => { @@ -231,19 +231,29 @@ describe("marker routes", () => { }); test("POST /api/markers requires a session", async () => { - const res = await post("/api/markers", { serverId: server.id, dimension: 0, x: 42, z: 43, name: "N", color: "#fff" }); + const res = await post("/api/markers", { + serverId: server.id, + dimension: 0, + x: 42, + y: 60, + z: 43, + name: "N", + color: "#fff", + }); expect(res.status).toBe(401); }); - test("POST /api/markers creates a marker with y resolved from the heightmap, then it's listable and deletable", async () => { + test("POST /api/markers creates a marker with the caller-supplied x/y/z, then it's listable, editable and deletable", async () => { const createRes = await post( "/api/markers", - { serverId: server.id, dimension: 0, x: 42, z: 43, name: "RouteMarker", color: "#ABCDEF" }, + { serverId: server.id, dimension: 0, x: 42, y: 60, z: 43, name: "RouteMarker", color: "#ABCDEF" }, { "X-MCMapper-Session": sessionToken }, ); expect(createRes.status).toBe(200); const created = (await createRes.json()) as any; - expect(created.marker.y).toBe(75); + expect(created.marker.x).toBe(42); + expect(created.marker.y).toBe(60); + expect(created.marker.z).toBe(43); const listRes = await get(`/api/markers/${server.id}`); expect(listRes.status).toBe(401); @@ -256,6 +266,27 @@ describe("marker routes", () => { const list = (await authedListRes.json()) as any[]; expect(list.some((m) => m.id === created.marker.id)).toBe(true); + const patchRes = await patch( + `/api/markers/${created.marker.id}`, + { name: "Renamed", x: 1, y: 2, z: 3, color: "#000000" }, + { "X-MCMapper-Session": sessionToken }, + ); + expect(patchRes.status).toBe(200); + const patched = (await patchRes.json()) as any; + expect(patched.marker.name).toBe("Renamed"); + expect(patched.marker.x).toBe(1); + expect(patched.marker.y).toBe(2); + expect(patched.marker.z).toBe(3); + + const patchNoSessionRes = await patch(`/api/markers/${created.marker.id}`, { + name: "X", + x: 0, + y: 0, + z: 0, + color: "#fff", + }); + expect(patchNoSessionRes.status).toBe(401); + const deleteRes = await del(`/api/markers/${created.marker.id}`, { "X-MCMapper-Session": sessionToken }); expect(deleteRes.status).toBe(200); @@ -268,21 +299,10 @@ describe("marker routes", () => { expect(listAfter.some((m) => m.id === created.marker.id)).toBe(false); }); - test("GET /api/height/:serverId/:dimension/:x/:z needs no session (anonymous markers need a real y too)", async () => { - const res = await get(`/api/height/${server.id}/0/42/43`); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ height: 75 }); - }); - - test("GET /api/height/... 404s for an unmapped column", async () => { - const res = await get(`/api/height/${server.id}/0/999999/999999`); - expect(res.status).toBe(404); - }); - test("POST /api/markers/:id/share reports server_not_connected when the mod isn't online", async () => { const createRes = await post( "/api/markers", - { serverId: server.id, dimension: 0, x: 42, z: 43, name: "ShareMe", color: "#111111" }, + { serverId: server.id, dimension: 0, x: 42, y: 60, z: 43, name: "ShareMe", color: "#111111" }, { "X-MCMapper-Session": sessionToken }, ); const created = (await createRes.json()) as any; diff --git a/api/src/index.ts b/api/src/index.ts index cba9305..65937e2 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -6,7 +6,7 @@ import { wsGateway } from "./ws-gateway"; import { chatGateway } from "./chat-gateway"; import { minio, TILE_BUCKET, ensureTileBucket } from "./minio"; import { redeemLinkCode, getAccountForSession, revokeSession } from "./link"; -import { createMarker, listMarkers, deleteMarker, shareMarkerToChat, resolveHeight } from "./markers"; +import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers"; const MARKER_SHARE_ERROR_STATUS: Record = { not_found: 404, @@ -118,20 +118,10 @@ export const app = new Elysia() if (token) await revokeSession(token); return { ok: true }; }) - // Unauthenticated on purpose: anonymous visitors placing a localStorage-only marker still need - // a real y (see markers.ts's resolveHeight doc comment), and this is read-only, scoped to - // already-public tile data. - .get("/api/height/:serverId/:dimension/:x/:z", async ({ params, set }) => { - const height = await resolveHeight(params.serverId, Number(params.dimension), Number(params.x), Number(params.z)); - if (height === null) { - set.status = 404; - return { error: "column_not_mapped" }; - } - return { height }; - }) // Markers are linked-account-only — anonymous visitors keep their marker list in browser // localStorage instead (see markers.ts's doc comment and the plan's marker feature section), - // so every route here requires a valid session. + // so every route here requires a valid session. x/y/z are all caller-supplied (the web UI's + // click-to-place popup lets the player set them directly), not resolved server-side. .post("/api/markers", async ({ body, headers, set }) => { const token = headers["x-mcmapper-session"]; const account = token ? await getAccountForSession(token) : null; @@ -139,19 +129,28 @@ export const app = new Elysia() set.status = 401; return { error: "unauthenticated" }; } - const { serverId, dimension, x, z, name, color } = body as { + const { serverId, dimension, x, y, z, name, color } = body as { serverId?: string; dimension?: number; x?: number; + y?: number; z?: number; name?: string; color?: string; }; - if (!serverId || dimension === undefined || x === undefined || z === undefined || !name || !color) { + if ( + !serverId || + dimension === undefined || + x === undefined || + y === undefined || + z === undefined || + !name || + !color + ) { set.status = 400; return { error: "missing_fields" }; } - const result = await createMarker({ accountId: account.id, serverId, dimension, x, z, name, color }); + const result = await createMarker({ accountId: account.id, serverId, dimension, x, y, z, name, color }); if (!result.ok) { set.status = 400; return result; @@ -167,6 +166,28 @@ export const app = new Elysia() } return listMarkers(account.id, params.serverId); }) + .patch("/api/markers/:markerId", async ({ params, body, headers, set }) => { + const token = headers["x-mcmapper-session"]; + const account = token ? await getAccountForSession(token) : null; + if (!account) { + set.status = 401; + return { error: "unauthenticated" }; + } + const { name, x, y, z, color } = body as { + name?: string; + x?: number; + y?: number; + z?: number; + color?: string; + }; + if (x === undefined || y === undefined || z === undefined || !name || !color) { + set.status = 400; + return { error: "missing_fields" }; + } + const result = await updateMarker(account.id, params.markerId, { name, x, y, z, color }); + if (!result.ok) set.status = 404; + return result; + }) .delete("/api/markers/:markerId", async ({ params, headers, set }) => { const token = headers["x-mcmapper-session"]; const account = token ? await getAccountForSession(token) : null; diff --git a/api/src/markers.test.ts b/api/src/markers.test.ts index 2815a6e..97c6219 100644 --- a/api/src/markers.test.ts +++ b/api/src/markers.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { eq } from "drizzle-orm"; import { db } from "./db/client"; -import { chunkColumns, servers } from "./db/schema"; -import { createMarker, listMarkers, deleteMarker, shareMarkerToChat, resolveHeight } from "./markers"; +import { servers } from "./db/schema"; +import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers"; import { createTestServer, deleteTestServer, createTestSession, FakeSocket } from "./test-helpers"; import { wsGateway } from "./ws-gateway"; @@ -13,83 +13,33 @@ describe("createMarker", () => { beforeAll(async () => { server = await createTestServer("markers-create"); ({ accountId } = await createTestSession("MarkerPlacer")); - await db.insert(chunkColumns).values({ - serverId: server.id, - dimension: 0, - x: 100, - z: 200, - blockId: 2, - blockMeta: 0, - height: 71, - }); }); afterAll(async () => { await deleteTestServer(server.id); }); - test("resolves y from the chunk store's heightmap at that column", async () => { + test("stores the caller-supplied x/y/z as-is, no server-side height lookup", async () => { const result = await createMarker({ accountId, serverId: server.id, dimension: 0, x: 100, + y: 60, z: 200, name: "Base", - color: "#3391FF", + color: "#3391ff", }); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.marker.y).toBe(71); expect(result.marker.x).toBe(100); + expect(result.marker.y).toBe(60); expect(result.marker.z).toBe(200); expect(result.marker.name).toBe("Base"); }); - - test("fails rather than guessing a y for an unmapped column", async () => { - const result = await createMarker({ - accountId, - serverId: server.id, - dimension: 0, - x: 999999, - z: 999999, - name: "Nowhere", - color: "#FFFFFF", - }); - expect(result).toEqual({ ok: false, error: "column_not_mapped" }); - }); }); -describe("resolveHeight", () => { - let server: { id: string }; - - beforeAll(async () => { - server = await createTestServer("markers-resolve-height"); - await db.insert(chunkColumns).values({ - serverId: server.id, - dimension: 0, - x: 7, - z: 8, - blockId: 2, - blockMeta: 0, - height: 90, - }); - }); - - afterAll(async () => { - await deleteTestServer(server.id); - }); - - test("returns the mapped column's height", async () => { - expect(await resolveHeight(server.id, 0, 7, 8)).toBe(90); - }); - - test("returns null for an unmapped column, rather than guessing", async () => { - expect(await resolveHeight(server.id, 0, 12345, 12345)).toBeNull(); - }); -}); - -describe("listMarkers / deleteMarker ownership", () => { +describe("listMarkers / deleteMarker / updateMarker ownership", () => { let server: { id: string }; let ownerAccountId: string; let otherAccountId: string; @@ -99,23 +49,15 @@ describe("listMarkers / deleteMarker ownership", () => { server = await createTestServer("markers-owner"); ({ accountId: ownerAccountId } = await createTestSession("Owner")); ({ accountId: otherAccountId } = await createTestSession("Other")); - await db.insert(chunkColumns).values({ - serverId: server.id, - dimension: 0, - x: 5, - z: 5, - blockId: 2, - blockMeta: 0, - height: 64, - }); const result = await createMarker({ accountId: ownerAccountId, serverId: server.id, dimension: 0, x: 5, + y: 64, z: 5, name: "Mine", - color: "#00FF00", + color: "#00ff00", }); if (!result.ok) throw new Error("setup failed"); ownedMarkerId = result.marker.id; @@ -134,6 +76,40 @@ describe("listMarkers / deleteMarker ownership", () => { expect(otherMarkers).toHaveLength(0); }); + test("updateMarker refuses to update another account's marker", async () => { + const result = await updateMarker(otherAccountId, ownedMarkerId, { + name: "Hijacked", + x: 0, + y: 0, + z: 0, + color: "#000000", + }); + expect(result).toEqual({ ok: false, error: "not_found" }); + + const stillMine = await listMarkers(ownerAccountId, server.id); + expect(stillMine[0]!.name).toBe("Mine"); + }); + + test("updateMarker succeeds for the owning account and persists the change", async () => { + const result = await updateMarker(ownerAccountId, ownedMarkerId, { + name: "Renamed", + x: 10, + y: 70, + z: 20, + color: "#123456", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.marker.name).toBe("Renamed"); + expect(result.marker.x).toBe(10); + expect(result.marker.y).toBe(70); + expect(result.marker.z).toBe(20); + expect(result.marker.color).toBe("#123456"); + + const listed = await listMarkers(ownerAccountId, server.id); + expect(listed[0]!.name).toBe("Renamed"); + }); + test("deleteMarker refuses to delete another account's marker", async () => { const result = await deleteMarker(otherAccountId, ownedMarkerId); expect(result).toEqual({ ok: false, error: "not_found" }); @@ -159,20 +135,12 @@ describe("shareMarkerToChat", () => { beforeAll(async () => { server = await createTestServer("markers-share"); ({ accountId } = await createTestSession("Sharer")); - await db.insert(chunkColumns).values({ - serverId: server.id, - dimension: 0, - x: 10, - z: 20, - blockId: 2, - blockMeta: 0, - height: 80, - }); const result = await createMarker({ accountId, serverId: server.id, dimension: 0, x: 10, + y: 80, z: 20, name: "Shareable", color: "#B311CF", diff --git a/api/src/markers.ts b/api/src/markers.ts index 543b5a5..f6a6c78 100644 --- a/api/src/markers.ts +++ b/api/src/markers.ts @@ -1,6 +1,6 @@ import { and, eq } from "drizzle-orm"; import { db } from "./db/client"; -import { chunkColumns, markers, servers } from "./db/schema"; +import { markers, servers } from "./db/schema"; import { getModSocket } from "./ws-gateway"; interface NewMarkerInput { @@ -8,6 +8,7 @@ interface NewMarkerInput { serverId: string; dimension: number; x: number; + y: number; z: number; name: string; color: string; @@ -16,39 +17,11 @@ interface NewMarkerInput { export type CreateMarkerResult = { ok: true; marker: typeof markers.$inferSelect } | { ok: false; error: string }; /** - * Topmost non-air block's height at a column, straight from the chunk store's heightmap — `null` - * if that column hasn't been mapped by the mod yet. Exposed on its own (see index.ts's - * unauthenticated `/api/height` route) because anonymous visitors also need a real `y` for their - * localStorage-only marker list (see the plan's marker feature section: "either way the record - * includes x, y, z"), even though they never reach `createMarker` below. - */ -export async function resolveHeight(serverId: string, dimension: number, x: number, z: number): Promise { - const [column] = await db - .select({ height: chunkColumns.height }) - .from(chunkColumns) - .where( - and( - eq(chunkColumns.serverId, serverId), - eq(chunkColumns.dimension, dimension), - eq(chunkColumns.x, x), - eq(chunkColumns.z, z), - ), - ) - .limit(1); - return column ? column.height : null; -} - -/** - * Placement is 2D-only (see the plan's marker feature section) — the caller only supplies x/z, - * and `y` is auto-derived here from the chunk store's heightmap rather than picked by the player, - * so the marker is a genuine 3D point usable by both the 3D viewer and the in-game waypoint - * formats (both require a real y). Fails rather than guessing a y if that column hasn't been - * mapped by the mod yet. + * Placement is fully manual (the web UI's click-to-place popup lets the player set x/y/z + * directly — x/z pre-filled from the click, y defaulting to 60 but editable — rather than y + * being auto-derived from the chunk heightmap), so there's nothing to resolve server-side here. */ export async function createMarker(input: NewMarkerInput): Promise { - const height = await resolveHeight(input.serverId, input.dimension, input.x, input.z); - if (height === null) return { ok: false, error: "column_not_mapped" }; - const [marker] = await db .insert(markers) .values({ @@ -56,7 +29,7 @@ export async function createMarker(input: NewMarkerInput): Promise { + const [marker] = await db + .update(markers) + .set({ name: update.name, x: update.x, y: update.y, z: update.z, color: update.color }) + .where(and(eq(markers.id, markerId), eq(markers.accountId, accountId))) + .returning(); + if (!marker) return { ok: false, error: "not_found" }; + return { ok: true, marker }; +} + export type ShareMarkerResult = { ok: true } | { ok: false; error: string }; /** diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 530666a..33d2140 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -14,6 +14,7 @@ const app = new Elysia() .get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css"))) .get("/js/map.js", () => Bun.file(join(import.meta.dir, "public/js/map.js"))) .get("/js/coords.js", () => Bun.file(join(import.meta.dir, "public/js/coords.js"))) + .get("/js/colors.js", () => Bun.file(join(import.meta.dir, "public/js/colors.js"))) .get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js"))) .get("/js/mesh-format.js", () => Bun.file(join(import.meta.dir, "public/js/mesh-format.js"))) .listen(Number(process.env.PORT ?? 3001)); diff --git a/frontend/src/public/js/colors.js b/frontend/src/public/js/colors.js new file mode 100644 index 0000000..4ae19ea --- /dev/null +++ b/frontend/src/public/js/colors.js @@ -0,0 +1,6 @@ +// `rng` is injectable (defaults to Math.random) so this is deterministically testable without +// stubbing the global — see colors.test.ts. +export function randomHexColor(rng = Math.random) { + const value = Math.floor(rng() * 0x1000000) & 0xffffff; + return "#" + value.toString(16).padStart(6, "0"); +} diff --git a/frontend/src/public/js/colors.test.ts b/frontend/src/public/js/colors.test.ts new file mode 100644 index 0000000..c4decd2 --- /dev/null +++ b/frontend/src/public/js/colors.test.ts @@ -0,0 +1,11 @@ +import { test, expect } from "bun:test"; +import { randomHexColor } from "./colors.js"; + +test("produces a well-formed 6-digit hex color string", () => { + expect(randomHexColor()).toMatch(/^#[0-9a-f]{6}$/); +}); + +test("is deterministic for an injected rng, spanning the full range", () => { + expect(randomHexColor(() => 0)).toBe("#000000"); + expect(randomHexColor(() => 0.5)).toBe("#800000"); +}); diff --git a/frontend/src/public/js/map.js b/frontend/src/public/js/map.js index 551af5d..d2ac5ea 100644 --- a/frontend/src/public/js/map.js +++ b/frontend/src/public/js/map.js @@ -8,6 +8,9 @@ // Marker world<->latlng conversion lives in coords.js (see its doc comment for the derivation) // — pulled out into its own module so it's unit-testable without a browser (see coords.test.ts). import { worldToLatLng, latLngToWorld } from "./coords.js"; +import { randomHexColor } from "./colors.js"; + +const DEFAULT_MARKER_HEIGHT = 60; // Session handling: the session token from /api/link/redeem is kept in localStorage and sent // back via the X-MCMapper-Session header / a WS message field, not an httpOnly cookie — see @@ -33,9 +36,12 @@ function mapmapper() { markers: [], markerLayer: null, placingMarker: false, - pendingMarker: null, // {x, z} — set by a map click while placingMarker is true - markerNameInput: "", - markerColorInput: "#3391ff", + // Backing state for the click-to-place/edit popup form (see openMarkerForm). `mode` is null + // when no form is open, "create" or "edit" otherwise — the same form/popup is reused for + // both, per the user's ask that markers be editable too, not just placeable. + markerForm: { mode: null, id: null, x: 0, y: DEFAULT_MARKER_HEIGHT, z: 0, name: "", color: "#3391ff" }, + markerFormEl: null, // captured once in init() — see openMarkerForm's doc comment for why + markerPopup: null, markerStatus: "", async init() { @@ -48,6 +54,13 @@ function mapmapper() { this.markerLayer = L.layerGroup().addTo(this.leaflet); this.leaflet.on("click", (e) => this.onMapClick(e)); + // Captured once, up front: Leaflet physically moves this node in and out of its popup + // pane's DOM on every open/close (see openMarkerForm), which would break Alpine's $refs + // treewalk (it only finds elements still attached under the root x-data element) if we + // re-queried $refs on every open. The Node reference itself — and Alpine's bindings on + // it — survive being detached/reattached, so grabbing it once here is safe. + this.markerFormEl = this.$refs.markerFormEl; + if (this.server) { L.tileLayer(`/api/tiles/${this.server.id}/0/{z}/{x}/{y}.png`, { tileSize: 256, @@ -65,25 +78,104 @@ function mapmapper() { } }, - // Marker placement is 2D-only (see the plan's marker feature section) — a click on this - // Leaflet map while `placingMarker` is on just records the clicked world x/z; `y` is always - // resolved server-side from the chunk heightmap (see confirmMarker), never picked here. + // Marker placement is 2D-click-to-open (see the plan's marker feature section) — a click on + // this Leaflet map while `placingMarker` is on opens the popup form pre-filled with the + // clicked world x/z, a default height of DEFAULT_MARKER_HEIGHT, and a random color; the + // player can freely edit any of x/y/z before saving (no server-side height lookup anymore). onMapClick(e) { if (!this.placingMarker) return; this.placingMarker = false; - this.pendingMarker = latLngToWorld(e.latlng); - this.markerNameInput = ""; - this.markerStatus = ""; + const { x, z } = latLngToWorld(e.latlng); + this.openMarkerForm({ mode: "create", id: null, x, y: DEFAULT_MARKER_HEIGHT, z, name: "", color: randomHexColor() }, e.latlng); }, togglePlacingMarker() { this.placingMarker = !this.placingMarker; - if (!this.placingMarker) this.pendingMarker = null; + if (!this.placingMarker) this.cancelMarkerForm(); }, - cancelMarker() { - this.pendingMarker = null; + editMarker(marker) { + this.openMarkerForm( + { mode: "edit", id: marker.id, x: marker.x, y: marker.y, z: marker.z, name: marker.name, color: marker.color }, + worldToLatLng(marker.x, marker.z), + ); + }, + + /** + * Opens a real Leaflet popup (per the user's ask — "open a pop-up") anchored at `latlng`, + * reusing the single `markerFormEl` node (see init()'s doc comment on why it's captured + * once) for both placing a new marker and editing an existing one — `markerForm.mode` + * decides which save behavior `saveMarkerForm` takes. + */ + openMarkerForm(form, latlng) { + this.markerForm = form; this.markerStatus = ""; + this.markerPopup = L.popup({ minWidth: 220 }) + .setLatLng(latlng) + .setContent(this.markerFormEl) + .openOn(this.leaflet); + }, + + cancelMarkerForm() { + if (this.markerPopup) this.leaflet.closePopup(this.markerPopup); + this.markerPopup = null; + this.markerForm = { mode: null, id: null, x: 0, y: DEFAULT_MARKER_HEIGHT, z: 0, name: "", color: "#3391ff" }; + this.markerStatus = ""; + }, + + /** + * Linked accounts persist markers server-side (Postgres, via markers.ts) so the same list + * follows them across devices after re-linking there; anonymous visitors keep markers in + * browser localStorage only (see loadLocalMarkers/saveLocalMarkers). Handles both create and + * edit — see markerForm.mode. + */ + async saveMarkerForm() { + const name = this.markerForm.name.trim(); + if (!name) { + this.markerStatus = "name is required"; + return; + } + const { mode, id, x, y, z, color } = this.markerForm; + + if (mode === "create") { + if (this.account && this.sessionToken) { + const res = await fetch("/api/markers", { + method: "POST", + headers: { "content-type": "application/json", "X-MCMapper-Session": this.sessionToken }, + body: JSON.stringify({ serverId: this.server.id, dimension: 0, x, y, z, name, color }), + }); + const data = await res.json(); + if (!data.ok) { + this.markerStatus = data.error; + return; + } + this.markers.push(data.marker); + } else { + const marker = { id: crypto.randomUUID(), serverId: this.server.id, dimension: 0, x, y, z, name, color }; + this.markers.push(marker); + this.saveLocalMarkers(this.markers); + } + } else if (mode === "edit") { + if (this.account && this.sessionToken) { + const res = await fetch(`/api/markers/${id}`, { + method: "PATCH", + headers: { "content-type": "application/json", "X-MCMapper-Session": this.sessionToken }, + body: JSON.stringify({ x, y, z, name, color }), + }); + const data = await res.json(); + if (!data.ok) { + this.markerStatus = data.error; + return; + } + this.markers = this.markers.map((m) => (m.id === id ? data.marker : m)); + } else { + this.markers = this.markers.map((m) => (m.id === id ? { ...m, x, y, z, name, color } : m)); + this.saveLocalMarkers(this.markers); + } + } + + this.cancelMarkerForm(); + this.renderMarkerLayer(); }, localMarkerStorageKey() { @@ -128,55 +220,6 @@ function mapmapper() { } }, - /** - * Linked accounts persist markers server-side (Postgres, via markers.ts — `y` auto-derived - * there from the chunk store's heightmap). Anonymous visitors keep markers in localStorage - * only, but still need a real `y`, so they hit the unauthenticated /api/height lookup - * instead — see markers.ts's resolveHeight doc comment for why that route exists. - */ - async confirmMarker() { - const name = this.markerNameInput.trim(); - if (!name || !this.pendingMarker) return; - const { x, z } = this.pendingMarker; - - if (this.account && this.sessionToken) { - const res = await fetch("/api/markers", { - method: "POST", - headers: { "content-type": "application/json", "X-MCMapper-Session": this.sessionToken }, - body: JSON.stringify({ serverId: this.server.id, dimension: 0, x, z, name, color: this.markerColorInput }), - }); - const data = await res.json(); - if (!data.ok) { - this.markerStatus = data.error; - return; - } - this.markers.push(data.marker); - } else { - const res = await fetch(`/api/height/${this.server.id}/0/${x}/${z}`); - if (!res.ok) { - this.markerStatus = "column_not_mapped"; - return; - } - const { height } = await res.json(); - const marker = { - id: crypto.randomUUID(), - serverId: this.server.id, - dimension: 0, - x, - y: height, - z, - name, - color: this.markerColorInput, - }; - this.markers.push(marker); - this.saveLocalMarkers(this.markers); - } - - this.pendingMarker = null; - this.markerStatus = ""; - this.renderMarkerLayer(); - }, - async deleteMarker(marker) { if (this.account && this.sessionToken) { await fetch(`/api/markers/${marker.id}`, { diff --git a/frontend/src/views/index.pug b/frontend/src/views/index.pug index 31d1777..5e614b8 100644 --- a/frontend/src/views/index.pug +++ b/frontend/src/views/index.pug @@ -22,6 +22,26 @@ html(lang="en") span.text-sm.text-red-400(x-show="!loading && !server") No server registered yet — see backend README (bun run seed). div.flex-1.flex.overflow-hidden div#map.flex-1 + div(style="display:none") + div(x-ref="markerFormEl") + div.bg-neutral-800.text-neutral-100.rounded.p-2.space-y-2(style="min-width: 220px;") + p.text-xs.text-neutral-400(x-text="markerForm.mode === 'edit' ? 'Edit marker' : 'New marker'") + div.grid.grid-cols-3.gap-1 + input.bg-neutral-900.text-xs.px-1.py-1.rounded.border.border-neutral-700( + type="number" x-model.number="markerForm.x" placeholder="x") + input.bg-neutral-900.text-xs.px-1.py-1.rounded.border.border-neutral-700( + type="number" x-model.number="markerForm.y" placeholder="y") + input.bg-neutral-900.text-xs.px-1.py-1.rounded.border.border-neutral-700( + type="number" x-model.number="markerForm.z" placeholder="z") + div.flex.gap-1 + input.flex-1.bg-neutral-900.text-sm.px-2.py-1.rounded.border.border-neutral-700( + type="text" placeholder="marker name" x-model="markerForm.name" + x-on:keydown.enter="saveMarkerForm") + input.w-10(type="color" x-model="markerForm.color") + div.flex.gap-1 + button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="saveMarkerForm") Save + button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelMarkerForm") Cancel + p.text-xs.text-amber-400(x-show="markerStatus" x-text="markerStatus") aside.w-80.flex.flex-col.border-l.border-neutral-700.bg-neutral-800(x-show="server") div.border-b.border-neutral-700.p-2.space-y-2(style="max-height: 40%; overflow-y: auto;") div.flex.items-center.justify-between @@ -31,28 +51,15 @@ html(lang="en") x-on:click="togglePlacingMarker" x-text="placingMarker ? 'click the map…' : '+ place marker'") - template(x-if="pendingMarker") - div.space-y-1.bg-neutral-900.rounded.p-2 - p.text-xs.text-neutral-400(x-text="'at ' + pendingMarker.x + ', ' + pendingMarker.z") - div.flex.gap-1 - input.flex-1.bg-neutral-800.text-sm.px-2.py-1.rounded.border.border-neutral-700( - type="text" placeholder="marker name" x-model="markerNameInput" - x-on:keydown.enter="confirmMarker") - input.w-10(type="color" x-model="markerColorInput") - div.flex.gap-1 - button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="confirmMarker") Save - button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelMarker") Cancel - template(x-for="marker in markers" x-bind:key="marker.id") div.flex.items-center.gap-2.text-sm span.inline-block.w-3.h-3.rounded-full.flex-shrink-0(x-bind:style="'background:' + marker.color") span.flex-1.truncate(x-text="marker.name") span.text-xs.text-neutral-500(x-text="marker.x + ', ' + marker.y + ', ' + marker.z") + button.text-xs.underline(x-on:click="editMarker(marker)") edit button.text-xs.underline(x-show="account" x-on:click="shareMarker(marker)") share button.text-xs.text-red-400(x-on:click="deleteMarker(marker)") × - p.text-xs.text-amber-400(x-show="markerStatus" x-text="markerStatus") - div.flex-1.overflow-y-auto.p-2.space-y-1(x-ref="chatLog") template(x-for="msg in chatMessages" x-bind:key="msg.id") p.text-sm.break-words