diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index cb45e5055..270f4d6f0 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -14,7 +14,7 @@ The `web/` app is the Patternflow site at [patternflow.work](https://patternflow | `/guide` · `/guide/build` · `/guide/play` · `/guide/make` · `/guide/audio` (each + `/ko`) | The interactive guide: a hub and four guides on one continuous 3D stage (`components/guide/`), with the firmware's input logic and screens ported to `lib/guide/` and the real console and audio editor in windows (`public/guide/console-demo/`, `audio-editor/`, generated by `firmware/toolchain/console_demo.py` and `editor_demo.py`). Words in `components/guide/copy.ts` and `copy/`; old `/guide#flash`-style anchors land on `/guide/play` (`legacy.ts`) | | `/inside/[build]` | One community build's page | | `/pattern-lab` | The pattern workspace — see below | -| `/community/**` | The community: feed, pattern pages (`/p/[id]`), profiles (`/u/[username]`), decks (`/d/[id]`, `/decks`), the atlas, notifications, featured, reports. The workshop came down on 26 October 2026, and its old addresses redirect to `/community` (`next.config.ts`). Renders a pointer to the community host unless `COMMUNITY_ENABLED=1` | +| `/community/**` | The community: feed, pattern pages (`/p/[id]`), profiles (`/u/[username]`), decks (`/d/[id]`, `/decks`), the atlas, notifications, featured, reports. The workshop came down on 26 October 2026, and its old addresses redirect to `/community` (`next.config.ts`). The atlas became a read-only archive the same day. The footer's Archive link is the way in, and `/api/community/atlas` answers a write with a 405. Renders a pointer to the community host unless `COMMUNITY_ENABLED=1` | | `/editions` | The edition shelf: every firmware you can put on a panel, official and community, with one-click install. `/variants` (the URL until 2026-09, baked into shipped console pages) redirects here permanently | | `/features` | The catalogue: every feature on its own — what it needs, which firmware carries it, and a reel of it running (Instagram embeds by permalink, lazy). The other axis of the shelf; hand-curated in `features-data.ts`. A feature that is not on the shelf can carry a frozen try-out image (`tryOut`), installed the way the shelf installs | | `/update` | The device's firmware-update handoff: the browser downloads an image and POSTs it to the panel over the LAN, because the panel cannot fetch over TLS | diff --git a/web/src/app/api/community/atlas/route.test.ts b/web/src/app/api/community/atlas/route.test.ts new file mode 100644 index 000000000..38ea6d678 --- /dev/null +++ b/web/src/app/api/community/atlas/route.test.ts @@ -0,0 +1,108 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// The atlas has been an archive since 26 October 2026. Its pins stay where +// their authors left them, so the route still reads them and refuses the +// writes that used to place, move and remove one, even from a moderator who +// could do all three until then. Driven through the real route with a real +// session and the Lab's Origin, on a database of its own. + +const LAB_ORIGIN = "https://lab.patternflow.test"; +const ENV_KEYS = [ + "COMMUNITY_DB_PATH", + "COMMUNITY_ENABLED", + "BETTER_AUTH_SECRET", + "COMMUNITY_ADMIN_USERNAMES", + "COMMUNITY_ALLOWED_ORIGINS", +] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; +let cookie = ""; +let route: typeof import("./route"); +let pinRows: () => Promise<{ patternId: string; x: number; y: number; entryId: string | null }[]>; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-atlas-archive-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "atlas-archive-test-secret-not-a-real-one"; + process.env.COMMUNITY_ADMIN_USERNAMES = "ada"; + process.env.COMMUNITY_ALLOWED_ORIGINS = LAB_ORIGIN; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + route = await import("./route"); + + const response = await getAuth().api.signUpEmail({ + body: { email: "ada@patternflow.local", password: "atlas-archive-test-password", name: "ada", username: "ada" }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error("no session cookie for ada"); + cookie = setCookie.split(";")[0]; + + const db = getDb(); + const [ada] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, "ada")) + .limit(1); + const now = new Date(); + for (const id of ["p1", "p2"]) { + await db.insert(schema.patterns).values({ + id, userId: ada!.id, title: `ada's ${id}`, code: "// js", visibility: "public", createdAt: now, updatedAt: now, + }); + } + await db.insert(schema.atlasPins).values({ patternId: "p1", x: 40, y: 60, entryId: null, kind: "pin", updatedAt: now }); + + pinRows = () => + db + .select({ + patternId: schema.atlasPins.patternId, + x: schema.atlasPins.x, + y: schema.atlasPins.y, + entryId: schema.atlasPins.entryId, + }) + .from(schema.atlasPins); +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +const call = (method: "GET" | "POST" | "DELETE", body?: Record) => + new Request("http://localhost:3000/api/community/atlas", { + method, + headers: { "Content-Type": "application/json", Origin: LAB_ORIGIN, cookie }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + +it.each([ + ["moving a pin", "POST", { patternId: "p1", x: 10, y: 10, entryId: null, kind: "pin" }], + ["placing a new one", "POST", { patternId: "p2", x: 70, y: 20, entryId: null, kind: "pin" }], + ["removing one", "DELETE", { patternId: "p1" }], +] as const)("refuses %s with a 405 the Lab can read", async (_, method, body) => { + const response = await route[method](call(method, body)); + expect(response.status).toBe(405); + expect(response.headers.get("Allow")).toBe("GET, OPTIONS"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe(LAB_ORIGIN); + expect(await response.json()).toEqual({ error: "The atlas is an archive now, and its pins stay where they are." }); + expect(await pinRows()).toEqual([{ patternId: "p1", x: 40, y: 60, entryId: null }]); +}); + +it("still reads the pins", async () => { + const response = await route.GET(call("GET")); + expect(response.status).toBe(200); + const { pins } = (await response.json()) as { pins: { patternId: string; x: number; y: number }[] }; + expect(pins.map(({ patternId, x, y }) => ({ patternId, x, y }))).toEqual([{ patternId: "p1", x: 40, y: 60 }]); +}); diff --git a/web/src/app/api/community/atlas/route.ts b/web/src/app/api/community/atlas/route.ts index f13c690a3..afaf20ae1 100644 --- a/web/src/app/api/community/atlas/route.ts +++ b/web/src/app/api/community/atlas/route.ts @@ -1,25 +1,14 @@ -import { eq } from "drizzle-orm"; -import { ENTRY_BY_ID } from "@/lib/atlas/data"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; +import { communityEnabled } from "@/lib/community/server/db"; import { listAtlasPins } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { atlasPins, patterns } from "@/lib/community/server/schema"; // GET /api/community/atlas — every pattern pinned on the atlas. -// POST — place or move a pin: { patternId, x, y, entryId?, kind? } in the -// atlas's 0..100 data space. Only the pattern's author (or a -// moderator) may. kind "pin" (default) is an exemplar tile on the -// shared map; kind "research" files the pattern against an entry as a -// field note — failures stay on record where they happened, without -// occupying the map. -// DELETE — take a pin off the map: { patternId }. Same permission. -// -// A pin says "this work lives at this spot of pattern space". One per -// pattern; placing again just moves it (and can change its kind). +// POST, DELETE — refused with a 405. The atlas is an archive since 26 October +// 2026: the pins stay where their authors left them. Until then POST +// placed or moved a pin ({ patternId, x, y, entryId?, kind? }) and +// DELETE took one off the map ({ patternId }). export async function GET(request: Request) { const blocked = originBlocked(request); @@ -30,13 +19,13 @@ export async function GET(request: Request) { export async function POST(request: Request) { const blocked = originBlocked(request); if (blocked) return blocked; - return withCors(request, await handleWrite(request, "place")); + return withCors(request, refuseWrite()); } export async function DELETE(request: Request) { const blocked = originBlocked(request); if (blocked) return blocked; - return withCors(request, await handleWrite(request, "remove")); + return withCors(request, refuseWrite()); } export const OPTIONS = preflight; @@ -64,103 +53,12 @@ async function handleGet(request: Request) { }); } -function cleanCoord(value: unknown): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value)) return undefined; - const rounded = Math.round(value); - if (rounded < 0 || rounded > 100) return undefined; - return rounded; -} - -async function handleWrite(request: Request, mode: "place" | "remove") { +function refuseWrite() { if (!communityEnabled()) { return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in to place work on the atlas." }, { status: 401 }); - } - - if (!rateLimit(`atlas:${session.user.id}`, 60, 60_000)) { - return Response.json({ error: "Too many moves — let the pin settle a moment." }, { status: 429 }); - } - - const payload = await readJsonObject(request); - if (payload instanceof Response) return payload; - - const patternId = typeof payload.patternId === "string" ? payload.patternId : ""; - if (!patternId) { - return Response.json({ error: "Which pattern?" }, { status: 400 }); - } - - const [pattern] = await getDb() - .select({ id: patterns.id, userId: patterns.userId, visibility: patterns.visibility }) - .from(patterns) - .where(eq(patterns.id, patternId)) - .limit(1); - if (!pattern) { - return Response.json({ error: "No such pattern." }, { status: 404 }); - } - - // The author decides where their work sits; moderators can tidy the map. - if (pattern.userId !== session.user.id && !isAdminSession(session)) { - return Response.json({ error: "Only the pattern's author can place it." }, { status: 403 }); - } - - if (mode === "remove") { - await getDb().delete(atlasPins).where(eq(atlasPins.patternId, patternId)); - return Response.json({ ok: true }); - } - - const kind = payload.kind === undefined ? "pin" : payload.kind; - if (kind !== "pin" && kind !== "research") { - return Response.json({ error: "Unknown pin kind." }, { status: 400 }); - } - - // A map pin is a tile everyone sees, so it must be public. A research row is - // a field note — private failures are allowed, because the read path already - // shows those only to their author and moderators. - if (kind === "pin" && pattern.visibility !== "public") { - return Response.json( - { error: "Only public patterns can sit on the shared map." }, - { status: 400 }, - ); - } - - const x = cleanCoord(payload.x); - const y = cleanCoord(payload.y); - if (x === undefined || y === undefined) { - return Response.json({ error: "Bad position." }, { status: 400 }); - } - - // Lineage is a deliberate, human act: the author links a pattern to the - // prompt point it came from (or to none). Nothing is inferred from code. - let entryId: string | null; - if (payload.entryId === undefined || payload.entryId === null || payload.entryId === "") { - entryId = null; - } else if (typeof payload.entryId === "string" && ENTRY_BY_ID.has(payload.entryId)) { - entryId = payload.entryId; - } else { - return Response.json({ error: "No such point on the map." }, { status: 400 }); - } - - // A research row exists to remember where an attempt happened — unmoored - // from any entry it would be invisible everywhere, so refuse the no-op. - if (kind === "research" && entryId === null) { - return Response.json( - { error: "Research notes attach to a point — drop it closer to one." }, - { status: 400 }, - ); - } - - const now = new Date(); - await getDb() - .insert(atlasPins) - .values({ patternId, x, y, entryId, kind, updatedAt: now }) - .onConflictDoUpdate({ - target: atlasPins.patternId, - set: { x, y, entryId, kind, updatedAt: now }, - }); - - return Response.json({ ok: true }); + return Response.json( + { error: "The atlas is an archive now, and its pins stay where they are." }, + { status: 405, headers: { Allow: "GET, OPTIONS" } }, + ); } diff --git a/web/src/app/api/community/nullBody.test.ts b/web/src/app/api/community/nullBody.test.ts index 23db9be67..b6dc1c657 100644 --- a/web/src/app/api/community/nullBody.test.ts +++ b/web/src/app/api/community/nullBody.test.ts @@ -28,8 +28,6 @@ type RouteModule = Record; // [method, path, module loader, path params] const ROUTES: [string, string, () => Promise, Record][] = [ - ["POST", "atlas", () => import("./atlas/route"), {}], - ["DELETE", "atlas", () => import("./atlas/route"), {}], ["POST", "builds", () => import("./builds/route"), {}], ["PATCH", "comments/c1?on=pattern", () => import("./comments/[id]/route"), { id: "c1" }], ["POST", "decks", () => import("./decks/route"), {}], diff --git a/web/src/app/community/atlas/page.tsx b/web/src/app/community/atlas/page.tsx index b661edaa8..c24ca2f3d 100644 --- a/web/src/app/community/atlas/page.tsx +++ b/web/src/app/community/atlas/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { communityEnabled } from "@/lib/community/server/db"; -import { listAtlasPins, listPatternsByUser } from "@/lib/community/server/queries"; +import { listAtlasPins } from "@/lib/community/server/queries"; import AtlasClient from "@/components/community/AtlasClient"; // The atlas: a map of pattern-technique space. It charts where PATTERNS could @@ -14,44 +14,27 @@ import AtlasClient from "@/components/community/AtlasClient"; // Technique entries live in lib/atlas/data.ts and are edited like the seed // script. Pins live in the atlas_pins table. // -// Connecting work to the map happens HERE, by hand: publishing stays its own -// flow, and the atlas offers "add my pattern" — pick one of your published -// patterns, click where it belongs, and (optionally) tie it to the prompt -// point it came from. Nothing is inferred, nothing rides the publish flow. +// An archive since 26 October 2026. The community's menu and phone tabs no +// longer name it; the footer's Archive link does. Until then an author could +// add a published pattern here, move it and tie it to the prompt point it +// came from. The pins stay as they were left, and the route refuses writes. export const dynamic = "force-dynamic"; export const metadata: Metadata = { - title: "Atlas / Patternflow Community", + title: "Atlas archive / Patternflow Community", description: - "A map of pattern-technique space — verified continents, open frontier, published patterns pinned in place, and ready-made generation prompts for exploring each point.", + "A map of pattern-technique space, kept as an archive — verified continents, open frontier, published patterns pinned in place, and ready-made generation prompts for exploring each point.", }; export default async function AtlasPage() { if (!communityEnabled()) return null; // layout already redirected or rendered the notice + // The viewer still matters to a read: a private research note shows only to + // its author and to moderators. const session = await getAuth().api.getSession({ headers: await headers() }); const viewerId = session?.user.id ?? null; - const isAdmin = isAdminSession(session); - - const pins = await listAtlasPins(viewerId ? { id: viewerId, isAdmin } : null); - - // The picker: the viewer's own patterns that are not on the map yet, newest - // first (listPatternsByUser already orders that way) and carrying their code - // — you pick by looking at the pattern, not by reading a title. Private ones - // ride along flagged: they can only be filed as research notes, never as - // map tiles, and the client routes them there. - const pinned = new Set(pins.map((pin) => pin.patternId)); - const myPatterns = viewerId - ? (await listPatternsByUser(viewerId, viewerId)) - .filter((pattern) => !pinned.has(pattern.id)) - .map((pattern) => ({ - id: pattern.id, - title: pattern.title, - code: pattern.code, - isPublic: pattern.visibility === "public", - })) - : []; + const pins = await listAtlasPins(viewerId ? { id: viewerId, isAdmin: isAdminSession(session) } : null); return ( ); } diff --git a/web/src/app/community/layout.tsx b/web/src/app/community/layout.tsx index 978a12ce1..65d80ace4 100644 --- a/web/src/app/community/layout.tsx +++ b/web/src/app/community/layout.tsx @@ -129,6 +129,10 @@ export default async function CommunityLayout({ children }: { children: React.Re Terms & Privacy · Report something + {/* The atlas, kept read-only since 26 October 2026. Nothing else + links to it. */} + · + Archive {/* The deck is assembled while browsing, so it stays on screen while diff --git a/web/src/components/community/Atlas.module.css b/web/src/components/community/Atlas.module.css index 8759c36f7..7a1182b5c 100644 --- a/web/src/components/community/Atlas.module.css +++ b/web/src/components/community/Atlas.module.css @@ -83,7 +83,7 @@ cursor: default; } -/* ── filter chips and the placing banner, over the map ── */ +/* ── filter chips, over the map ── */ .mapTop { position: absolute; @@ -164,134 +164,6 @@ } } -.mapWrap[data-placing="true"] .map { - cursor: crosshair; -} - -/* ── "Add my pattern" picker ── */ - -.pickerWrap { - position: relative; - display: inline-flex; -} - -.picker { - position: absolute; - right: 0; - top: calc(100% + 6px); - z-index: 6; - width: 396px; - max-height: 420px; - overflow-y: auto; - background: var(--pfc-panel); - border: 1px solid var(--pfc-edge); - padding: 4px; -} - -/* You recognise your own work by looking at it — the picker is a contact - sheet of thumbnails, newest first, not a list of titles. */ -.pickerGrid { - display: grid; - /* minmax(0, …): a long title is cut by its caption, it does not widen the - column. With plain 1fr the grid grew past the picker and scrolled sideways. */ - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 8px; - padding: 2px 4px 6px; -} - -.pickerItem { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - background: none; - border: none; - padding: 0; - cursor: pointer; -} - -.pickerTile { - position: relative; - display: block; - width: 100%; - aspect-ratio: 1 / 2; - border: 1px solid var(--pfc-hair); - background: var(--pfc-well); - overflow: hidden; -} - -.pickerItem:hover .pickerTile { - border-color: var(--pfc-led); -} - -.pickerCaption { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--pf-mono, monospace); - font-size: 8.5px; - color: var(--pfc-ink-muted); -} - -.pickerItem:hover .pickerCaption { - color: var(--pfc-ink); -} - -/* Private patterns in the picker can only become research notes — the tag - says where the drop will land before anything is dropped. */ -.researchTag { - color: var(--pfc-led, #e8552e); - opacity: 0.85; -} - -.pickerHead { - font-family: var(--pf-mono, monospace); - font-size: 9.5px; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--pfc-ink-faint); - padding: 6px 10px 8px; -} - -.pickerSearch { - width: calc(100% - 8px); - margin: 0 4px 6px; - background: var(--pfc-well); - border: 1px solid var(--pfc-hair); - border-radius: 0; - color: var(--pfc-ink); - font-family: var(--pf-sans, sans-serif); - font-size: 12px; - padding: 6px 8px; -} - -.pickerSearch:focus { - outline: none; - border-color: var(--pfc-edge); -} - -.placeBanner { - align-self: center; - max-width: 100%; - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - background: var(--pfc-led-wash); - border: 1px solid var(--pfc-led-edge); - color: var(--pfc-led); - font-size: 12.5px; -} - -.placeBanner > button { - flex: none; -} - -.bannerLink { - color: var(--pfc-ink); -} - /* ── pattern pins: live tiles on the map ── */ .pin { @@ -303,10 +175,6 @@ cursor: pointer; } -.pin[data-draggable="true"] { - cursor: grab; -} - .pinTile { position: relative; width: 44px; @@ -376,33 +244,6 @@ color: var(--pfc-led); } -.moveHint { - margin: 14px 0 8px; - font-size: 12px; - color: var(--pfc-ink-faint); -} - -.removeBtn { - composes: toolBtn; - color: var(--pfc-danger); - border-color: rgba(232, 107, 94, 0.4); -} - -.removeBtn:hover { - color: var(--pfc-danger); - border-color: var(--pfc-danger); -} - -/* Why a move, a link or a removal did not save. */ -.writeError { - margin: 0 0 14px; - padding: 8px 10px; - background: rgba(232, 107, 94, 0.1); - border: 1px solid rgba(232, 107, 94, 0.4); - color: var(--pfc-danger); - font-size: 13px; -} - .legendTile { width: 8px; height: 16px; @@ -411,7 +252,7 @@ flex: 0 0 auto; } -/* ── lineage: made-from chip / owner select ── */ +/* ── lineage: the made-from chip ── */ .fromChip { composes: toolBtn; @@ -421,17 +262,6 @@ color: var(--pfc-ink); } -.fromSelect { - width: 100%; - background: var(--pfc-well); - color: var(--pfc-ink); - border: 1px solid var(--pfc-hair); - border-radius: 0; - font-family: var(--pf-sans, sans-serif); - font-size: 12.5px; - padding: 7px 8px; -} - /* ── the player: sandboxed live run + knobs ── */ .playerWell { @@ -783,22 +613,6 @@ /* ── narrow screens: stack map over panel ── */ @media (max-width: 900px) { - /* Here the buttons sit on a row of their own and may wrap, so "Add my - pattern" can be at the left edge. The picker hangs from the toolbar's - right edge instead of the button, and is never wider than the toolbar. */ - .toolbar { - position: relative; - } - - .pickerWrap { - position: static; - } - - .picker { - top: calc(100% - 8px); - width: min(396px, 100%); - } - .body { flex-direction: column; height: auto; @@ -806,7 +620,7 @@ } /* At 390px the chips wrap to two rows, and over the map they hid 21 of its - 83 labels. Here they stand above it, with the banner under them. */ + 83 labels. Here they stand above it. */ .mapTop { position: static; padding: 12px 16px 8px; diff --git a/web/src/components/community/AtlasClient.test.tsx b/web/src/components/community/AtlasClient.test.tsx index c3648deac..3786ab7a2 100644 --- a/web/src/components/community/AtlasClient.test.tsx +++ b/web/src/components/community/AtlasClient.test.tsx @@ -2,18 +2,12 @@ // rendering. With site data blocked that read throws a SecurityError, and it // used to take the whole map down with it. -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import type { AnchorHTMLAttributes, ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENTRIES } from "@/lib/atlas/data"; import AtlasClient, { type AtlasPinData } from "./AtlasClient"; -const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() })); - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ refresh, push: vi.fn(), replace: vi.fn() }), -})); - vi.mock("next/link", () => ({ default: ({ href, children, ...rest }: AnchorHTMLAttributes & { href: string; children: ReactNode }) => ( @@ -26,9 +20,7 @@ vi.mock("next/link", () => ({ vi.mock("./PatternCanvas", () => ({ default: () => null })); vi.mock("./SandboxPreview", () => ({ default: () => null })); -const atlas = (pins: AtlasPinData[] = []) => ( - -); +const atlas = (pins: AtlasPinData[] = []) => ; const tide: AtlasPinData = { patternId: "p-tide", @@ -36,10 +28,8 @@ const tide: AtlasPinData = { y: 60, entryId: null, kind: "pin", - isPublic: true, title: "Slow tide", code: "function draw() {}", - userId: "u1", username: "maker", displayUsername: null, }; @@ -63,36 +53,14 @@ describe("atlas by keyboard", () => { }); }); -// Every write to the map used to ignore the answer: a 401 after the login ran -// out left a dragged tile at its new spot as if it had saved, and a dropped -// connection ended in an unhandled rejection. -describe("atlas writes the server refuses", () => { - // The author's own view of the map, so the pin can be moved and removed. - const owned = ( - pins: AtlasPinData[], - myPatterns: Array<{ id: string; title: string; code: string; isPublic: boolean }> = [], - ) => ; - - const answer = (status: number, body: unknown) => - vi.stubGlobal( - "fetch", - vi.fn(async () => Response.json(body, { status })), - ); - - const openPin = () => { - fireEvent.click(screen.getByRole("button", { name: "Slow tide" })); - expect(screen.getByRole("heading", { name: "Slow tide" })).toBeTruthy(); - }; - - const scrollIntoView = vi.fn(); +// An archive since 26 October 2026. Until then an author's own view of the +// map had "Add my pattern", a made-from select, demote and promote buttons and +// "Remove from the map", and Edit layout let them drag their pin to a new +// spot. Each of those wrote to a route that now refuses. +describe("the atlas is an archive", () => { + const linked: AtlasPinData = { ...tide, entryId: ENTRIES[0].id }; beforeEach(() => { - refresh.mockClear(); - scrollIntoView.mockClear(); - Object.defineProperty(Element.prototype, "scrollIntoView", { - configurable: true, - value: scrollIntoView, - }); // jsdom lays nothing out and has neither of these. With the identity as // the map's screen matrix, a client point is a viewBox point. Object.defineProperty(SVGSVGElement.prototype, "getScreenCTM", { @@ -119,133 +87,52 @@ describe("atlas writes the server refuses", () => { vi.unstubAllGlobals(); }); - it("puts a dragged tile back and says why when the move is refused", async () => { - answer(401, { error: "Sign in to place work on the atlas." }); - render(owned([tide])); - openPin(); + it("says so at the top, in both languages", () => { + render(atlas()); + expect(screen.getByText(/^Archived in October 2026\./)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "한국어" })); + expect(screen.getByText(/^2026년 10월에 보관한 지도\./)).toBeTruthy(); + // The language is the module's for the rest of this file. + fireEvent.click(screen.getByRole("button", { name: "English" })); + }); + + it("opens a pin with nothing on it that writes", () => { + render(atlas([linked])); + expect(screen.queryByRole("button", { name: "Add my pattern" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Slow tide" })); + const panel = screen.getByRole("complementary"); + expect(within(panel).getByRole("heading", { name: "Slow tide" })).toBeTruthy(); + expect(within(panel).queryByRole("combobox")).toBeNull(); + expect(within(panel).queryByRole("button", { name: "Remove from the map" })).toBeNull(); + expect(within(panel).queryByRole("button", { name: "File as research (hide the tile)" })).toBeNull(); + + // The prompt it was made from is a way to that point, for everyone. + fireEvent.click(within(panel).getByRole("button", { name: ENTRIES[0].nmEn })); + expect(within(panel).getByRole("heading", { name: ENTRIES[0].nmEn })).toBeTruthy(); + }); + + it("leaves a pin where it is when Edit layout is on, and sends nothing", () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + render(atlas([tide])); + fireEvent.click(screen.getByRole("button", { name: "Slow tide" })); fireEvent.click(screen.getByRole("button", { name: "Edit layout" })); const map = screen.getByLabelText("A map of pattern space"); fireEvent.pointerDown(screen.getByRole("button", { name: "Slow tide" })); // viewBox (500, 320) is the middle of the plot: data (50, 50). fireEvent.pointerMove(map, { clientX: 500, clientY: 320 }); - expect(screen.getByText("x 50 · y 50")).toBeTruthy(); fireEvent.pointerUp(map); - expect((await screen.findByRole("alert")).textContent).toBe( - "Sign in to place work on the atlas.", - ); expect(screen.getByText("x 40 · y 60")).toBeTruthy(); - expect(refresh).not.toHaveBeenCalled(); - }); - - it("keeps a pin it could not remove, and the reason leaves with the selection", async () => { - answer(403, { error: "Only the pattern's author can place it." }); - render(owned([tide])); - openPin(); - fireEvent.click(screen.getByRole("button", { name: "Remove from the map" })); - - expect((await screen.findByRole("alert")).textContent).toBe( - "Only the pattern's author can place it.", - ); - expect(screen.getByRole("heading", { name: "Slow tide" })).toBeTruthy(); - expect(refresh).not.toHaveBeenCalled(); - // Remove is at the foot of the panel and the reason at its head. - expect(scrollIntoView).toHaveBeenCalledTimes(1); - - fireEvent.click(screen.getAllByRole("button", { name: ENTRIES[0].nmEn })[0]); - expect(screen.queryByRole("alert")).toBeNull(); - }); - - it("closes the panel and reloads the map when the removal saves", async () => { - answer(200, { ok: true }); - render(owned([tide])); - openPin(); - fireEvent.click(screen.getByRole("button", { name: "Remove from the map" })); - - await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); - expect(screen.queryByRole("heading", { name: "Slow tide" })).toBeNull(); - expect(screen.queryByRole("alert")).toBeNull(); - }); - - it("says nothing was saved when the link change gets no answer", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => { - throw new TypeError("Failed to fetch"); - }), - ); - render(owned([tide])); - openPin(); - const select = screen.getByRole("combobox") as HTMLSelectElement; - fireEvent.change(select, { target: { value: ENTRIES[0].id } }); - - expect((await screen.findByRole("alert")).textContent).toBe( - "Not saved. The server did not answer.", - ); - expect(select.value).toBe(""); - }); - - it("reads a proxy's error page as no answer", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("Bad gateway", { status: 502 })), - ); - render(owned([tide])); - openPin(); - fireEvent.click(screen.getByRole("button", { name: "Remove from the map" })); - - expect((await screen.findByRole("alert")).textContent).toBe( - "Not saved. The server did not answer.", - ); - }); - - it("stays in placing mode and says why when the drop is refused", async () => { - answer(429, { error: "Too many moves — let the pin settle a moment." }); - render(owned([], [{ id: "p-ember", title: "Ember", code: "function draw() {}", isPublic: true }])); - fireEvent.click(screen.getByRole("button", { name: "Add my pattern" })); - fireEvent.click(screen.getByTitle("Ember")); - - const catcher = screen.getByLabelText("A map of pattern space").querySelector("rect")!; - fireEvent.pointerUp(catcher, { clientX: 900, clientY: 100 }); - - expect((await screen.findByRole("alert")).textContent).toBe( - "Too many moves — let the pin settle a moment.", - ); - expect(screen.getByText(/Placing “Ember”/)).toBeTruthy(); - - fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - expect(screen.queryByRole("alert")).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); }); }); -// A screen reader announced these as "edit text", "combo box" and "slider" and -// nothing more. The search box only had its placeholder to go on. +// A screen reader announced these as "slider" and nothing more. describe("atlas fields say what they are", () => { - const nine = Array.from({ length: 9 }, (_, i) => ({ - id: `p${i}`, - title: `Pattern ${i}`, - code: "function draw() {}", - isPublic: true, - })); - - it("names the picker's search box in both languages", () => { - render(); - fireEvent.click(screen.getByRole("button", { name: "Add my pattern" })); - expect(screen.getByRole("searchbox", { name: "Search your patterns" })).toBeTruthy(); - - fireEvent.click(screen.getByRole("button", { name: "한국어" })); - expect(screen.getByRole("searchbox", { name: "내 패턴 찾기" })).toBeTruthy(); - // The language is the module's for the rest of this file. - fireEvent.click(screen.getByRole("button", { name: "English" })); - }); - - it("names the prompt a pin was made from", () => { - render(); - fireEvent.click(screen.getByRole("button", { name: "Slow tide" })); - expect(screen.getByRole("combobox", { name: "Made from prompt" })).toBeTruthy(); - }); - it("names each knob slider after the knob", () => { render(atlas([tide, { ...tide, patternId: "p-folds", title: "Folds", code: "// @knobs Folds=3..12, -, Zoom=2..17\nfunction draw() {}" }])); fireEvent.click(screen.getByRole("button", { name: "Slow tide" })); diff --git a/web/src/components/community/AtlasClient.tsx b/web/src/components/community/AtlasClient.tsx index 430d7c82a..16e6de01d 100644 --- a/web/src/components/community/AtlasClient.tsx +++ b/web/src/components/community/AtlasClient.tsx @@ -12,12 +12,14 @@ // preference. The generation prompt itself is always English. // // Entry coordinates are taste hypotheses: drag in edit mode, export, bake the -// JSON back into data.ts. Pin coordinates belong to their authors and save to -// the database on drop. +// JSON back into data.ts. Edit mode moves nothing on the server. +// +// Since 26 October 2026 the atlas is an archive, reached from the community's +// footer. The pins stay where their authors left them, and the route refuses +// the writes that used to place and move them. import Link from "next/link"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { useRouter } from "next/navigation"; import { BLOBS, ENTRIES, @@ -52,9 +54,6 @@ const LED = "#E8552E"; const LANG_KEY = "pf-atlas-lang"; const MIN_ZOOM = 1; const MAX_ZOOM = 8; -/** Drop a pattern within this many viewBox px of an entry and it inherits - * that entry's lineage — placing near a prompt IS pointing at it. */ -const LINK_RADIUS = 80; type Lang = "en" | "ko"; /** Status chips, plus "all" and "new" — the latest batch, which cuts across @@ -71,19 +70,15 @@ export type AtlasPinData = { * against an entry — kept as data, revealed from the entry panel, never * drawn as a tile. Failures are worth remembering where they happened. */ kind: "pin" | "research"; - /** Whether the pattern itself is public — a research note is allowed to be - * private, and a private note can never be promoted to a map tile. */ - isPublic: boolean; title: string; code: string; - userId: string; username: string | null; displayUsername: string | null; }; const UI = { en: { - tagline: "Experimental — a map for exploring more patterns.", + archived: "Archived in October 2026. The pins stay where their authors left them, and no new ones go up.", edit: "Edit layout", editing: "Editing — drag points", copyLayout: "Copy layout", @@ -109,10 +104,6 @@ const UI = { legendPin: "Pin — a published pattern, placed by its author", pinBy: "by", openPattern: "Open pattern ↗", - removePin: "Remove from the map", - moveHint: "Turn on [Edit layout] to drag this pin.", - placeBanner: (title: string) => `Placing “${title}” — click the map where it belongs`, - cancel: "Cancel", zoomHint: "wheel to zoom · drag to pan · double-click to reset", madeFrom: "Made from prompt", linkNone: "— none —", @@ -120,20 +111,10 @@ const UI = { researchFrom: (n: number) => `Research attempts · ${n}`, researchBadge: "research note", researchHint: "Kept as data against this point — not drawn on the map.", - fileResearch: "File as research (hide the tile)", - promotePin: "Put on the map as a tile", - placeBannerResearch: (title: string) => - `Filing “${title}” as research — drop it on the point it came from`, - privateTag: "private → research", knobs: "Knobs", - addPattern: "Add my pattern", - pickerHead: (n: number) => `Your patterns · newest first (${n})`, - pickerSearch: "Search by title…", - pickerSearchLabel: "Search your patterns", - noAnswer: "Not saved. The server did not answer.", }, ko: { - tagline: "실험 기능 — 더 많은 패턴 탐구를 위한 지도.", + archived: "2026년 10월에 보관한 지도. 핀은 작성자가 놓은 자리에 그대로 있고, 새 핀은 더 올라가지 않는다.", edit: "배치 편집", editing: "편집 중 — 점을 끌기", copyLayout: "배치 복사", @@ -159,10 +140,6 @@ const UI = { legendPin: "핀 — 작성자가 지도에 놓은 공개 패턴", pinBy: "by", openPattern: "패턴 열기 ↗", - removePin: "지도에서 내리기", - moveHint: "[배치 편집]을 켜면 이 핀을 끌 수 있다.", - placeBanner: (title: string) => `“${title}” 놓는 중 — 어울리는 자리를 클릭`, - cancel: "취소", zoomHint: "휠 확대 · 드래그 이동 · 더블클릭 리셋", madeFrom: "출신 프롬프트", linkNone: "— 없음 —", @@ -170,34 +147,11 @@ const UI = { researchFrom: (n: number) => `연구 기록 · ${n}`, researchBadge: "연구 기록", researchHint: "이 포인트에 데이터로만 남음 — 지도에는 그려지지 않는다.", - fileResearch: "연구 기록으로 내리기 (타일 숨김)", - promotePin: "지도 타일로 올리기", - placeBannerResearch: (title: string) => `“${title}” 연구 기록 중 — 출신 포인트 위에 놓기`, - privateTag: "비공개 → 연구 기록", knobs: "노브", - addPattern: "내 패턴 올리기", - pickerHead: (n: number) => `내 패턴 · 최신순 (${n})`, - pickerSearch: "제목으로 검색…", - pickerSearchLabel: "내 패턴 찾기", - noAnswer: "저장되지 않았다. 서버가 답하지 않았다.", }, } as const; -export default function AtlasClient({ - pins, - viewerId, - isAdmin, - myPatterns, -}: { - pins: AtlasPinData[]; - viewerId: string | null; - isAdmin: boolean; - /** The viewer's own patterns not yet on the map — the picker. Private ones - * can only be filed as research notes, and the placement flow routes them - * there. */ - myPatterns: Array<{ id: string; title: string; code: string; isPublic: boolean }>; -}) { - const router = useRouter(); +export default function AtlasClient({ pins }: { pins: AtlasPinData[] }) { // Reading straight from the store (rather than syncing state in an effect) // keeps the server's English markup and the browser's remembered choice // from fighting during hydration. @@ -205,42 +159,18 @@ export default function AtlasClient({ const [positions, setPositions] = useState>( () => Object.fromEntries(ENTRIES.map((e) => [e.id, [e.x, e.y] as [number, number]])), ); - // Local pin positions override the server rows while dragging. - const [pinPositions, setPinPositions] = useState>({}); const [selection, setSelection] = useState(null); const [filter, setFilter] = useState("all"); const [editMode, setEditMode] = useState(false); const [copied, setCopied] = useState(false); const [exported, setExported] = useState(false); const [view, setView] = useState({ k: 1, tx: 0, ty: 0 }); - // "Add my pattern": pick from the toolbar, then click the map to drop. - const [pickerOpen, setPickerOpen] = useState(false); - const [placing, setPlacing] = useState<{ - id: string; - title: string; - code: string; - isPublic: boolean; - } | null>(null); - // While placing: the entry the cursor would link to (nearest within radius). - const [placingNear, setPlacingNear] = useState(null); - // The picker lists your own patterns newest first, as thumbnails — you - // recognise your work by looking at it, not by reading titles. - const [pickerQuery, setPickerQuery] = useState(""); - // A write the server refused, with its reason (null when no answer came - // back), and what the panel showed then. Select something else and the - // message goes, so a refusal about one pin never sits under another. - const [writeError, setWriteError] = useState<{ text: string | null; at: Selection } | null>( - null, - ); const svgRef = useRef(null); const draggingEntryRef = useRef(null); - const draggingPinRef = useRef(null); const panRef = useRef<{ px: number; py: number; tx: number; ty: number } | null>(null); const dragMovedRef = useRef(false); - - // Wheel zoom must preventDefault, which React's synthetic listener cannot — // attach a real non-passive listener. useEffect(() => { @@ -272,11 +202,9 @@ export default function AtlasClient({ const statusLabel = (s: AtlasStatusId) => (lang === "en" ? STATUSES[s].labelEn : STATUSES[s].label); const statusLegend = (s: AtlasStatusId) => (lang === "en" ? STATUSES[s].legendEn : STATUSES[s].legend); - const pinPos = (pin: AtlasPinData): [number, number] => pinPositions[pin.patternId] ?? [pin.x, pin.y]; // Fall back to the data coords: hot reloads keep old state while ENTRIES // grows, and a missing id must never take the whole map down. const entryPos = (e: AtlasEntry): [number, number] => positions[e.id] ?? [e.x, e.y]; - const canEditPin = (pin: AtlasPinData) => isAdmin || (viewerId !== null && viewerId === pin.userId); // Only exemplars are drawn on the map; research rows surface from the entry // panel. Both live in `pins`, so selecting either opens the same panel. @@ -301,22 +229,7 @@ export default function AtlasClient({ return [Math.round(x), Math.round(y)]; }; - /** Nearest entry within LINK_RADIUS (perceptual/viewBox distance), else null. */ - const nearestEntry = ([x, y]: [number, number]): string | null => { - let best: string | null = null; - let bestDist = LINK_RADIUS; - for (const entry of ENTRIES) { - const [ex, ey] = entryPos(entry); - const dist = Math.hypot(((ex - x) / 100) * W, ((ey - y) / 100) * H); - if (dist < bestDist) { - bestDist = dist; - best = entry.id; - } - } - return best; - }; - - /* ── pointer choreography: pan the world, drag entries, drag pins ── */ + /* ── pointer choreography: pan the world, drag entries ── */ const onBackgroundDown = (ev: React.PointerEvent) => { const svg = svgRef.current; @@ -340,21 +253,6 @@ export default function AtlasClient({ return; } - const pinId = draggingPinRef.current; - if (pinId) { - const coords = toDataCoords(ev.clientX, ev.clientY); - if (!coords) return; - dragMovedRef.current = true; - setPinPositions((prev) => ({ ...prev, [pinId]: coords })); - return; - } - - // Placement mode: track which entry the cursor would inherit lineage from. - if (placing && !panRef.current) { - const coords = toDataCoords(ev.clientX, ev.clientY); - if (coords) setPlacingNear(nearestEntry(coords)); - } - const pan = panRef.current; if (pan) { const point = toViewBox(svg, ev.clientX, ev.clientY); @@ -367,13 +265,7 @@ export default function AtlasClient({ }; const onMapPointerUp = () => { - const pinId = draggingPinRef.current; - if (pinId && dragMovedRef.current) { - const pin = pins.find((p) => p.patternId === pinId); - if (pin) void savePin(pin, pinPos(pin)); - } draggingEntryRef.current = null; - draggingPinRef.current = null; panRef.current = null; window.setTimeout(() => { dragMovedRef.current = false; @@ -382,104 +274,9 @@ export default function AtlasClient({ const onMapClick = () => { if (dragMovedRef.current) return; - // In placement mode the background rect's pointerup does the drop - // (the click event carries no coordinates here). - if (placing) return; setSelection(null); }; - const onPlaceClick = (ev: React.PointerEvent) => { - if (!placing || dragMovedRef.current) return; - const coords = toDataCoords(ev.clientX, ev.clientY); - if (!coords) return; - void placePattern(placing, coords); - }; - - /* ── server writes ── */ - - /** One write to the atlas route. True when it saved; otherwise the panel - * says why, in the server's words when it sent any. */ - const writeAtlas = async (method: "POST" | "DELETE", body: Record) => { - const at = selection; - setWriteError(null); - try { - const res = await fetch("/api/community/atlas", { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (res.ok) return true; - const payload = (await res.json().catch(() => ({}))) as { error?: unknown }; - setWriteError({ text: typeof payload.error === "string" ? payload.error : null, at }); - } catch { - setWriteError({ text: null, at }); - } - return false; - }; - - const forgetPinPosition = (patternId: string) => - setPinPositions((prev) => { - const next = { ...prev }; - delete next[patternId]; - return next; - }); - - const savePin = async (pin: AtlasPinData, [x, y]: [number, number]) => { - const saved = await writeAtlas("POST", { - patternId: pin.patternId, x, y, entryId: pin.entryId, kind: pin.kind, - }); - if (saved) { - router.refresh(); - return; - } - // The drop did not stick: the tile goes back to where the server has it. - forgetPinPosition(pin.patternId); - }; - - const setPinLink = async (pin: AtlasPinData, entryId: string | null) => { - const [x, y] = pinPos(pin); - if (await writeAtlas("POST", { patternId: pin.patternId, x, y, entryId, kind: pin.kind })) { - router.refresh(); - } - }; - - /** Demote a tile to a research note, or promote a note back to a tile. */ - const setPinKind = async (pin: AtlasPinData, kind: "pin" | "research") => { - const [x, y] = pinPos(pin); - if (await writeAtlas("POST", { patternId: pin.patternId, x, y, entryId: pin.entryId, kind })) { - router.refresh(); - } - }; - - const placePattern = async (pattern: NonNullable, coords: [number, number]) => { - // Dropping NEAR a prompt point is pointing at it — the nearest entry - // within LINK_RADIUS becomes the lineage. Dropping in open sea links to - // nothing; the pin panel's select stays for corrections either way. - // - // A private pattern files as a research note, and a note without a point - // would be invisible everywhere — so that drop must land near an entry - // (the banner says so; a miss is a no-op, not an error). - const entryId = nearestEntry(coords); - const kind = pattern.isPublic ? "pin" : "research"; - if (kind === "research" && !entryId) return; - const saved = await writeAtlas("POST", { - patternId: pattern.id, x: coords[0], y: coords[1], entryId, kind, - }); - if (saved) { - setPlacing(null); - setPlacingNear(null); - setSelection({ kind: "pin", id: pattern.id }); - router.refresh(); - } - }; - - const removePin = async (patternId: string) => { - if (!(await writeAtlas("DELETE", { patternId }))) return; - setSelection(null); - forgetPinPosition(patternId); - router.refresh(); - }; - /* ── clipboard ── */ const copyText = async (text: string) => { @@ -507,11 +304,6 @@ export default function AtlasClient({ window.setTimeout(() => setExported(false), 1800); }; - const query = pickerQuery.trim().toLowerCase(); - const pickerList = myPatterns.filter( - (pattern) => query === "" || pattern.title.toLowerCase().includes(query), - ); - const newCount = ENTRIES.filter(isNewEntry).length; // A chip nobody can fill is a button that empties the map — statuses with no // entries stay off the bar until something moves into them. @@ -529,76 +321,15 @@ export default function AtlasClient({ const worldTransform = `translate(${view.tx} ${view.ty}) scale(${view.k})`; const inv = 1 / view.k; - const shownError = - writeError && writeError.at?.kind === selection?.kind && writeError.at?.id === selection?.id - ? writeError.text ?? t.noAnswer - : null; - return (

- {t.tagline} {t.zoomHint} + {t.archived} {t.zoomHint}

{/* One block: when the row runs out, the buttons move under the tagline together instead of each squeezing into a column. */}
- {myPatterns.length > 0 && ( - - - {pickerOpen && ( -
-
{t.pickerHead(myPatterns.length)}
- {myPatterns.length > 8 && ( - setPickerQuery(ev.target.value)} - autoFocus - /> - )} -
- {pickerList.map((pattern) => ( - - ))} -
-
- )} -
- )} @@ -617,9 +348,7 @@ export default function AtlasClient({
-
- {/* Chips and the placing banner share one column, so the banner sits - under the chips however many rows they wrap to. */} +
{chipList.map(([key, label]) => ( @@ -635,29 +364,6 @@ export default function AtlasClient({ ))}
- {placing && ( -
- - {placing.isPublic ? t.placeBanner(placing.title) : t.placeBannerResearch(placing.title)} - {placingNear && ENTRY_BY_ID.has(placingNear) && ( - - {" "}→ {entryName(ENTRY_BY_ID.get(placingNear)!)} - - )} - - -
- )}
- {/* Background catcher: pans the world; in placement mode, drops the pattern. */} + {/* Background catcher: pans the world. */} @@ -724,11 +429,10 @@ export default function AtlasClient({ const entry = ENTRY_BY_ID.get(pin.entryId); if (!entry) return null; const [ex, ey] = entryPos(entry); - const [px, py] = pinPos(pin); return ( - {placingNear === e.id && ( - - )} {e.st === "active" && ( )} @@ -813,13 +514,11 @@ export default function AtlasClient({ })} {mapPins.map((pin) => { - const [px, py] = pinPos(pin); const isSelected = selection?.kind === "pin" && selection.id === pin.patternId; - const draggable = editMode && canEditPin(pin); return (
{ - if (!draggable) return; - ev.preventDefault(); - ev.stopPropagation(); - draggingPinRef.current = pin.patternId; - dragMovedRef.current = false; - }} onClick={(ev) => { ev.stopPropagation(); if (!dragMovedRef.current) setSelection({ kind: "pin", id: pin.patternId }); @@ -882,11 +573,6 @@ export default function AtlasClient({