# Second Phone Number Blog admin

Admin panel for the [Second Phone Number](https://secondlinenumber.com/) blog. The team writes, schedules and publishes articles here; the website ([website repository](https://dev.azure.com/BTS-MobileApps/1140_2nd_Phone_Number/_git/1140_2nd_Phone_Number_Website)) renders them at `/blog/`.

The admin panel has **no database**. It signs in to the Laravel blog CMS (`https://secondphonenumber.worksqr.com`) and reads and writes everything through its admin API (`docs/laravel-blog-api.md` in the website repository). Laravel stores articles, media and settings, runs the publishing rules, publishes scheduled articles and refreshes the website.

Built with [Next.js 16](https://nextjs.org) (App Router), React 19, TypeScript, Tailwind CSS v4, [shadcn/ui](https://ui.shadcn.com) and the [Tiptap](https://tiptap.dev) editor.

## Getting started

```bash
npm install
cp .env.example .env        # CMS_ENV=dev (staging); prod = https://api.secondlinenumber.com/api
npm run dev                 # http://localhost:3200
```

Sign in at [http://localhost:3200/login](http://localhost:3200/login) with a blog admin account on the Laravel CMS. Accounts are created on Laravel (or under **Settings → Team** once Laravel serves `admin/users`).

## Scripts

| Command                        | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run dev`                  | Start the dev server on port 3200                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `npm run build`                | Production build                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `npm run start`                | Serve the production build                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `npm run check`                | Typecheck, lint and format check: run before committing                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `npm run laravel -- <command>` | Call the Laravel CMS API from the command line: `login`, `me`, `blogs`, `create-draft` (optionally with `--featured`/`--image` as Base64), `set-active`, `delete-blog`, `media`, `upload-media <image>`, `delete-media`, `upload-images <file>` (puts an export file's images in the media library and writes `<name>.cms.json`), `check-images`, `import <file>` (dry run; `--write` imports when nothing fails), `verify <file>`, `encrypt`, `decrypt`, `public posts\|post <slug>\|sitemap`. Reads `LARAVEL_*` from `.env`; see `scripts/laravel-cms.ts` |

## Environment variables

See `.env.example`.

| Name                                          | Required | Description                                                                                                                                                                                                        |
| --------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `CMS_ENV`                                     | No       | Which Laravel CMS: `prod` (`https://api.secondlinenumber.com/api`) or `dev` (staging, the default). Also used at build time to proxy `/uploads/*`. Secrets for `npm run laravel` may take a `_PROD`/`_DEV` suffix. |
| `LARAVEL_API_URL_PROD`, `LARAVEL_API_URL_DEV` | No       | The API base URL for each server; `CMS_ENV` picks one. Falls back to `LARAVEL_API_URL`, then the built-in address.                                                                                                 |
| `SITE_URL`                                    | In prod  | The website. Enables "View live" links and lets previews frame it.                                                                                                                                                 |
| `MEDIA_MAX_UPLOAD_MB`                         | No       | Upload size limit checked here before forwarding, default 10. Keep it in step with Laravel.                                                                                                                        |

## How it fits together

```
 Editors ──► Admin panel (this app) ──► Laravel CMS admin API ──── MySQL/Postgres, media storage
                                              │
                    refresh after a change    ▼
              Website (/blog/, /blog/<slug>/, /blog/preview/<token>/) ◄── Laravel public API ◄── Readers
```

- **Sign-in**: `POST admin/auth/login` returns an API token, kept in an httpOnly cookie (`__Host-cms_session` in production) and sent as `Authorization: Bearer` on every request. Laravel rate-limits sign-in and expires tokens; sign-out calls `admin/auth/logout`.
- **Data layer**: `src/lib/cms/api.ts` is the only way to the CMS. `cmsPaths` lists every endpoint; queries and actions in `src/features/*/` keep the same signatures they had with Prisma, so pages and components are unchanged. Laravel returns snake_case rows; the mappers read either case.
- **Endpoints Laravel doesn't serve yet**: staging serves auth, the blog list, one article, create/update, publish/unpublish and the JSON import (docs/laravel-blog-api.md §18.1). Pages whose data comes from a missing endpoint show "Not available yet" with the endpoint name (`CmsUnavailable`), pickers fall back to empty lists, and actions explain that the CMS doesn't support them yet. Each starts working as soon as Laravel adds the §8.2 route.
- **Images**: uploads go through `POST /api/admin/media`, which forwards them to Laravel. Article HTML that refers to `/uploads/…` is proxied to the Laravel host (`next.config.ts` rewrites), so the editor shows those images.
- **Preview**: opens `/admin/blogs/<id>/preview`, which frames the URL from `GET admin/blogs/{id}/preview-url` at desktop, tablet and mobile widths.

`legacy-postgres/` holds the old Prisma/Postgres implementation for reference while Laravel ports it. It is excluded from the build and tooling.

## Publishing workflow

| Status    | Visible on the website         | How it gets there                                        |
| --------- | ------------------------------ | -------------------------------------------------------- |
| Draft     | No                             | New articles, Save draft, Unpublish, Unschedule          |
| Scheduled | From its publish date and time | Publish date in the future, then Schedule                |
| Published | Yes                            | Publish (optionally backdated), or a schedule coming due |

- **Autosave** runs a few seconds after typing stops. Drafts are saved in place. For live or scheduled articles, edits are parked in the article's `autosave` on the CMS so readers never see half-finished changes; **Update** applies them, **Discard edits** throws them away, and previews show them.
- **Unsaved changes** trigger a warning before closing the tab or following a link.
- **Conflicts**: every save carries the article's `version`. If someone else saved in between, the save is rejected with a prompt to reload rather than overwriting their work.
- **Revisions** are recorded when an article is published, updated while live, unpublished, restored, or published by the scheduler. Restoring brings back the content and settings but keeps the current URL, and snapshots the current state first.
- **Slugs** are generated from the title until edited or published. Changing a published article's slug makes the CMS add a permanent redirect from the old URL and re-point older redirects to avoid chains. Manual redirects are managed under SEO.

### Importing a Markdown file

**Import from Markdown…** (the editor's ⋯ menu, or the prompt on an empty article) fills the editor from a `.md` file. It replaces the title, excerpt, body, FAQs and SEO text; category, author, tags, images, CTA, related articles and the publish date change only when the file sets them. Nothing goes live: drafts autosave as usual, live articles still need **Update**, and **Undo** restores the previous state. A live article keeps its slug.

```markdown
---
title: How Airbnb hosts keep their number private
slug: airbnb-host-phone-number # optional; otherwise from the title
excerpt: One or two lines for the blog cards. # optional; otherwise the first paragraph
category: Privacy # name or slug
author: Andrew Miller # name or slug
tags: [airbnb, privacy]
date: 2026-11-02 # or 2026-11-02T09:30, in your time zone
seo_title: …
meta_description: …
focus_keyword: airbnb host phone number
canonical: https://… # only for articles first published elsewhere
cover_image: /uploads/… # must already be uploaded (or an https:// URL)
cover_alt: …
og_title: …
og_description: …
og_image: …
noindex: false
faqs:
  - q: A question?
    a: The answer. Blank lines separate paragraphs.
cta: {
    title: …,
    description: …,
    text: Get your number,
    link: /business/phone-numbers/,
    style: button, # or app_store (Apple badge); an App Store link picks app_store
  }
related: [other-article-slug, /blog/another-article/]
---

# Title (used when the front matter has none; always taken out of the body)

The article in Markdown, including GFM tables. ![alt](https://… "caption") becomes an image with a caption.

## FAQ

### Questions under this heading become the FAQs when the front matter has none
```

Front matter keys also accept `snake_case`, `camelCase` or spaces, and common aliases (`primary_keyword`, `meta_title`, `cta_text`/`cta_link`, `featured_image`). Keys with no field here yet, such as `tldr` or `references`, are listed in the import summary rather than lost silently. The summary also flags images that aren't uploaded, placeholders such as `___`, and categories, authors or related articles it couldn't find. The parser is `src/features/blogs/markdown-import.ts`.

## Project structure

```
admin-panel/
├── scripts/laravel-cms.ts    # Laravel CMS API from the command line
├── legacy-postgres/          # The old Prisma version, reference only (not built)
└── src/
    ├── proxy.ts              # Sends visitors without a session cookie to /login
    ├── app/
    │   ├── login/
    │   ├── admin/            # Dashboard, blogs (editor, preview, revisions, import), categories,
    │   │                     # authors, media, seo, settings
    │   └── api/admin/media/  # Upload and library listing, forwarded to Laravel
    ├── components/
    │   ├── ui/               # shadcn/ui primitives
    │   ├── admin/            # Page shell, sidebar, list controls, dialogs, badges, CmsUnavailable
    │   └── media/            # Dropzone, image field, media picker
    ├── features/             # One folder per domain: schemas.ts, queries.ts, actions.ts
    │   ├── blogs/            #   (+ service.ts mappers and editor/ for the article editor)
    │   └── categories/ authors/ media/ seo/ redirects/ settings/ users/ auth/ blog-import/
    └── lib/
        ├── cms/              # api.ts (client, paths, row helpers), types.ts (enums)
        ├── laravel-cms/      # Shared HTTP client and RNCryptor (also used by the script)
        └── …                 # env, auth (session cookie), sanitize, slug, site URLs
```

### Conventions

- **Server Components by default**; reads live in `features/*/queries.ts` (`server-only`) and go through `src/lib/cms/api.ts`.
- **Every mutation is a Server Action** in `features/*/actions.ts` that starts with `requireActionAdmin()`, validates input with zod, calls the CMS and returns an `ActionResult` via `runAction`, which turns Laravel errors (validation, 409 conflicts, expired sessions, missing routes) into messages for the editor.
- **New endpoints**: add the path to `cmsPaths`, and wrap optional data in `withFallback` and a page's main data in `loadOrUnavailable`.
- **Route Handlers that change state** check `isSameOrigin(request)` and the session themselves.
- **Colors come from tokens** in `src/app/globals.css` (`bg-card`, `text-muted-foreground`, `bg-success-soft`, …).

## Security

- **Authentication** is Laravel's (Sanctum token per admin). The token never reaches browser JavaScript: the cookie is `HttpOnly`, `SameSite=Lax`, `Secure` in production with the `__Host-` prefix.
- **Authorization**: Admins manage the team and site settings; Editors manage content. Laravel enforces it; the admin panel also hides admin-only pages.
- **Headers**: CSP, `X-Frame-Options: DENY`, HSTS in production, `noindex` on every page.

## Deployment

1. Set `CMS_ENV` (and `SITE_URL`) and build: `npm run build`.
2. When running more than one instance yourself, set `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` to the same value everywhere.

On a single Linux server (AWS EC2): Nginx, PM2, release folders with rollback and HTTPS are set up by the scripts in [`deploy/`](deploy/README.md).
