Skip to content
69 changes: 67 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,76 @@ cp .env.example .env # then fill in the values (see below)
npm run dev # http://localhost:3000
```

### Local development with local Postgres

You don't need PlanetScale to develop locally — any Postgres 16 works. The app,
the migration runner, and the seed script all read one connection string
(`PLANETSCALE_URL`), so pointing that at a local server is the only change.

1. Start a throwaway Postgres in Docker:

```sh
docker run -d --name jh-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16
```

2. Point `.env` at it (this replaces the PlanetScale value for local dev):

```sh
PLANETSCALE_URL=postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable
```

3. Install deps and run the migrations against the local DB:

```sh
npm install
npm run migrate # apply pending migrations (reads .env)
npm run migrate:status # show applied / pending
```

4. Seed a couple of users + docs (edit `scripts/dev-seed.ts` to taste):

```sh
npx tsx --env-file=.env scripts/dev-seed.ts
```

The seed script prints session tokens for `alice` and `bob`.

5. Run the server:

```sh
npm run dev # http://localhost:3000
```

6. Log in as a seeded user without the email flow: open http://localhost:3000,
then in the browser devtools console set the session cookie to a token the
seed script printed:

```js
document.cookie = "jh_sess=<bob_token>; path=/";
```

Reload the page and you're signed in as that user. To reset everything, just
`docker rm -f jh-pg` and start over from step 1.

#### Poking at the local DB directly

The container ships with `psql`, so you can run ad-hoc SQL against the local DB
without any client installed on your host — handy for tweaking state while
testing (e.g. rotating a doc's view token to check the share flow):

```sh
docker exec jh-pg psql -U postgres -c \
"UPDATE documents SET view_token = 'rotated999' WHERE slug = '<private-slug>';"
```


## Environment variables

All live in `.env` (never committed). See `.env.example` for the full list.

| Var | Purpose |
|----------------------|----------------------------------------------------------|
| `PLANETSCALE_URL` | Postgres connection string (psql-compatible wire) |
| `PLANETSCALE_URL` | Postgres connection string (psql-compatible wire). For local dev, point this at a local Postgres, e.g. `postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable` |
| `RESEND_API_KEY` | Resend key for login magic-link email (`notify.justhtml.sh`) |
| `VERCEL_TOKEN` / `VERCEL_ORG_ID` / `VERCEL_PROJECT_ID` | Deploy pipeline |

Expand All @@ -35,7 +98,9 @@ New env vars must be set in **both** `.env` and Vercel production env.
## Migrations

SQL migrations live in `migrations/` (numbered, run in order). They run directly
against the production PlanetScale database — there is no separate dev DB.
against whatever `PLANETSCALE_URL` points at — the production PlanetScale
database, or a local Postgres for development (see [Local development with local
Postgres](#local-development-with-local-postgres)).

```sh
npm run migrate # apply pending migrations (reads .env)
Expand Down
201 changes: 201 additions & 0 deletions app/bookmarks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { getSession } from "@/lib/auth/session";
import type { Session } from "@/lib/auth/session";
import { sanitizeNext } from "@/lib/auth/url";
import { canViewSession } from "@/lib/docs/access";
import { safeEqualStr } from "@/lib/auth/tokens";
import { accessRoleLabel, resolveAccess } from "@/lib/docs/grants";
import { htmlResponse, manPage, esc, redirect } from "@/lib/page";
import { query } from "@/lib/db";
import {
listBookmarks,
saveBookmark,
removeBookmark,
type BookmarkDocRow,
findBySlug,
} from "@/lib/docs/store";
import { bookmarkRow } from "@/lib/docs/bookmarks-view";

export const dynamic = "force-dynamic";

const LIST_LIMIT = 500;

// Rows lay out as a flex line: the man-page `<pre>` (title + dimmed tail) and a
// quiet inline `remove` form sitting on the same baseline.
const ROW_STYLE = `
<style>
.row { display: flex; align-items: baseline; gap: 0.75rem; padding: 0.1rem 0; }
.row pre { min-width: 0; }
.row a.title { font-weight: 700; }
.row .tail { color: #888; }
.row.revoked .title { color: #999; }
.row.revoked .tail { color: #b00020; }
.row .rm { margin: 0; }
.row .remove { background: none; border: none; padding: 0; color: #888; text-decoration: underline; cursor: pointer; }
.row .remove:hover { background: none; color: #b00020; }
</style>`;

async function accountOwnerId(email: string): Promise<number | null> {
const { rows } = await query<{ id: number }>(`SELECT id FROM users WHERE email = $1`, [email]);
return rows[0]?.id ?? null;
}

