|
1 | 1 | # stackdeck |
2 | 2 |
|
3 | | -Internal viewer for Octify case study decks. Bring your own HTML, render as a deck. |
| 3 | +A bring-your-own-HTML deck viewer. Drop a folder of self-contained HTML files into `slides/<slug>/`, get a polished web viewer with present mode, browser-side PDF export, and per-deck OG images. Optional Sanity CMS for non-developer authoring. |
| 4 | + |
| 5 | +No editor, no theming layer, no markdown directives. Each slide is exactly the HTML you wrote, scaled to fit the viewport. |
| 6 | + |
| 7 | +## Why |
| 8 | + |
| 9 | +Most deck tools force you into their templates and editors. This one stays out of the way. If you can ship a `1920×1080` HTML document, you can ship a slide. The viewer adds chrome (sidebar, navigation, present mode, PDF export) without touching what's inside the slide. |
| 10 | + |
| 11 | +Use it for sales decks shared as URLs, internal show-and-tells, or any context where you want the reach of a web link plus the precision of hand-authored HTML. |
| 12 | + |
| 13 | +## Quick start |
| 14 | + |
| 15 | +```bash |
| 16 | +git clone https://github.com/Octify-Technologies/stackdeck |
| 17 | +cd stackdeck |
| 18 | +pnpm install |
| 19 | +pnpm dev |
| 20 | +``` |
| 21 | + |
| 22 | +Open `http://localhost:3000`. The index will be empty until you add a deck. |
| 23 | + |
| 24 | +### Add your first deck |
| 25 | + |
| 26 | +```bash |
| 27 | +mkdir -p slides/hello/assets |
| 28 | +cat > slides/hello/meta.json <<'EOF' |
| 29 | +{ |
| 30 | + "slug": "hello", |
| 31 | + "title": "Hello, deck", |
| 32 | + "summary": "First slide deck.", |
| 33 | + "visibility": "public" |
| 34 | +} |
| 35 | +EOF |
| 36 | +cat > slides/hello/01.html <<'EOF' |
| 37 | +<!doctype html> |
| 38 | +<html lang="en"> |
| 39 | +<head> |
| 40 | + <meta charset="utf-8"> |
| 41 | + <title>Hello</title> |
| 42 | + <style> |
| 43 | + body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; |
| 44 | + display: grid; place-items: center; background: #0a0a0a; color: #fafafa; |
| 45 | + font: 600 200px/1 -apple-system, sans-serif; letter-spacing: -0.04em; } |
| 46 | + </style> |
| 47 | +</head> |
| 48 | +<body>Hello.</body> |
| 49 | +</html> |
| 50 | +EOF |
| 51 | +``` |
| 52 | + |
| 53 | +Reload `http://localhost:3000`. Your deck appears. Click in to view, press `F` for present mode, click "Download PDF" to export. |
4 | 54 |
|
5 | 55 | ## How it works |
6 | 56 |
|
7 | | -Each case study lives in `case-studies/<slug>/` as a folder of self-contained HTML files (one per slide) plus a `meta.json` describing the deck. The viewer renders each slide inside a sandboxed iframe at a fixed **1920×1080** canvas, scaled to fit the viewport. |
| 57 | +Each deck is a folder under `slides/<slug>/`: |
| 58 | + |
| 59 | +- `meta.json` describes the deck (title, client, slug, etc.). |
| 60 | +- `01.html`, `02.html`, ... are individual slide documents. |
| 61 | +- `assets/` holds any images or fonts referenced from the slide HTML. |
8 | 62 |
|
9 | | -There is no editor, no theming layer, no markdown directives. Slides are authored elsewhere (Astro, hand-coded HTML, whatever) and dropped in. |
| 63 | +Each slide HTML is loaded into a sandboxed iframe at a fixed `1920×1080` canvas, scaled with CSS `transform: scale(...)` to whatever space the viewer has. The viewer chrome lives outside the iframe and never touches your slide content. |
10 | 64 |
|
11 | 65 | ## Authoring contract |
12 | 66 |
|
13 | | -Every slide HTML file must: |
| 67 | +Every slide HTML must: |
14 | 68 |
|
15 | 69 | 1. Be a complete `<!doctype html>` document. |
16 | | -2. Render against a **1920×1080** canvas. Set `body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }`. |
17 | | -3. Inline its CSS (no external CSS, no Google Fonts, no external scripts). Use system font stacks. |
18 | | -4. Include a `<title>` tag, used as the slide name. |
| 70 | +2. Render against a `1920×1080` canvas. Set `body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }`. |
| 71 | +3. Inline its CSS. No external stylesheets, no Google Fonts, no external scripts. Use system font stacks. |
| 72 | +4. Include a `<title>` tag, used as the slide name in the viewer's Contents sidebar. |
| 73 | + |
| 74 | +Static assets go in `slides/<slug>/assets/` and are referenced relatively from the slide: |
| 75 | + |
| 76 | +```html |
| 77 | +<img src="./assets/logo.png" alt="Logo"> |
| 78 | +``` |
19 | 79 |
|
20 | | -Static assets (images, fonts) live in `case-studies/<slug>/assets/` and are served at `/c/<slug>/assets/<path>`. |
| 80 | +The viewer injects a `<base href="/c/<slug>/">` tag when serving the slide so relative paths resolve correctly inside the iframe. |
21 | 81 |
|
22 | | -## `meta.json` |
| 82 | +## `meta.json` reference |
23 | 83 |
|
24 | 84 | ```json |
25 | 85 | { |
26 | | - "slug": "acme-churn", |
27 | | - "title": "How Acme cut churn by 38%", |
| 86 | + "slug": "acme-launch", |
| 87 | + "title": "How Acme launched their public API", |
28 | 88 | "client": "Acme Corp", |
29 | 89 | "industry": "B2B SaaS", |
30 | 90 | "date": "2026-03-12", |
31 | | - "summary": "One-line summary shown on the index card.", |
32 | | - "tags": ["churn", "lifecycle"], |
| 91 | + "summary": "One-line summary shown on the index card and in social previews.", |
| 92 | + "tags": ["api", "launch"], |
33 | 93 | "cover": "01.html", |
34 | 94 | "slides": [ |
35 | 95 | { "file": "01.html", "title": "Cover" }, |
36 | | - { "file": "02.html", "title": "Tear sheet" } |
| 96 | + { "file": "02.html", "title": "The brief" } |
37 | 97 | ], |
38 | 98 | "visibility": "public" |
39 | 99 | } |
40 | 100 | ``` |
41 | 101 |
|
42 | | -`slides` is optional. If omitted, all `*.html` files in the folder are picked up in lexicographic order, and each slide title is read from its `<title>` tag. |
| 102 | +| Field | Required | Notes | |
| 103 | +| ------------ | -------- | ----------------------------------------------------------------------------------------------------------- | |
| 104 | +| `slug` | yes | Must match folder name. Kebab-case, `[a-z0-9-]+`. | |
| 105 | +| `title` | yes | Deck title shown in the viewer's chrome. | |
| 106 | +| `client` | no | Optional client name shown in breadcrumbs. | |
| 107 | +| `industry` | no | Free-form string. | |
| 108 | +| `date` | no | ISO date, used to sort the index. | |
| 109 | +| `summary` | no | Up to ~280 chars. Shown on the homepage card and OG image. | |
| 110 | +| `tags` | no | Array of strings, rendered as colored chips on the index. | |
| 111 | +| `cover` | no | Filename of the slide used as the cover thumbnail. Defaults to first slide. | |
| 112 | +| `slides` | no | Explicit slide order. If omitted, all `*.html` files in the folder are picked up in lexicographic order. | |
| 113 | +| `visibility` | no | `"public"` (default) or `"private"`. Private decks are reachable via direct link but hidden from the index. | |
43 | 114 |
|
44 | 115 | ## Routes |
45 | 116 |
|
46 | | -- `/` — case studies index |
47 | | -- `/c/<slug>` — viewer (thumbnail strip + main slide + chrome) |
48 | | -- `/c/<slug>/present` — fullscreen present mode |
49 | | -- `/c/<slug>/slides/<file>` — raw slide HTML (iframe source, also openable directly for debugging) |
50 | | -- `/c/<slug>/assets/<path>` — slide static assets |
| 117 | +| URL | What it serves | |
| 118 | +| ------------------------- | ------------------------------------------------- | |
| 119 | +| `/` | Index of public decks | |
| 120 | +| `/c/<slug>` | Viewer (sidebar + main slide + chrome) | |
| 121 | +| `/c/<slug>/slides/<file>` | Raw slide HTML, used as the iframe source | |
| 122 | +| `/c/<slug>/assets/<path>` | Slide static assets | |
| 123 | +| `/api/revalidate` | Sanity webhook receiver (no-op without Sanity) | |
| 124 | +| `http://localhost:3333` | Sanity Studio (standalone, run via `pnpm studio`) | |
51 | 125 |
|
52 | | -## Keyboard |
| 126 | +## Keyboard shortcuts |
53 | 127 |
|
54 | | -| Key | Viewer | Present | |
55 | | -| ------------------ | ------------- | ------------- | |
56 | | -| `→` `Space` `PgDn` | Next | Next | |
57 | | -| `←` `PgUp` | Prev | Prev | |
58 | | -| `Home` / `End` | First / Last | First / Last | |
59 | | -| `1`–`9` | — | Jump to slide | |
60 | | -| `F` | Enter present | — | |
61 | | -| `Esc` | — | Exit | |
| 128 | +| Key | Viewer | Present | |
| 129 | +| ------------------ | ------------------ | ----------------- | |
| 130 | +| `→` `Space` `PgDn` | Next slide | Next slide | |
| 131 | +| `←` `PgUp` | Previous slide | Previous slide | |
| 132 | +| `Home` / `End` | First / last | First / last | |
| 133 | +| `1`–`9` | Jump to slide | Jump to slide | |
| 134 | +| `F` | Enter present mode | — | |
| 135 | +| `Esc` | — | Exit present mode | |
| 136 | + |
| 137 | +## URL parameters |
| 138 | + |
| 139 | +| Parameter | Effect | |
| 140 | +| ----------------- | ---------------------------------------------------------------------------------------------- | |
| 141 | +| `?to=<recipient>` | Shows a "for `<recipient>`" chip in the breadcrumb and personalizes mailto / PDF contact card. | |
| 142 | +| `#<n>` | Open the deck on slide `<n>` (1-indexed). The hash updates as you navigate. | |
| 143 | + |
| 144 | +## PDF export |
| 145 | + |
| 146 | +Click "Download PDF" in the viewer. The browser renders each slide into a JPEG via `html-to-image`, assembles a one-slide-per-page PDF via `jsPDF`, and triggers a Blob download. An Octify-branded contact card is appended as the last page. |
| 147 | + |
| 148 | +Output is rasterized (text isn't selectable in the resulting PDF) but every glyph is preserved exactly as your browser painted it, so font fidelity is guaranteed regardless of what's installed on the recipient's machine. |
| 149 | + |
| 150 | +To customize the PDF contact card branding, edit `renderContactCardToJpeg` in [src/lib/generate-pdf.ts](src/lib/generate-pdf.ts). |
| 151 | + |
| 152 | +## Optional: Sanity CMS |
| 153 | + |
| 154 | +If you want non-developers to author decks, you can connect Sanity. Decks created in Sanity Studio appear in the index alongside filesystem decks. When both sources contain the same slug, Sanity wins. |
| 155 | + |
| 156 | +1. Create a Sanity project at https://sanity.io/manage. |
| 157 | +2. Set env vars in `.env.local` (see `.env.example`): |
| 158 | + ``` |
| 159 | + NEXT_PUBLIC_SANITY_PROJECT_ID=<your project id> |
| 160 | + NEXT_PUBLIC_SANITY_DATASET=production |
| 161 | + SANITY_WEBHOOK_SECRET=<random string> |
| 162 | + ``` |
| 163 | +3. Add CORS origins for `http://localhost:3000` and your production URL in the Sanity dashboard. |
| 164 | +4. Run `pnpm studio` to start the Sanity Studio at `http://localhost:3333`. Sign in, create a `deck` document, paste the slide HTML into each slide entry. Publish. |
| 165 | +5. Configure a webhook (Project → API → Webhooks) pointing at `<your-domain>/api/revalidate`: |
| 166 | + - Filter (GROQ): `_type == "deck"` |
| 167 | + - Projection: `{ "_type": _type, "slug": slug.current }` |
| 168 | + - Secret: matches `SANITY_WEBHOOK_SECRET` |
| 169 | + |
| 170 | +Without these env vars the app silently uses the filesystem only. |
| 171 | + |
| 172 | +## Configuration |
| 173 | + |
| 174 | +All env vars are optional. App boots with none set; only filesystem decks are served. |
| 175 | + |
| 176 | +| Var | Purpose | |
| 177 | +| -------------------------------- | ------------------------------------------------------------ | |
| 178 | +| `NEXT_PUBLIC_SANITY_PROJECT_ID` | Enables the Sanity loader path. | |
| 179 | +| `NEXT_PUBLIC_SANITY_DATASET` | Defaults to `production`. | |
| 180 | +| `NEXT_PUBLIC_SANITY_API_VERSION` | ISO date, defaults to `2024-10-01`. | |
| 181 | +| `SANITY_READ_TOKEN` | Required only for previewing unpublished drafts. | |
| 182 | +| `SANITY_WEBHOOK_SECRET` | Required for `/api/revalidate` to verify webhook signatures. | |
62 | 183 |
|
63 | 184 | ## Development |
64 | 185 |
|
65 | 186 | ```bash |
66 | 187 | pnpm install |
67 | | -pnpm dev |
| 188 | +pnpm dev # http://localhost:3000 |
| 189 | +pnpm typecheck |
| 190 | +pnpm lint |
| 191 | +pnpm build |
68 | 192 | ``` |
69 | 193 |
|
70 | | -## Publishing a case study |
| 194 | +## Deploying |
| 195 | + |
| 196 | +Vercel is the simplest target. |
| 197 | + |
| 198 | +1. Connect the GitHub repo to Vercel. |
| 199 | +2. Add the Sanity env vars from `.env.example` to the Vercel project (or skip them entirely to run filesystem-only). |
| 200 | +3. Push to `main`. |
| 201 | + |
| 202 | +Per-deck OG images are statically generated at build time. The PDF generation runs entirely in the visitor's browser, so it has no serverless cold-start cost. |
| 203 | + |
| 204 | +## File map |
| 205 | + |
| 206 | +- `slides/<slug>/` — filesystem decks (gitignored is fine if Sanity is your source of truth). |
| 207 | +- `studio/` — Sanity schema and config. |
| 208 | +- [src/lib/decks.ts](src/lib/decks.ts) — hybrid loader (Sanity + filesystem). |
| 209 | +- [src/lib/sanity.ts](src/lib/sanity.ts) — Sanity client (returns `null` if env vars missing). |
| 210 | +- [src/lib/generate-pdf.ts](src/lib/generate-pdf.ts) — browser-side PDF generator. |
| 211 | +- [src/components/Viewer.tsx](src/components/Viewer.tsx) — main viewer. |
| 212 | +- [src/components/Present.tsx](src/components/Present.tsx) — fullscreen present mode. |
| 213 | +- [src/components/SlideFrame.tsx](src/components/SlideFrame.tsx) — fixed-canvas iframe. |
| 214 | +- [src/app/c/[slug]/](src/app/c/[slug]/) — viewer routes (page, slide HTML, assets, OG image). |
| 215 | +- [src/app/api/revalidate/](src/app/api/revalidate/) — Sanity webhook receiver. |
| 216 | + |
| 217 | +## Contributing |
| 218 | + |
| 219 | +Issues and PRs welcome. Two guardrails worth knowing: |
| 220 | + |
| 221 | +- The app is intentionally narrow. No in-app editor, no theming layer, no markdown. If you want a different visual per deck, put it in the deck's HTML. |
| 222 | +- Keep the BYO HTML contract intact. Any feature that lets slides skip "complete `<!doctype html>` document at 1920×1080 with inlined CSS" is a non-starter. |
71 | 223 |
|
72 | | -1. Create `case-studies/<slug>/` with a `meta.json` and your HTML files. |
73 | | -2. Open a PR. Merge. |
74 | | -3. Deploy. |
| 224 | +## License |
75 | 225 |
|
76 | | -That's it. |
| 226 | +MIT. |
0 commit comments