Thistle

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.

FrameworkNext.js 16
DatabasePostgreSQL
ORMPrisma 7
AuthSolana SIWS
React 19Tailwind CSS 4TiptapRechartsdnd-kitSWRiron-sessionPhosphor Icons

Quick Start

1. Prerequisites

  • Node.js 18+ and npm
  • PostgreSQL running locally (port 5432)
  • A Solana wallet browser extension (Phantom or Solflare) for auth testing

2. Install and run

terminalbash
npm install
npx prisma db push
npm run dev

The dev server starts on localhost:3003 (configured in package.json scripts). Turbopack is the default bundler.

3. Environment variables

.envenv
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.

4. Other commands

terminalbash
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 client

Architecture

Thistle 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.

Public pages
/

Landing page (SSR)

/s/[slug]

Published stories (ISR, 1h)

/docs

This documentation

App (authenticated)
/app

Hub / Dashboard

/app/stories

My Stories gallery

/app/create

Data source picker

/app/create/[id]

Story editor

/app/preview/[id]

Story preview

API layer
/api/auth/*

4 endpoints — SIWS auth

/api/stories/*

CRUD + publish

/api/data-sources/*

Create + preview

/api/public-datasets

6 hardcoded datasets

/app layout providers
SolanaProviderTutorialProviderAppShell (sidebar + header)

File Map

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)

Data Model

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/.

prisma/schema.prismaprisma
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.

API Routes

Authentication

MethodPathDescription
GET/api/auth/nonceGenerate random hex nonce, store in session
POST/api/auth/verifyVerify wallet signature (tweetnacl), upsert user, save session
GET/api/auth/meReturn current authenticated user from session
POST/api/auth/logoutDestroy session cookie

Stories

MethodPathDescription
GET/api/storiesList all stories for authenticated user
POST/api/storiesCreate 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]/publishSet published=true, generate slug, set publishedAt

Data Sources

MethodPathDescription
GET/api/data-sourcesList user's data sources
POST/api/data-sourcesCreate data source (type: API | CSV | PUBLIC)
POST/api/data-sources/previewServer-side fetch of external JSON API (CORS bypass, 10s timeout)
GET/api/public-datasetsReturn 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.

Auth Flow

Sign-In With Solana (SIWS) — the user proves wallet ownership by signing a message. No passwords, no emails.

1

Connect wallet

User clicks ConnectButton, which opens the Solana wallet adapter modal. Phantom and Solflare are the configured adapters.

2

Request nonce

Frontend calls GET /api/auth/nonce. Server generates a random 32-byte hex string and stores it in the iron-session cookie.

3

Sign message

Frontend asks wallet to sign: "Sign in to Thistle\n\nNonce: {nonce}". Signature is bs58-encoded.

4

Verify

Frontend POSTs { publicKey, signature, message } to /api/auth/verify. Server uses tweetnacl to verify the detached signature against the public key bytes.

5

Session created

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).

useAuth hook

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.

src/hooks/useAuth.ts — return typets
{
  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
}

Design System

Color palette

Defined as --color-* tokens in globals.css via @theme inline. Use as Tailwind utilities: bg-void, text-thistle-bright, border-bloom/20, etc.

void#0c0810
night#150f1a
dusk#1e1628
mist#2d2339
thistle#c46fa8
thistle-bright#d882bb
thistle-dim#8b5578
bloom#d4a574
sage#7da07d
ink#e8dfd0
parchment#f5efe5
charcoal#2a1f25

Typography

Display

Fraunces

Variable serif. Headings, titles, brand. Use via font-display.

Body

Outfit

Geometric sans-serif. Body text, UI, labels. Use via font-body.

Common patterns

Glassmorphism card
bg-night/60 backdrop-blur-xl border border-white/[0.06]
Page atmosphere
<div className="absolute inset-0 page-atmosphere" />
<div className="absolute inset-0 grain pointer-events-none" />
CTA button
style={{ background: 'linear-gradient(135deg, #a8568e, #d882bb)' }}
Easing curve (used everywhere)
cubic-bezier(0.25, 0.1, 0, 1)

Block System

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).

text

Rich text via Tiptap (StarterKit + Placeholder). Stores HTML string.

TextBlockEditor.tsx

chart

Recharts visualization. Supports line, bar, area, scatter. Stores chart config + inline data.

ChartBlockEditor.tsx

highlight

KPI card with value, label, and optional delta with direction arrow.

HighlightBlockEditor.tsx

divider

Visual separator. No configuration needed.

DividerBlockEditor.tsx

Type definitions

src/lib/blocks.tsts
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).

Demo stories

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.

Gotchas

Things that will bite you if you don't know about them.

framer-motion does not work

critical

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.

Tailwind v4 CSS layer rules

critical

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.

Tiptap SSR hydration

high

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 hydration

high

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.

Prisma 7 config file

medium

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.

Dev server port

low

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.

Demo story ID convention

medium

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.

Phosphor Icons — duotone vs regular

low

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.