/**
* Re-resolve a shared bookmark's access using the token it was saved through
* (owner/grant/public need none). Revoked → not linkable. A private doc still
* reachable only via that token is labeled "link" and keeps the token so its
* row links back with it appended.
*/
async function bookmarkAccess(
doc: BookmarkDocRow,
session: Session,
ownerId: number | null
): Promise<{ access: string; linkable: boolean; token: string | null }> {
if (doc.deleted_at) return { access: "revoked", linkable: false, token: null };

if (!(await canViewSession(doc, session, doc.bookmark_token))) {
return { access: "revoked", linkable: false, token: null };
}

const resolved = await resolveAccess(doc, session.email, ownerId ?? -1);
if (resolved.kind === "owner") return { access: "owner", linkable: true, token: null };
if (resolved.kind === "none") {
// No grant: reachable via public, or only through the stored view token.
return doc.is_public
? { access: "public", linkable: true, token: null }
: { access: "link", linkable: true, token: doc.bookmark_token };
}
return { access: accessRoleLabel(resolved), linkable: true, token: null };
}

function emptySection(heading: string, copy: string): string {
return `<h2>${heading}</h2>
<div class="body"><pre>${copy}</pre></div>`;
}

function section(heading: string, rows: string[]): string {
return `<h2>${heading}</h2>
<div class="body">
${rows.join("\n")}
</div>`;
}

export async function GET(req: Request): Promise<Response> {
const session = await getSession(req);
if (!session) return redirect("/login?next=%2Fbookmarks");

const email = session.email;
const ownerId = session.user_id ?? (await accountOwnerId(email));
const bookmarks = await listBookmarks(email, LIST_LIMIT);

const owned: BookmarkDocRow[] = [];
const shared: BookmarkDocRow[] = [];
for (const bookmark of bookmarks) {
if (ownerId != null && bookmark.owner_id === ownerId) owned.push(bookmark);
else shared.push(bookmark);
}

const intro = `<div class="body"><pre>Signed in as <code>${esc(email)}</code>.\n\nBookmarks track current access. If a doc's access is later revoked it stays\nlisted with a revoked label and no link — use remove to drop it.</pre></div>`;

// Revoked rows show the title snapshotted at bookmark time (bookmark_title),
// never the doc's current title, which the viewer can no longer see.
const ownedRows = owned.map((doc) => {
const revoked = doc.deleted_at != null;
return bookmarkRow({
docId: doc.id,
slug: doc.slug,
title: revoked ? doc.bookmark_title : doc.title,
access: revoked ? "revoked" : "owner",
isPublic: doc.is_public,
bookmarkedAt: doc.bookmarked_at,
linkable: !revoked,
token: null,
});
});

const sharedRows: string[] = [];
for (const doc of shared) {
const a = await bookmarkAccess(doc, session, ownerId);
sharedRows.push(
bookmarkRow({
docId: doc.id,
slug: doc.slug,
title: a.linkable ? doc.title : doc.bookmark_title,
access: a.access,
isPublic: doc.is_public,
bookmarkedAt: doc.bookmarked_at,
linkable: a.linkable,
token: a.token,
})
);
}

const body = [
ROW_STYLE,
intro,
owned.length
? section("YOUR DOCUMENTS", ownedRows)
: emptySection(
"YOUR DOCUMENTS",
"You haven't bookmarked any of your own documents yet. Use the bookmark\nbutton on a doc to save it here."
),
shared.length
? section("SHARED WITH YOU", sharedRows)
: emptySection(
"SHARED WITH YOU",
"Nothing shared with you is bookmarked yet. Use the bookmark button on a\ndoc to save it here."
),
].join("\n");

return htmlResponse(
manPage({
title: "justhtml.sh — bookmarks",
bodyHtml: body,
})
);
}

