# Thoughts

URL: https://sola.ysz.life/projects/thoughts

> Reflections, fragments, and notes

- Type: Personal project
- Period: Aug 2025
- Live: https://thoughts.ysz.life
- Source: https://github.com/lyfe691/thoughts
- Stack: Next.js, TypeScript, Tailwind CSS, MDX, PostgreSQL, Vercel

Thoughts is my writing site, kept apart from this portfolio: a few pages of plain text and a guestbook that anyone can sign. I built it in August 2025 as a Next.js app on Vercel, with the posts as MDX files and the guestbook in Postgres.

## A writing site with a guestbook

[Thoughts](https://thoughts.ysz.life) is where I write the things that don't fit a project page. It's a few quiet pages of text: an about page, the list of posts and a guestbook where anyone can leave a note. It's based on [Shu Ding's site](https://shud.in).

![The about page, then a click on guestbook in the italic links on the left: the guestbook fades in with its dated entries, and a message is typed into the form without being sent. Every visitor's name and message is blurred](https://sola.ysz.life/projects/thoughts/hero.webp)
*From the about page to the guestbook, names blurred.*
[Video](https://sola.ysz.life/projects/thoughts/hero.mp4)

### Pages and posts

The links sit to the left of the text in lowercase italic serif, and on a wide screen they stay in place while you scroll. Moving between pages crossfades. On a phone, the links move into a row above the text.

Each post is an MDX file in the repository that exports its title, date and description. The list shows the posts newest first, each title joined to its date by a row of dots. Code in a post is highlighted by Shiki on the server, so the colours arrive as finished HTML, and an image's title becomes a note in the margin beside it on a wide screen.

An address that doesn't exist gets a short verse.

![The not found page: the italic links about, thoughts and guestbook on the left, and beside them the heading not found above two lines, the page does not exist, and you were never supposed to be here](https://sola.ysz.life/projects/thoughts/not-found.webp)
*A page that doesn't exist.*

### The guestbook

Anyone can sign the guestbook with an optional name and a message of up to 280 characters, without an account. Entries show newest first with their date and time, ten at a time, and one you wrote yourself gets a Delete link when you hover it.

![The guestbook: a one-line form for a name and a message above a list of dated entries, with every name and message blurred](https://sola.ysz.life/projects/thoughts/guestbook-desktop.webp)
*The guestbook, with names and messages blurred here.*

### How it's built

Thoughts is a Next.js app on Vercel. The posts are compiled from MDX and rendered as static pages at build time. The guestbook is one Postgres table, read and written through `@vercel/postgres` by a single route handler.

#### Signing the guestbook

A `POST` to `/api/guestbook` runs three checks in order and writes only if all of them pass:

1. **The hidden field is empty.** The form has a `website` field a person never sees and a form-filling bot usually does, and any value in it gets the request rejected.
2. **The message fits.** It's trimmed and has to be between 1 and 280 characters, and the name is cut to 50.
3. **The sender isn't rushing.** The route looks up the latest entry from the same sender and answers 429 if it's less than 30 seconds old.

A sender is identified by a SHA-256 hash of the client's address, taken from the first entry in `x-forwarded-for`, and the hash is all the table keeps. It is enough to recognise the same sender twice, which is what the rate limit and the delete check below need.

`app/api/guestbook/route.ts`

```ts
export async function POST(req: NextRequest) {
  await ensureTable()
  const ipHash = getClientIpHash(req)
  let body: { name?: string; message?: string; hp?: string } = {}
  try {
    body = await req.json()
  } catch {
    // ignore
  }
  const { name = null, message = '', hp = '' } = body
  if (hp && hp.trim().length > 0) {
    return NextResponse.json({ error: 'rejected' }, { status: 400 })
  }
  const trimmed = (message || '').trim()
  if (trimmed.length === 0 || trimmed.length > MAX_MESSAGE_LENGTH) {
    return NextResponse.json({ error: 'invalid_message' }, { status: 400 })
  }
  const safeName = name ? String(name).slice(0, 50).trim() : null
  if (isRateLimitEnabled()) {
    const recent = await sql<{ created_at: string }>`
      SELECT created_at FROM guestbook
      WHERE ip_hash = ${ipHash}
      ORDER BY created_at DESC
      LIMIT 1
    `
    if (recent.rows.length > 0) {
      const last = new Date(recent.rows[0].created_at).getTime()
      const now = Date.now()
      if (now - last < RATE_LIMIT_WINDOW_MS) {
        return NextResponse.json({ error: 'rate_limited' }, { status: 429 })
      }
    }
  }
  const id = crypto.randomUUID()
  const approved = isAutoApprove()
  const rejected = false
  const inserted = await sql<GuestbookRow>`
    INSERT INTO guestbook (id, name, message, ip_hash, approved, rejected)
    VALUES (${id}, ${safeName}, ${trimmed}, ${ipHash}, ${approved}, ${rejected})
    RETURNING id, name, message, created_at, updated_at, edited
  `
  return NextResponse.json({ item: inserted.rows[0], approved }, { status: 201 })
}
```

This is abridged: the real handler has a second branch that keeps entries in an in-memory array when no database is configured, so the page works in local development without one.

#### Moderation

Every row carries two flags, and together they decide where an entry is:

| `approved` | `rejected` | The entry                      |
| ---------- | ---------- | ------------------------------ |
| Yes        | No         | Shows in the public list       |
| No         | No         | Waits in the admin queue       |
| Either     | Yes        | Is hidden, and the row is kept |

Whether a new entry is approved on arrival comes from one environment variable, which defaults to yes. With it off, new entries wait in the queue, and the visitor sees a note that theirs is pending instead of seeing it at the top of the list.

The queue is a token-protected admin page. It lists pending entries oldest first, each with an approve and a reject button, and a second view lists the approved ones with a delete button. Rejecting an entry sets its flag and keeps the row. The rate limit reads every row from a sender, so a rejected entry still counts toward that sender's 30 seconds.

#### Deleting your own entry

Visitors don't have accounts, so the site needs another way to know who wrote an entry. When the browser posts one, it saves the returned id in `localStorage`, and entries with a saved id get the Delete link. The server doesn't rely on that list. Its query is `DELETE FROM guestbook WHERE id = … AND ip_hash = …`, so a row goes only when the request comes from the same hashed address that wrote it. The tradeoff is that ownership follows the network: on another connection the link still shows, and the server refuses it.

![The about page: the italic links about, thoughts and guestbook on the left, and a short introduction in a single column of text](https://sola.ysz.life/projects/thoughts/about-desktop.webp)
*The about page.*

![The about page on a phone: the three links in a row at the top and the introduction in one column below](https://sola.ysz.life/projects/thoughts/about-mobile.webp)
*About on a phone.*

![The guestbook on a phone: the name and message fields above dated entries, with every name and message blurred](https://sola.ysz.life/projects/thoughts/guestbook-mobile.webp)
*The guestbook.*
