Internal documentation for the project codebase
Thistle is a data storytelling platform that transforms raw data into interactive, scroll-driven visual narratives. Think NYT or The Pudding — but anyone can build one, no-code, with live-updating data.
npm install
npx prisma db push
npm run devThe dev server starts on localhost:3003 (configured in package.json scripts). Turbopack is the default bundler.
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/thistle?schema=public"
SESSION_SECRET="change-this-to-a-random-32-char-string-in-production"
NEXT_PUBLIC_SOLANA_NETWORK="devnet"DATABASE_URL — PostgreSQL connection string. SESSION_SECRET — used by iron-session for encrypted cookies (min 32 chars). NEXT_PUBLIC_SOLANA_NETWORK — either devnet or mainnet-beta.
npm run build # Production build (19 routes)
npm run start # Start production server on :3003
npm run lint # ESLint
npx prisma studio # Database GUI
npx prisma generate # Regenerate Prisma clientThistle is a monolithic Next.js application. Server components handle data fetching and SSR, client components handle interactivity. There is no separate backend — API routes live inside the Next.js app.
/Landing page (SSR)
/s/[slug]Published stories (ISR, 1h)
/docsThis documentation
/appHub / Dashboard
/app/storiesMy Stories gallery
/app/createData source picker
/app/create/[id]Story editor
/app/preview/[id]Story preview
/api/auth/*4 endpoints — SIWS auth
/api/stories/*CRUD + publish
/api/data-sources/*Create + preview
/api/public-datasets6 hardcoded datasets
src/ ├── app/ │ ├── globals.css ← Theme tokens, animations, base styles │ ├── layout.tsx ← Root: Fraunces + Outfit fonts, metadata │ ├── page.tsx ← Landing page composition │ │ │ ├── docs/page.tsx ← This documentation page │ │ │ ├── api/ │ │ ├── auth/ │ │ │ ├── nonce/route.ts ← GET: generate signing nonce │ │ │ ├── verify/route.ts ← POST: verify signature, create session │ │ │ ├── me/route.ts ← GET: current user │ │ │ └── logout/route.ts ← POST: destroy session │ │ ├── stories/ │ │ │ ├── route.ts ← GET list, POST create │ │ │ ├── [id]/route.ts ← GET, PUT, DELETE │ │ │ └── [id]/publish/route.ts ← POST: publish + generate slug │ │ ├── data-sources/ │ │ │ ├── route.ts ← GET list, POST create │ │ │ └── preview/route.ts ← POST: fetch external JSON API │ │ └── public-datasets/route.ts ← GET: 6 hardcoded datasets │ │ │ ├── app/ ← Authenticated app shell │ │ ├── layout.tsx ← Sidebar, providers, tutorial tooltips │ │ ├── page.tsx ← Hub with greeting, featured, quick actions │ │ ├── stories/page.tsx ← My Stories gallery │ │ ├── create/page.tsx ← Data source picker (3 tabs) │ │ ├── create/[storyId]/page.tsx ← Block editor with drag-and-drop │ │ └── preview/[storyId]/page.tsx ← Story preview │ │ │ └── s/[slug]/ │ ├── page.tsx ← Published reader (ISR, 1h revalidation) │ └── opengraph-image.tsx ← Dynamic OG image (1200x630) │ ├── components/ │ ├── ThistleMark.tsx ← SVG thistle bloom mark │ ├── StoryReader.tsx ← Published story renderer with scroll reveals │ ├── blocks/ │ │ ├── BlockRenderer.tsx ← Block type switch │ │ ├── TextBlockEditor.tsx ← Tiptap rich text │ │ ├── ChartBlockEditor.tsx ← Recharts (line/bar/area/scatter) │ │ ├── HighlightBlockEditor.tsx ← KPI card (value + delta) │ │ └── DividerBlockEditor.tsx ← Visual separator │ ├── landing/ │ │ ├── Navigation.tsx ← Fixed header, scroll-aware blur │ │ ├── Hero.tsx ← 6-layer atmospheric hero │ │ ├── Showcase.tsx ← Interactive browser mockup with chart │ │ ├── HowItWorks.tsx ← 3-step process with SVG illustrations │ │ ├── ClosingCTA.tsx ← Gradient text CTA │ │ └── Footer.tsx ← Brand + mascot + links │ ├── app/ │ │ ├── AuthOverlay.tsx ← Frosted auth flow overlay │ │ ├── ConnectButton.tsx ← Wallet connect trigger │ │ ├── HubGreeting.tsx ← Auth-aware welcome message │ │ ├── HubFeatured.tsx ← Story card grid │ │ ├── HubQuickActions.tsx ← Data source shortcut cards │ │ ├── MiniChart.tsx ← SVG sparkline for story cards │ │ ├── SandboxBar.tsx ← Demo mode indicator │ │ ├── StoryCard.tsx ← Rich story preview card │ │ ├── TutorialProvider.tsx ← Tutorial state machine (localStorage) │ │ └── TutorialTooltip.tsx ← Positioned glassmorphism tooltip │ └── providers/ │ └── SolanaProvider.tsx ← Connection + wallet + modal providers │ ├── hooks/ │ ├── useAuth.ts ← Wallet + session + SIWS flow │ ├── useInView.ts ← IntersectionObserver for scroll reveals │ └── useIsMounted.ts ← Hydration safety check │ ├── lib/ │ ├── auth.ts ← iron-session config, getSession, requireAuth │ ├── prisma.ts ← Prisma client singleton (PrismaPg adapter) │ ├── blocks.ts ← Block type definitions + createBlock factory │ └── demo-stories.ts ← 3 demo stories with chart data │ └── generated/prisma/ ← Auto-generated Prisma client (gitignored)
Five models in PostgreSQL via Prisma 7 with the @prisma/adapter-pg driver adapter. Schema lives in prisma/schema.prisma, client generates to src/generated/prisma/.
model User {
id String @id @default(cuid())
walletAddress String @unique
displayName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
dataSources DataSource[]
stories Story[]
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model DataSource {
id String @id @default(cuid())
userId String
type DataSourceType // API | CSV | PUBLIC
name String
config Json
cachedData Json?
lastFetched DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stories StoryDataSource[]
}
model Story {
id String @id @default(cuid())
userId String
title String @default("Untitled Story")
slug String? @unique
blocks Json @default("[]")
published Boolean @default(false)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
dataSources StoryDataSource[]
}
model StoryDataSource { // many-to-many junction
storyId String
dataSourceId String
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
dataSource DataSource @relation(fields: [dataSourceId], references: [id], onDelete: Cascade)
@@id([storyId, dataSourceId])
}Prisma 7 note: This project uses prisma.config.ts (not the old prisma/schema.prisma datasource block alone). The config imports dotenv/config for env loading. Run npx prisma db push to sync schema, npx prisma generate to regenerate the client.
| Method | Path | Description |
|---|---|---|
| GET | /api/auth/nonce | Generate random hex nonce, store in session |
| POST | /api/auth/verify | Verify wallet signature (tweetnacl), upsert user, save session |
| GET | /api/auth/me | Return current authenticated user from session |
| POST | /api/auth/logout | Destroy session cookie |
| Method | Path | Description |
|---|---|---|
| GET | /api/stories | List all stories for authenticated user |
| POST | /api/stories | Create new story (default title: "Untitled Story") |
| GET | /api/stories/[id] | Fetch single story with linked data sources |
| PUT | /api/stories/[id] | Update title and/or blocks (auto-save target) |
| DELETE | /api/stories/[id] | Delete story permanently |
| POST | /api/stories/[id]/publish | Set published=true, generate slug, set publishedAt |
| Method | Path | Description |
|---|---|---|
| GET | /api/data-sources | List user's data sources |
| POST | /api/data-sources | Create data source (type: API | CSV | PUBLIC) |
| POST | /api/data-sources/preview | Server-side fetch of external JSON API (CORS bypass, 10s timeout) |
| GET | /api/public-datasets | Return 6 hardcoded public datasets (World Bank, CoinGecko, OpenWeatherMap) |
Auth pattern: All story and data source routes use requireAuth() from src/lib/auth.ts, which reads the iron-session cookie and throws if no userId is found. Returns 401 on failure.
Sign-In With Solana (SIWS) — the user proves wallet ownership by signing a message. No passwords, no emails.
User clicks ConnectButton, which opens the Solana wallet adapter modal. Phantom and Solflare are the configured adapters.
Frontend calls GET /api/auth/nonce. Server generates a random 32-byte hex string and stores it in the iron-session cookie.
Frontend asks wallet to sign: "Sign in to Thistle\n\nNonce: {nonce}". Signature is bs58-encoded.
Frontend POSTs { publicKey, signature, message } to /api/auth/verify. Server uses tweetnacl to verify the detached signature against the public key bytes.
Server upserts a User by walletAddress, sets session.userId and session.walletAddress, clears the nonce. iron-session encrypts everything into a cookie (7-day maxAge, httpOnly, sameSite: lax).
The src/hooks/useAuth.ts hook wraps everything into one interface. It combines useWallet() from the Solana adapter with SWR polling of /api/auth/me.
{
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
connected: boolean;
publicKey: PublicKey | null;
authenticate: () => Promise<void>; // full nonce → sign → verify flow
disconnect: () => Promise<void>; // logout API + wallet disconnect
}Defined as --color-* tokens in globals.css via @theme inline. Use as Tailwind utilities: bg-void, text-thistle-bright, border-bloom/20, etc.
Fraunces
Variable serif. Headings, titles, brand. Use via font-display.
Outfit
Geometric sans-serif. Body text, UI, labels. Use via font-body.
bg-night/60 backdrop-blur-xl border border-white/[0.06]<div className="absolute inset-0 page-atmosphere" />
<div className="absolute inset-0 grain pointer-events-none" />style={{ background: 'linear-gradient(135deg, #a8568e, #d882bb)' }}cubic-bezier(0.25, 0.1, 0, 1)Stories are composed of blocks, stored as a JSON array in the blocks column. Four block types exist. Each has an editor component (for the create page) and a read-only renderer (for preview and published view).
textRich text via Tiptap (StarterKit + Placeholder). Stores HTML string.
TextBlockEditor.tsx
chartRecharts visualization. Supports line, bar, area, scatter. Stores chart config + inline data.
ChartBlockEditor.tsx
highlightKPI card with value, label, and optional delta with direction arrow.
HighlightBlockEditor.tsx
dividerVisual separator. No configuration needed.
DividerBlockEditor.tsx
type BlockType = 'text' | 'chart' | 'highlight' | 'divider';
interface TextBlock {
id: string; type: 'text';
content: string; // HTML from Tiptap
}
interface ChartBlock {
id: string; type: 'chart';
chartType: 'line' | 'bar' | 'area' | 'scatter';
dataSourceId?: string;
dataKey: string; // y-axis field
xKey: string; // x-axis field
title?: string;
color?: string;
data?: Record<string, unknown>[];
}
interface HighlightBlock {
id: string; type: 'highlight';
label: string; // e.g. "Temperature Rise"
value: string; // e.g. "+1.2°C"
delta?: string; // e.g. "+0.3°C"
deltaDirection?: 'up' | 'down';
}
interface DividerBlock {
id: string; type: 'divider';
}Rendering pipeline: BlockRenderer is the central switch. It receives a StoryBlock and a readOnly flag, then delegates to the appropriate editor component. In readOnly mode, text blocks use dangerouslySetInnerHTML instead of instantiating Tiptap (to avoid SSR issues).
Three hardcoded stories live in src/lib/demo-stories.ts for the sandbox experience. IDs are prefixed with demo- (e.g., demo-warming, demo-solana, demo-population). The editor and preview pages detect this prefix to enable sandbox mode — local-only edits, no API calls.
Things that will bite you if you don't know about them.
framer-motion v12 does not hydrate with React 19 + Next.js 16. Elements get stuck at their initial animation state forever. The entire project uses pure CSS @keyframes with animation-delay and animation-fill-mode: both. Scroll reveals use a custom useInView hook (IntersectionObserver). Do not add framer-motion to any component.
Never write unlayered CSS resets like * { margin: 0 }. They override Tailwind v4's layered utilities. Tailwind v4 preflight handles resets. Custom base styles go in @layer base {}, component styles in @layer components {}. Color tokens: --color-NAME in @theme inline gives bg-NAME, text-NAME, etc.
The useEditor hook must never run during SSR. TextBlockEditor splits into two components: TextEditor (with useEditor + immediatelyRender: false) is only mounted when readOnly is false. In readOnly mode, it uses dangerouslySetInnerHTML. If you create new rich text components, follow this same pattern.
Solana wallet adapters cause hydration mismatches with React 19. SolanaProvider uses the useIsMounted hook and returns null until client-side mounted. Any component using useWallet must be inside SolanaProvider and should handle the unmounted state.
This project uses prisma.config.ts at the root (not the old prisma block in schema.prisma). It imports dotenv/config and uses defineConfig with the PrismaPg adapter. The generated client lives in src/generated/prisma/ (gitignored). Run npx prisma generate after any schema change.
The dev server runs on port 3003, not the default 3000. This is configured in the package.json dev and start scripts. If you see EADDRINUSE, check for lingering processes on :3003.
Story IDs starting with demo- are treated as sandbox stories. The editor skips API save calls, the preview loads from demo-stories.ts instead of the database. If you add new demo stories, keep this prefix convention.
The design convention uses weight="duotone" for active/emphasized icons and weight="regular" for inactive states. The icon set is @phosphor-icons/react. Some icon names differ from other libraries — always check the Phosphor docs for the exact export name.