// Content-negotiated: the doc viewer's bookmark button posts via fetch with
// `Accept: application/json` and gets a bare status back (it updates the icon
// optimistically, no navigation). The zero-JS forms on the /bookmarks list
// (per-row remove) omit that header and get the usual 303 back to `next`.
export async function POST(req: Request): Promise<Response> {
const wantsJson = (req.headers.get("accept") ?? "").includes("application/json");
const status = (code: number) => new Response(null, { status: code });

const session = await getSession(req);
if (!session) return wantsJson ? status(401) : redirect("/login?next=%2Fbookmarks");

const form = await req.formData();
const action = String(form.get("action") ?? "add").trim();
const slug = String(form.get("slug") ?? "").trim();
const next = sanitizeNext(
String(form.get("next") ?? (slug ? `/d/${encodeURIComponent(slug)}` : "/bookmarks"))
);
const done = () => (wantsJson ? status(204) : redirect(next));

// Remove is keyed by doc id so a revoked/deleted doc (whose slug no longer
// resolves) can still be dropped. No access check: you can only ever remove
// your own bookmark.
if (action === "remove") {
const docId = Number(form.get("doc_id"));
if (Number.isInteger(docId)) await removeBookmark(session.email, docId);
return done();
}

const viewtoken = String(form.get("viewtoken") ?? "").trim() || null;
if (!slug) return wantsJson ? status(400) : redirect("/bookmarks");

const doc = await findBySlug(slug);
if (!doc || !(await canViewSession(doc, session, viewtoken))) {
return wantsJson ? status(403) : redirect(next);
}

// Persist the token only when it actually matches this doc. Access may have
// come from ownership, a grant, or the doc being public, in which case the
// submitted token is irrelevant — storing it would gate the later re-check on
// a token that was never the basis for access (and could overwrite a good
// one on upsert).
const tokenToStore = viewtoken && safeEqualStr(viewtoken, doc.view_token) ? viewtoken : null;
await saveBookmark(session.email, doc.id, tokenToStore, doc.title);
return done();
}
77 changes: 76 additions & 1 deletion app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ type Props = {
canComment: boolean;
canReact: boolean;
signedIn: boolean;
docId: number;
bookmarked: boolean;
me: string | null;
initialThreads: Thread[];
initialDocReactions: Reaction[];
Expand Down Expand Up @@ -134,7 +136,20 @@ function legacyCopy(text: string): boolean {
}

export default function CommentsShell(props: Props) {
const { slug, title, rawSrc, viewtoken, canComment, canReact, signedIn, me, initialSections } = props;
const {
slug,
title,
rawSrc,
viewtoken,
canComment,
canReact,
signedIn,
docId,
me,
initialSections,
} = props;
const [bookmarked, setBookmarked] = useState(props.bookmarked);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bookmark icon stale after navigation

Medium Severity

The doc viewer keeps bookmark UI in useState(props.bookmarked) without resetting when the server sends a new bookmarked value for another document. Client navigations that reuse CommentsShell can show the previous doc’s filled or empty bookmark icon and wrong aria-pressed labels on the current page.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6ded664. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid as a general React pattern, but not a reachable bug in this app, so leaving as-is.

So the trigger the check describes ("client navigations that reuse CommentsShell") does not exist. If soft routing between docs is ever introduced, the correct fix is key={slug} on <CommentsShell> so all prop-seeded state resets together — a bookmark-only useEffect would be the wrong shape.

const bookmarkPending = useRef(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [threads, setThreads] = useState<Thread[]>(props.initialThreads);
Expand Down Expand Up @@ -522,6 +537,41 @@ export default function CommentsShell(props: Props) {
[apiBase, tokenQuery, reload]
);

// Bookmark toggle. OPTIMISTIC like reactions: flip the icon immediately, POST
// to /bookmarks in the background (form-encoded so it reuses the same handler
// as the zero-JS forms; Accept: application/json makes it answer 204 instead
// of redirecting), and roll back if the request fails. A pending guard drops
// clicks while one is in flight so rapid toggling can't desync.
const toggleBookmark = useCallback(async () => {
if (bookmarkPending.current) return;
bookmarkPending.current = true;
const next = !bookmarked;
setBookmarked(next);

const body = new URLSearchParams();
if (next) {
body.set("slug", slug);
if (viewtoken) body.set("viewtoken", viewtoken);
} else {
body.set("action", "remove");
body.set("doc_id", String(docId));
}

try {
const r = await fetch("/bookmarks", {
method: "POST",
headers: { Accept: "application/json" },
credentials: "same-origin",
body,
});
if (!r.ok) setBookmarked(!next);
} catch {
setBookmarked(!next);
} finally {
bookmarkPending.current = false;
}
}, [bookmarked, slug, viewtoken, docId]);

// Anchored reaction (on a SPAN). OPTIMISTIC (birthday.md HARD REQUIREMENT): the
// local state mutates first so the chip + highlight paint immediately via the
// jh:reactions postMessage — no reload, no refetch wait. The POST runs in the
Expand Down Expand Up @@ -782,6 +832,31 @@ export default function CommentsShell(props: Props) {
</span>
<span style={{ flexShrink: 0, paddingLeft: "1.25rem", display: "flex", gap: "1.25rem", alignItems: "center", color: "var(--jh-bar-muted, #666)" }}>
<ThemeToggle mode={mode} onChange={chooseMode} />
{signedIn ? (
<button
type="button"
onClick={toggleBookmark}
aria-pressed={bookmarked}
aria-label={bookmarked ? "Remove bookmark" : "Bookmark this doc"}
title={bookmarked ? "Remove bookmark" : "Bookmark this doc"}
style={{ ...commentBtnStyle(bookmarked), lineHeight: 1 }}
>
<svg
viewBox="0 0 24 24"
width="13"
height="13"
fill={bookmarked ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={{ display: "block" }}
>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
) : null}
<button
type="button"
className="jh-commentbtn"
Expand Down
Loading
Loading