Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/drizzle/0022_atlas-pin-kind.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `atlas_pins` ADD `kind` text DEFAULT 'pin' NOT NULL;
9 changes: 8 additions & 1 deletion web/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@
"when": 1786860000000,
"tag": "0021_pattern-performances",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1786880000000,
"tag": "0022_atlas-pin-kind",
"breakpoints": true
}
]
}
}
43 changes: 34 additions & 9 deletions web/src/app/api/community/atlas/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,21 @@ import { rateLimit } from "@/lib/community/ratelimit";
import { atlasPins, patterns } from "@/lib/community/schema";

// GET /api/community/atlas — every pattern pinned on the atlas.
// POST — place or move a pin: { patternId, x, y } in the atlas's 0..100
// data space. Only the pattern's author (or a moderator) may.
// 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.
// pattern; placing again just moves it (and can change its kind).

export async function GET(request: Request) {
const blocked = originBlocked(request);
if (blocked) return blocked;
return withCors(request, await handleGet());
return withCors(request, await handleGet(request));
}

export async function POST(request: Request) {
Expand All @@ -36,17 +40,21 @@ export async function DELETE(request: Request) {

export const OPTIONS = preflight;

async function handleGet() {
async function handleGet(request: Request) {
if (!communityEnabled()) {
return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 });
}
const pins = await listAtlasPins();
const session = await getAuth().api.getSession({ headers: request.headers });
const pins = await listAtlasPins(
session ? { id: session.user.id, isAdmin: isAdminSession(session) } : null,
);
return Response.json({
pins: pins.map((pin) => ({
patternId: pin.patternId,
x: pin.x,
y: pin.y,
entryId: pin.entryId,
kind: pin.kind,
title: pin.title,
userId: pin.userId,
username: pin.username,
Expand Down Expand Up @@ -107,7 +115,15 @@ async function handleWrite(request: Request, mode: "place" | "remove") {
return Response.json({ ok: true });
}

if (pattern.visibility !== "public") {
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 },
Expand All @@ -131,13 +147,22 @@ async function handleWrite(request: Request, mode: "place" | "remove") {
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, updatedAt: now })
.values({ patternId, x, y, entryId, kind, updatedAt: now })
.onConflictDoUpdate({
target: atlasPins.patternId,
set: { x, y, entryId, updatedAt: now },
set: { x, y, entryId, kind, updatedAt: now },
});

return Response.json({ ok: true });
Expand Down
21 changes: 15 additions & 6 deletions web/src/app/community/atlas/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,23 @@ export default async function AtlasPage() {
const viewerId = session?.user.id ?? null;
const isAdmin = isAdminSession(session);

const pins = await listAtlasPins();
const pins = await listAtlasPins(viewerId ? { id: viewerId, isAdmin } : null);

// The picker: the viewer's own public 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.
// 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) => pattern.visibility === "public" && !pinned.has(pattern.id))
.map((pattern) => ({ id: pattern.id, title: pattern.title, code: pattern.code }))
.filter((pattern) => !pinned.has(pattern.id))
.map((pattern) => ({
id: pattern.id,
title: pattern.title,
code: pattern.code,
isPublic: pattern.visibility === "public",
}))
: [];

return (
Expand All @@ -54,6 +61,8 @@ export default async function AtlasPage() {
x: pin.x,
y: pin.y,
entryId: pin.entryId,
kind: pin.kind === "research" ? ("research" as const) : ("pin" as const),
isPublic: pin.visibility === "public",
title: pin.title,
code: pin.code,
userId: pin.userId,
Expand Down
7 changes: 7 additions & 0 deletions web/src/components/community/Atlas.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@
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;
Expand Down
Loading
Loading