dillinger Documentation

The last Markdown editor, ever.

8,292 stars TypeScript TypeScript 12 files ยท ~24,665 tokens #blog#blogging#editor#ide#javascript#markdown#md#node
RAW Doc

Technical Documentation: joemccann/dillinger

โ„น๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference

Overview (`README.md`)

Dillinger


โœจ Why Dillinger

Most markdown editors are either:

  • bloated
  • offline-only
  • or lack serious developer ergonomics

Dillinger fixes that.

It combines:

  • โšก VS Code-grade editing
  • โ˜๏ธ Native cloud storage
  • ๐Ÿง˜ Distraction-free UX
  • ๐Ÿ”„ Real-time rendering

โšก Quick Start

bash
npm install
npm run dev

Open โ†’ http://localhost:3000


๐Ÿงฑ Requirements

  • Node.js 18+
  • npm / yarn / pnpm / bun

๐Ÿš€ Features

Editor Experience

  • Monaco Editor (VS Code core)
  • Syntax highlighting for Markdown
  • Image paste support

Live Workflow

  • Real-time preview
  • Scroll sync
  • Instant feedback loop

Focus Mode

  • Zen Mode (Cmd/Ctrl + Shift + Z)
  • Fullscreen, distraction-free writing

File Handling

  • Drag & drop .md, .txt, .markdown
  • Auto-save (local persistence)

Export

  • Markdown
  • HTML
  • PDF

Cloud Integrations

  • GitHub
  • Google Drive
  • Dropbox
  • OneDrive
  • Bitbucket

๐Ÿ” Environment Setup

Create:

bash
.env.local

Template

bash
# Google Drive
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/api/google-drive/callback

# OneDrive
ONEDRIVE_CLIENT_ID=
ONEDRIVE_CLIENT_SECRET=
ONEDRIVE_REDIRECT_URI=http://localhost:3000/api/onedrive/callback

# Bitbucket
BITBUCKET_CLIENT_ID=
BITBUCKET_CLIENT_SECRET=
BITBUCKET_REDIRECT_URI=http://localhost:3000/api/bitbucket/callback

# GitHub
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_REDIRECT_URI=http://localhost:3000/api/github/callback

# Dropbox
DROPBOX_CLIENT_ID=
DROPBOX_CLIENT_SECRET=
DROPBOX_REDIRECT_URI=http://localhost:3000/api/dropbox/callback

โ˜๏ธ Cloud Integrations

Each provider requires OAuth configuration.

Google Drive

  • Enable Drive API
  • Create OAuth credentials
  • Redirect:
text
/api/google-drive/callback

OneDrive

  • Azure App Registration

  • Permissions:

    • Files.ReadWrite
    • User.Read

Bitbucket

  • OAuth consumer
  • Repo read/write access

GitHub

  • OAuth App
  • Callback:
text
/api/github/callback

Dropbox

  • Scoped app

  • Enable:

    • files.metadata.read
    • files.content.write

๐Ÿšข Deployment

Update all OAuth callbacks:

bash
https://yourdomain.com/api/{provider}/callback

Then:

bash
npm run build
npm start

๐Ÿ— Tech Stack

Layer Technology
Framework Next.js 14
Editor Monaco
Styling Tailwind CSS
State Zustand
Icons Lucide

๐Ÿ“ฆ Scripts

bash
npm run dev              # Start dev server
npm run build            # Production build
npm start                # Start production server
npm run lint             # ESLint
npm run test             # Unit + E2E tests
npm run test:unit        # Vitest unit/integration tests
npm run test:watch       # Vitest watch mode
npm run test:e2e         # Playwright E2E tests
npm run test:e2e:headed  # E2E in visible browser
npm run verify           # Lint + typecheck + all tests

๐Ÿงช Testing

294 unit/integration tests (Vitest) + 39 E2E tests (Playwright) with 98% code coverage.

bash
npm run test:unit                # Run all unit tests
npx vitest run --coverage        # With coverage report
npm run test:e2e:headed          # E2E in visible browser

Tests live in tests/ organized by type: lib/, store/, hooks/, components/, routes/, and e2e/.


๐Ÿงญ Philosophy

Dillinger is built around a simple idea:

Writing tools should disappear.

No friction.
No clutter.
Just flow.


๐Ÿค Contributing

PRs welcome. Open an issue first for major changes.


๐Ÿ“„ License

MIT


Overview (`packages/cli/README.md`)

@dillinger/cli

Command-line interface for Dillinger markdown editor.

Install

bash
npm install @dillinger/cli

Setup

bash
export DILLINGER_API_KEY="your-api-key"

Usage

bash
# Render markdown to HTML
dlg render README.md

# Export to PDF
dlg export README.md --format pdf -o output.pdf

# Export to styled HTML
dlg export README.md --format html

# Convert HTML to markdown
dlg convert page.html

# Pipe from stdin
cat README.md | dlg render
echo "<h1>Hello</h1>" | dlg convert

Overview (`packages/mcp/README.md`)

@dillinger/mcp

MCP (Model Context Protocol) server for Dillinger. Lets LLMs use Dillinger as a native markdown tool.

Setup

bash
npm install
npm run build

Usage with Claude Desktop

Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):

json
{
  "mcpServers": {
    "dillinger": {
      "command": "node",
      "args": ["/path/to/packages/mcp/dist/index.js"],
      "env": {
        "DILLINGER_API_KEY": "your-api-key",
        "DILLINGER_URL": "https://dillinger.io"
      }
    }
  }
}

Available Tools

Tool Description
render_markdown Markdown to HTML with full plugin support
export_pdf Markdown to PDF document
export_html Markdown to styled HTML document
convert_html_to_markdown HTML to clean markdown

AGENTS (`docs/AGENTS.md`)

Agent Learnings - Next.js Migration

This document captures key learnings and debugging insights from the Next.js migration project.


Vercel Deployment - Subdirectory Configuration

Problem: Vercel was deploying the old Express.js app at the repository root instead of the Next.js app in next-app/ subdirectory.

Solution:

  1. Set Root Directory to next-app in Vercel Dashboard (Settings โ†’ General)
  2. Create vercel.json inside next-app/ directory (not repository root)
  3. Minimal config works best:
    json
    {
      "$schema": "https://openapi.vercel.sh/vercel.json",
      "framework": "nextjs"
    }

Key Insight: When Root Directory is set to a subdirectory, Vercel looks for vercel.json INSIDE that subdirectory, not at the repository root. The framework detection works automatically once the config file is in the right place.


Next.js API Routes - Static Generation Errors

Problem: Production build failed with errors like:

text
Dynamic server usage: Route /api/bitbucket/branches couldn't be rendered statically because it used `cookies`

Root Cause: Next.js tries to statically pre-render API routes during build by default. Routes that use cookies() or other dynamic server features must be explicitly marked as dynamic.

Solution: Add to the top of every API route file:

typescript
export const dynamic = "force-dynamic";

Files affected: All 45 API route files in:

  • app/api/google-drive/
  • app/api/onedrive/
  • app/api/github/
  • app/api/dropbox/
  • app/api/bitbucket/
  • app/api/medium/
  • app/api/export/
  • app/api/upload/

Implementation: Used bash script to add export to all route files:

bash
for file in $(find app/api -name "route.ts"); do
  echo 'export const dynamic = "force-dynamic";
' | cat - "$file" > /tmp/tempfile && mv /tmp/tempfile "$file"
done

Google Drive OAuth Scopes

Problem 1: Files API returned empty array even though user had .md files in Drive.

Root Cause: Using https://www.googleapis.com/auth/drive.file scope only grants access to files created by the app itself.

Solution: Changed to https://www.googleapis.com/auth/drive scope in app/api/google-drive/oauth/route.ts to access all user files.

Problem 2: Status endpoint returned 401 "missing authentication credential".

Root Cause: Missing openid and email scopes required for /oauth2/v2/userinfo endpoint.

Solution: Updated scope to:

typescript
scope: "openid email https://www.googleapis.com/auth/drive"

Google Drive - Preventing Duplicate Files

Problem: Saving a file with the same name created duplicates instead of overwriting.

Root Cause: Google Drive allows duplicate filenames by default. The save route only updated files when an explicit fileId was provided.

Solution: Modified app/api/google-drive/save/route.ts to:

  1. Check if file with same name exists in target folder
  2. If exists, use that file ID to update it
  3. If not, create new file

Implementation:

typescript
if (!targetFileId) {
  const query = `name = '${fileName}' and '${parentId}' in parents and trashed = false`;
  const searchResponse = await fetch(
    `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`,
    { headers: { Authorization: `Bearer ${accessToken}` } }
  );

  if (searchResponse.ok) {
    const searchData = await searchResponse.json();
    if (searchData.files?.length > 0) {
      targetFileId = searchData.files[0].id;
    }
  }
}

// Use PATCH if targetFileId exists, POST if creating new
const method = targetFileId ? "PATCH" : "POST";

OneDrive OAuth Fixes

Problem 1: OAuth callback failed with "invalid_request - redirect_uri mismatch" for consumer accounts.

Solution: Changed OAuth endpoint from /common/ to /consumers/ in app/api/onedrive/oauth/route.ts:

typescript
const authUrl = `https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?${params}`;

Problem 2: Graph API returned 401 Unauthorized.

Solution: Added User.Read scope:

typescript
scope: "User.Read Files.ReadWrite.All offline_access"

Problem 3: Files API returned "Operation not supported" when using $filter.

Solution: Removed $filter query parameter (not supported on personal OneDrive) and implemented server-side filtering:

typescript
const files = data.value.filter((item) => {
  return item.folder || item.name.toLowerCase().endsWith('.md');
});

Bitbucket OAuth Fixes

Problem 1: Token exchange failed with "redirect_uri does not match".

Solution: Added redirect_uri parameter to token exchange POST body in app/api/bitbucket/callback/route.ts.

Problem 2: API returned 403 "credentials lack required privilege scopes".

Solution: Added account and repository scopes:

typescript
scope: "account repository"

Note: User must also enable these permissions in Bitbucket OAuth app settings.

Problem 3: Files API returned "Resource not found" when listing directory contents.

Solution: Added trailing slash to directory paths:

typescript
const apiPath = path ? `/${path}/` : "/";
const url = `https://api.bitbucket.org/2.0/repositories/${workspace}/${repo}/src/${branch}${apiPath}`;

React SSR/Hydration Issues

Problem: Cannot read properties of null (reading 'useContext') error during build.

Root Cause: StoreProvider was calling useStore() hook during render, creating closure issues between server and client.

Solution: Changed to use useStore.getState() in useEffect instead:

typescript
const hasHydrated = useRef(false);

useEffect(() => {
  if (!hasHydrated.current) {
    useStore.getState().hydrate();
    hasHydrated.current = true;
  }
}, []);

Scroll Sync Feature

Problem: Scroll sync toggle didn't work - enabling it didn't sync scrolling.

Root Cause: Scroll listener was set up in handleMount callback, which captured stale settings value in closure.

Solution: Moved listener setup to useEffect with settings.enableScrollSync dependency in components/editor/MonacoEditor.tsx:

typescript
useEffect(() => {
  const editor = editorRef.current;
  if (!editor) return;

  const disposable = editor.onDidScrollChange(() => {
    if (settings.enableScrollSync) {
      const scrollTop = editor.getScrollTop();
      const scrollHeight = editor.getScrollHeight() - editor.getLayoutInfo().height;
      const percent = scrollHeight > 0 ? scrollTop / scrollHeight : 0;
      setEditorScrollPercent(percent);
    }
  });

  return () => disposable.dispose();
}, [settings.enableScrollSync, setEditorScrollPercent]);

Dark Mode Preview

Problem: Night mode only affected Monaco editor, not the markdown preview panel.

Solution: Added conditional dark class to preview in components/preview/MarkdownPreview.tsx:

typescript
className={`preview-html h-full overflow-auto p-6 ${
  settings.enableNightMode ? 'dark bg-[#1e1e1e]' : 'bg-bg-primary'
}`}

And added comprehensive dark mode CSS to app/globals.css:

css
.dark.preview-html {
  color: #d4d4d4;
}

.dark.preview-html code {
  background: #2d2d2d;
  color: #d4d4d4;
}

.dark.preview-html pre {
  background: #2d2d2d;
}
/* ... more dark mode styles */

Production Deployment Checklist

Created comprehensive checklist at docs/PRODUCTION-OAUTH-CHECKLIST.md covering:

  • All 6 OAuth service configurations
  • Redirect URI updates for production domain
  • Environment variable setup in Vercel
  • Testing checklist after deployment

Key Reminder: Most OAuth apps allow multiple redirect URIs, so you can add production URLs without removing localhost for local development.


Common Patterns

OAuth Scope Issues

  • Always check provider documentation for required scopes
  • drive.file vs drive - understand access levels
  • Some APIs require openid and email for userinfo endpoints
  • Personal vs organizational accounts may have different requirements

API Route Best Practices

  • Mark all routes using cookies() as force-dynamic
  • Use server-side filtering when provider API doesn't support it
  • Always include redirect_uri in OAuth token exchange
  • Handle trailing slashes carefully in REST APIs

Vercel Subdirectory Deployment

  1. Set Root Directory in dashboard
  2. Put vercel.json in that subdirectory
  3. Let framework auto-detection work
  4. Avoid custom build commands unless necessary

References


CLAUDE (`docs/CLAUDE.md`)

# Recent Activity

No recent activity


PRODUCTION OAUTH CHECKLIST (`docs/PRODUCTION-OAUTH-CHECKLIST.md`)

Production OAuth Configuration Checklist

Before deploying the Next.js app to production, update ALL OAuth applications with production URLs.

Production URL Format

Your production redirect URIs will be:

  • Base URL: https://your-production-domain.com (or https://dillinger.vercel.app)
  • Callback pattern: https://your-production-domain.com/api/{service}/callback

Google Drive

  • Go to Google Cloud Console
  • Select your OAuth 2.0 Client ID
  • Add production Authorized redirect URI:
    text
    https://your-production-domain.com/api/google-drive/callback
  • Update Vercel environment variables:
    text
    GOOGLE_CLIENT_ID=your_client_id
    GOOGLE_CLIENT_SECRET=your_client_secret
    NEXT_PUBLIC_APP_URL=https://your-production-domain.com

OneDrive (Microsoft)

  • Go to Azure Portal
  • Select your app registration
  • Go to "Authentication" โ†’ "Add a platform" โ†’ "Web"
  • Add production redirect URI:
    text
    https://your-production-domain.com/api/onedrive/callback
  • Update Vercel environment variables:
    text
    ONEDRIVE_CLIENT_ID=your_client_id
    ONEDRIVE_CLIENT_SECRET=your_client_secret
    NEXT_PUBLIC_APP_URL=https://your-production-domain.com

GitHub

  • Go to GitHub Developer Settings
  • Select your OAuth App (or create new one for production)
  • Update Authorization callback URL:
    text
    https://your-production-domain.com/api/github/callback
  • Update Vercel environment variables:
    text
    GITHUB_CLIENT_ID=your_client_id
    GITHUB_CLIENT_SECRET=your_client_secret
    NEXT_PUBLIC_APP_URL=https://your-production-domain.com

Dropbox

  • Go to Dropbox App Console
  • Select your app
  • Under "OAuth 2" โ†’ "Redirect URIs", add:
    text
    https://your-production-domain.com/api/dropbox/callback
  • Update Vercel environment variables:
    text
    DROPBOX_APP_KEY=your_app_key
    DROPBOX_APP_SECRET=your_app_secret
    NEXT_PUBLIC_APP_URL=https://your-production-domain.com

Bitbucket

  • Go to Bitbucket OAuth Consumers
  • Select your OAuth consumer
  • Update Callback URL:
    text
    https://your-production-domain.com/api/bitbucket/callback
  • Update Vercel environment variables:
    text
    BITBUCKET_CLIENT_ID=your_client_id
    BITBUCKET_CLIENT_SECRET=your_client_secret
    NEXT_PUBLIC_APP_URL=https://your-production-domain.com

Vercel Environment Variables

In Vercel dashboard, set all environment variables for Production environment:

bash
# Google Drive
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# OneDrive
ONEDRIVE_CLIENT_ID=
ONEDRIVE_CLIENT_SECRET=

# GitHub
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

# Dropbox
DROPBOX_APP_KEY=
DROPBOX_APP_SECRET=

# Bitbucket
BITBUCKET_CLIENT_ID=
BITBUCKET_CLIENT_SECRET=

# App URL (CRITICAL - used for all OAuth redirects)
NEXT_PUBLIC_APP_URL=https://your-production-domain.com

# Node Environment
NODE_ENV=production

Testing Checklist (After Production Deploy)

After updating all OAuth apps and deploying to production:

  • Test Google Drive: Connect โ†’ List Files โ†’ Save File โ†’ Import File
  • Test OneDrive: Connect โ†’ List Files โ†’ Save File โ†’ Import File
  • Test GitHub: Connect โ†’ List Repos โ†’ List Files โ†’ Save File
  • Test Dropbox: Connect โ†’ List Files โ†’ Save File โ†’ Import File
  • Test Bitbucket: Connect โ†’ List Repos โ†’ List Files โ†’ Save File
  • Test all export formats: HTML, Markdown, PDF
  • Test drag & drop file import
  • Test image upload
  • Test zen mode toggle
  • Test scroll sync toggle
  • Test night mode toggle

Important Notes

  1. NEXT_PUBLIC_APP_URL is critical - All OAuth callbacks use this to construct redirect URIs
  2. You can keep localhost URLs in OAuth apps for local development alongside production URLs
  3. OAuth apps allow multiple redirect URIs - add production without removing localhost
  4. Some providers require review - If you added new scopes, some OAuth apps may need re-approval
  5. Clear cookies - After deploying, users may need to reconnect their accounts with new tokens

Quick Reference: All Callback URLs

text
https://your-production-domain.com/api/google-drive/callback
https://your-production-domain.com/api/onedrive/callback
https://your-production-domain.com/api/github/callback
https://your-production-domain.com/api/dropbox/callback
https://your-production-domain.com/api/bitbucket/callback

Plans 2026 01 19 Nextjs Migration Design (`docs/plans/2026-01-19-nextjs-migration-design.md`)

Dillinger Next.js Migration Design

Date: 2026-01-19
Status: Approved
Scope: Full stack migration from Angular.js to Next.js App Router


Overview

Migrate the Dillinger markdown editor from Angular.js 1.8.3 to Next.js 14 with App Router, maintaining 100% functional parity and visual consistency.

Key Decisions

Decision Choice Rationale
Framework Next.js 14 (App Router) Modern React, Vercel-optimized, API routes
Editor Monaco Editor Better maintained than Ace, VS Code familiar
Persistence localStorage only Matches original, no database needed
Cloud integrations GitHub + Dropbox initially Core services first, pattern for others
Markdown markdown-it + plugins Identical rendering output
Styling Tailwind CSS Design tokens extracted from SCSS
State Zustand Simple, performant, localStorage middleware
Export Server-side PDF md-to-pdf via API routes

Project Structure

text
dillinger-next/
โ”œโ”€โ”€ app/
โ”‚   โ”œโ”€โ”€ layout.tsx              # Root layout with providers
โ”‚   โ”œโ”€โ”€ page.tsx                # Main editor page
โ”‚   โ”œโ”€โ”€ globals.css             # Tailwind + custom CSS
โ”‚   โ””โ”€โ”€ api/
โ”‚       โ”œโ”€โ”€ export/
โ”‚       โ”‚   โ”œโ”€โ”€ html/route.ts
โ”‚       โ”‚   โ”œโ”€โ”€ pdf/route.ts
โ”‚       โ”‚   โ””โ”€โ”€ markdown/route.ts
โ”‚       โ”œโ”€โ”€ convert/
โ”‚       โ”‚   โ””โ”€โ”€ html-to-md/route.ts
โ”‚       โ”œโ”€โ”€ github/
โ”‚       โ”‚   โ”œโ”€โ”€ oauth/route.ts
โ”‚       โ”‚   โ”œโ”€โ”€ callback/route.ts
โ”‚       โ”‚   โ”œโ”€โ”€ orgs/route.ts
โ”‚       โ”‚   โ”œโ”€โ”€ repos/route.ts
โ”‚       โ”‚   โ”œโ”€โ”€ branches/route.ts
โ”‚       โ”‚   โ”œโ”€โ”€ files/route.ts
โ”‚       โ”‚   โ””โ”€โ”€ save/route.ts
โ”‚       โ””โ”€โ”€ dropbox/
โ”‚           โ”œโ”€โ”€ oauth/route.ts
โ”‚           โ”œโ”€โ”€ callback/route.ts
โ”‚           โ”œโ”€โ”€ files/route.ts
โ”‚           โ””โ”€โ”€ save/route.ts
โ”œโ”€โ”€ components/
โ”‚   โ”œโ”€โ”€ editor/
โ”‚   โ”‚   โ”œโ”€โ”€ MonacoEditor.tsx
โ”‚   โ”‚   โ””โ”€โ”€ EditorToolbar.tsx
โ”‚   โ”œโ”€โ”€ preview/
โ”‚   โ”‚   โ””โ”€โ”€ MarkdownPreview.tsx
โ”‚   โ”œโ”€โ”€ sidebar/
โ”‚   โ”‚   โ”œโ”€โ”€ Sidebar.tsx
โ”‚   โ”‚   โ””โ”€โ”€ DocumentList.tsx
โ”‚   โ”œโ”€โ”€ navbar/
โ”‚   โ”‚   โ””โ”€โ”€ Navbar.tsx
โ”‚   โ”œโ”€โ”€ modals/
โ”‚   โ”‚   โ”œโ”€โ”€ GitHubModal.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ DropboxModal.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ DeleteModal.tsx
โ”‚   โ”‚   โ””โ”€โ”€ SettingsModal.tsx
โ”‚   โ””โ”€โ”€ ui/
โ”‚       โ”œโ”€โ”€ Toast.tsx
โ”‚       โ”œโ”€โ”€ Button.tsx
โ”‚       โ”œโ”€โ”€ Modal.tsx
โ”‚       โ””โ”€โ”€ Switch.tsx
โ”œโ”€โ”€ hooks/
โ”‚   โ”œโ”€โ”€ useDocuments.ts
โ”‚   โ”œโ”€โ”€ useUserSettings.ts
โ”‚   โ”œโ”€โ”€ useGitHub.ts
โ”‚   โ”œโ”€โ”€ useDropbox.ts
โ”‚   โ””โ”€โ”€ useMarkdown.ts
โ”œโ”€โ”€ stores/
โ”‚   โ””โ”€โ”€ store.ts
โ”œโ”€โ”€ lib/
โ”‚   โ”œโ”€โ”€ markdown.ts
โ”‚   โ””โ”€โ”€ storage.ts
โ””โ”€โ”€ tailwind.config.ts

State Management

Zustand Store

typescript
interface Document {
  id: string;
  title: string;
  body: string;
  createdAt: Date;
  github?: {
    sha: string;
    path: string;
    repo: string;
    owner: string;
    branch: string;
  };
}

interface UserSettings {
  enableAutoSave: boolean;
  enableWordsCount: boolean;
  enableScrollSync: boolean;
  tabSize: number;
  keybindings: 'default' | 'vim' | 'emacs';
  enableNightMode: boolean;
}

interface AppState {
  documents: Document[];
  currentDocument: Document | null;
  settings: UserSettings;
  sidebarOpen: boolean;
  settingsOpen: boolean;
  previewVisible: boolean;
  zenMode: boolean;

  // Actions
  createDocument: () => void;
  selectDocument: (id: string) => void;
  deleteDocument: (id: string) => void;
  updateDocumentBody: (body: string) => void;
  updateDocumentTitle: (title: string) => void;
  updateSettings: (settings: Partial<UserSettings>) => void;
  hydrate: () => void;
  persist: () => void;
}

Persistence

  • hydrate() loads from localStorage on mount
  • persist() saves to localStorage (debounced 2 seconds)
  • Keys: files, currentDocument, profileV3 (compatible with original)

Tailwind Configuration

Design tokens extracted from legacy SCSS _config.scss:

typescript
const config: Config = {
  content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        plum: '#35D7BB',
        'bg-primary': '#ffffff',
        'bg-sidebar': '#2B2F36',
        'bg-navbar': '#373D49',
        'bg-highlight': '#1D212A',
        'bg-button-save': '#4A5261',
        'text-primary': '#373D49',
        'text-invert': '#ffffff',
        'text-muted': '#A0AABF',
        'border-light': '#E8E8E8',
        'border-settings': '#4F535B',
        'icon-default': '#D3DAEA',
        'dropdown-link': '#D0D6E2',
        'switchery': '#4B5363',
      },
      fontFamily: {
        sans: ['"Source Sans Pro"', 'sans-serif'],
        serif: ['Georgia', 'Cambria', 'serif'],
        mono: ['"Ubuntu Mono"', 'Monaco', 'monospace'],
      },
      spacing: {
        sidebar: '270px',
        gutter: '32px',
      },
      zIndex: {
        sidebar: '1',
        page: '2',
        editor: '3',
        preview: '4',
        overlay: '5',
        navbar: '6',
        settings: '7',
      },
    },
  },
};

Component Architecture

Main Page Layout

tsx
export default function EditorPage() {
  return (
    <div className="h-screen flex overflow-hidden">
      <Sidebar />
      <main className="flex-1 flex flex-col">
        <Navbar />
        <DocumentTitle />
        <div className="flex-1 flex">
          <ResizablePanel>
            <MonacoEditor />
          </ResizablePanel>
          <ResizablePanel>
            <MarkdownPreview />
          </ResizablePanel>
        </div>
      </main>
      <ModalProvider />
    </div>
  );
}

Component Types

All major components are Client Components ('use client') due to:

  • Monaco Editor (browser API)
  • localStorage access
  • Event handlers and interactivity

Monaco Editor Configuration

tsx
<Editor
  height="100%"
  language="markdown"
  theme={settings.enableNightMode ? 'vs-dark' : 'vs'}
  value={currentDocument?.body ?? ''}
  onChange={handleChange}
  options={{
    fontSize: 14,
    fontFamily: '"Ubuntu Mono", Monaco, monospace',
    lineHeight: 24,
    wordWrap: 'on',
    minimap: { enabled: false },
    lineNumbers: 'off',
    tabSize: settings.tabSize,
    scrollBeyondLastLine: false,
    automaticLayout: true,
  }}
/>

Keybindings

  • Default: Standard Monaco
  • Vim: monaco-vim package
  • Emacs: monaco-emacs package

Markdown Rendering

typescript
import MarkdownIt from 'markdown-it';
// + 8 plugins: abbr, checkbox, deflist, footnote, ins, mark, sub, sup, toc
// + highlight.js for code syntax
// + KaTeX for math

export const md = new MarkdownIt({
  html: true,
  linkify: true,
  typographer: true,
  highlight: (str, lang) => hljs.highlight(str, { language: lang }).value,
})
  .use(markdownItAbbr)
  .use(markdownItCheckbox)
  // ... all plugins

API Routes

Export Routes

Route Method Purpose
/api/export/pdf POST Generate PDF via md-to-pdf
/api/export/html POST Generate styled HTML
/api/export/markdown POST Return raw markdown
/api/convert/html-to-md POST Convert HTML to Markdown

GitHub Routes

Route Method Purpose
/api/github/oauth GET Initiate OAuth flow
/api/github/callback GET OAuth callback, set cookie
/api/github/orgs GET List user organizations
/api/github/repos GET List repositories
/api/github/branches GET List branches
/api/github/files POST List/fetch files
/api/github/save POST Save file to repo

Dropbox Routes

Same pattern as GitHub.

Token Storage

OAuth tokens stored in HTTP-only cookies:

  • Secure, not accessible to JavaScript
  • Automatically sent with API requests
  • No session database required

Notifications

Custom Toast context replacing Angular's diNotify:

tsx
const { notify } = useToast();
notify('Document saved successfully');
notify('Uploading...', 5000);  // Custom duration
notify('Processing...', 0);    // Persistent

Integration Hooks

typescript
function useGitHub() {
  return {
    isAuthenticated,
    orgs, repos, branches, files,
    current: { owner, repo, branch, path, sha },
    authenticate,
    fetchOrgs,
    fetchRepos,
    fetchBranches,
    fetchFiles,
    saveFile,
  };
}

Deferred Features

To be added in future iterations:

  • Google Drive integration
  • OneDrive integration
  • Medium integration
  • Bitbucket integration
  • Zen mode (fullscreen editing)
  • Image upload to Dropbox
  • Scroll sync between editor and preview

Dependencies

Production

json
{
  "next": "^14.0.0",
  "@monaco-editor/react": "^4.6.0",
  "markdown-it": "^14.0.0",
  "markdown-it-*": "various plugins",
  "highlight.js": "^11.0.0",
  "katex": "^0.16.0",
  "zustand": "^4.4.0",
  "lucide-react": "^0.300.0",
  "md-to-pdf": "^5.0.0",
  "breakdance": "^4.0.0"
}

Development

json
{
  "typescript": "^5.0.0",
  "tailwindcss": "^3.4.0",
  "@types/node": "^20.0.0",
  "@types/react": "^18.0.0"
}

Migration Compatibility

localStorage keys maintained for seamless migration:

  • files - Document array
  • currentDocument - Active document
  • profileV3 - User settings

Users' existing documents will be preserved when switching to the new app.


Plans 2026 01 19 Nextjs Migration Phase2 (`docs/plans/2026-01-19-nextjs-migration-phase2.md`)

Dillinger Next.js Migration - Phase 2: Cloud Integrations

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add GitHub and Dropbox OAuth integrations for importing and saving markdown files to cloud services.

Architecture: OAuth tokens stored in HTTP-only cookies, API routes handle all cloud service communication, React hooks manage client-side state for file browsing modals.

Tech Stack: Next.js API Routes, GitHub REST API, Dropbox SDK, HTTP-only cookies for token storage


Prerequisites

  • Phase 1 complete (core editor working)
  • Environment variables configured for OAuth:
    • GITHUB_CLIENT_ID
    • GITHUB_CLIENT_SECRET
    • DROPBOX_APP_KEY
    • DROPBOX_APP_SECRET
    • NEXT_PUBLIC_BASE_URL (e.g., http://localhost:3000)

Phase 2.1: Environment Setup

Task 2.1.1: Create Environment Configuration

Files:

  • Create: next-app/.env.local.example
  • Create: next-app/.env.local
  • Modify: next-app/.gitignore

Step 1: Create environment example file

Create next-app/.env.local.example:

bash
# GitHub OAuth
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret

# Dropbox OAuth
DROPBOX_APP_KEY=your_dropbox_app_key
DROPBOX_APP_SECRET=your_dropbox_app_secret

# App URL (for OAuth callbacks)
NEXT_PUBLIC_BASE_URL=http://localhost:3000

Step 2: Create actual .env.local (user fills in values)

bash
cp .env.local.example .env.local

User must fill in actual OAuth credentials.

Step 3: Ensure .env.local is gitignored

Verify .gitignore contains:

text
.env*.local

Step 4: Commit

bash
git add .env.local.example && git commit -m "docs: add environment variables example for OAuth"

Phase 2.2: GitHub Integration

Task 2.2.1: Create GitHub OAuth Routes

Files:

  • Create: next-app/app/api/github/oauth/route.ts
  • Create: next-app/app/api/github/callback/route.ts

Step 1: Create OAuth initiation route

Create next-app/app/api/github/oauth/route.ts:

typescript
import { NextResponse } from "next/server";

export async function GET() {
  const clientId = process.env.GITHUB_CLIENT_ID;
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;

  if (!clientId || !baseUrl) {
    return NextResponse.json(
      { error: "GitHub OAuth not configured" },
      { status: 500 }
    );
  }

  const redirectUri = `${baseUrl}/api/github/callback`;
  const scope = "repo,user";

  const authUrl = new URL("https://github.com/login/oauth/authorize");
  authUrl.searchParams.set("client_id", clientId);
  authUrl.searchParams.set("redirect_uri", redirectUri);
  authUrl.searchParams.set("scope", scope);

  return NextResponse.redirect(authUrl.toString());
}

Step 2: Create OAuth callback route

Create next-app/app/api/github/callback/route.ts:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 3: Commit

bash
git add app/api/github/oauth/route.ts app/api/github/callback/route.ts && git commit -m "feat: add GitHub OAuth routes"

Files:

  • Create: next-app/app/api/github/status/route.ts
  • Create: next-app/app/api/github/unlink/route.ts

Step 1: Create status check route

Create next-app/app/api/github/status/route.ts:

typescript
import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function GET() {
  const cookieStore = await cookies();
  const token = cookieStore.get("github_token")?.value;

  if (!token) {
    return NextResponse.json({ connected: false });
  }

  try {
    // Verify token is still valid by fetching user
    const response = await fetch("https://api.github.com/user", {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/vnd.github.v3+json",
      },
    });

    if (!response.ok) {
      return NextResponse.json({ connected: false });
    }

    const user = await response.json();

    return NextResponse.json({
      connected: true,
      user: {
        login: user.login,
        name: user.name,
        avatar_url: user.avatar_url,
      },
    });
  } catch (error) {
    return NextResponse.json({ connected: false });
  }
}

Step 2: Create unlink route

Create next-app/app/api/github/unlink/route.ts:

typescript
import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function POST() {
  const cookieStore = await cookies();
  cookieStore.delete("github_token");

  return NextResponse.json({ success: true });
}

Step 3: Commit

bash
git add app/api/github/status/route.ts app/api/github/unlink/route.ts && git commit -m "feat: add GitHub status and unlink routes"

Task 2.2.3: Create GitHub Data Routes

Files:

  • Create: next-app/app/api/github/orgs/route.ts
  • Create: next-app/app/api/github/repos/route.ts
  • Create: next-app/app/api/github/branches/route.ts
  • Create: next-app/app/api/github/files/route.ts

Step 1: Create orgs route

Create next-app/app/api/github/orgs/route.ts:

typescript
import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function GET() {
  const cookieStore = await cookies();
  const token = cookieStore.get("github_token")?.value;

  if (!token) {
    return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
  }

  try {
    // Fetch user first
    const userResponse = await fetch("https://api.github.com/user", {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/vnd.github.v3+json",
      },
    });

    const user = await userResponse.json();

    // Fetch organizations
    const orgsResponse = await fetch("https://api.github.com/user/orgs", {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/vnd.github.v3+json",
      },
    });

    const orgs = await orgsResponse.json();

    // Prepend user as first "org" option
    const allOrgs = [{ login: user.login, type: "user" }, ...orgs];

    return NextResponse.json(allOrgs);
  } catch (error) {
    console.error("GitHub orgs error:", error);
    return NextResponse.json({ error: "Failed to fetch organizations" }, { status: 500 });
  }
}

Step 2: Create repos route

Create next-app/app/api/github/repos/route.ts:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 3: Create branches route

Create next-app/app/api/github/branches/route.ts:

typescript
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function GET(request: NextRequest) {
  const cookieStore = await cookies();
  const token = cookieStore.get("github_token")?.value;

  if (!token) {
    return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
  }

  const searchParams = request.nextUrl.searchParams;
  const owner = searchParams.get("owner");
  const repo = searchParams.get("repo");

  if (!owner || !repo) {
    return NextResponse.json({ error: "Owner and repo are required" }, { status: 400 });
  }

  try {
    const response = await fetch(
      `https://api.github.com/repos/${owner}/${repo}/branches`,
      {
        headers: {
          Authorization: `Bearer ${token}`,
          Accept: "application/vnd.github.v3+json",
        },
      }
    );

    if (!response.ok) {
      throw new Error(`GitHub API error: ${response.status}`);
    }

    const branches = await response.json();

    return NextResponse.json(
      branches.map((branch: any) => ({
        name: branch.name,
        sha: branch.commit.sha,
      }))
    );
  } catch (error) {
    console.error("GitHub branches error:", error);
    return NextResponse.json({ error: "Failed to fetch branches" }, { status: 500 });
  }
}

Step 4: Create files route

Create next-app/app/api/github/files/route.ts:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 5: Commit

bash
git add app/api/github/orgs/route.ts app/api/github/repos/route.ts app/api/github/branches/route.ts app/api/github/files/route.ts && git commit -m "feat: add GitHub data routes (orgs, repos, branches, files)"

Task 2.2.4: Create GitHub Save Route

Files:

  • Create: next-app/app/api/github/save/route.ts

Step 1: Create save route

Create next-app/app/api/github/save/route.ts:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add app/api/github/save/route.ts && git commit -m "feat: add GitHub save route"

Task 2.2.5: Create GitHub Hook

Files:

  • Create: next-app/hooks/useGitHub.ts

Step 1: Create GitHub hook

Create next-app/hooks/useGitHub.ts:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add hooks/useGitHub.ts && git commit -m "feat: add useGitHub hook for GitHub integration"

Task 2.2.6: Create GitHub Modal

Files:

  • Create: next-app/components/modals/GitHubModal.tsx

Step 1: Create GitHub modal component

Create next-app/components/modals/GitHubModal.tsx:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add components/modals/GitHubModal.tsx && git commit -m "feat: add GitHubModal component"

Phase 2.3: Dropbox Integration

Task 2.3.1: Install Dropbox SDK

Step 1: Install dependency

bash
npm install dropbox

Step 2: Commit

bash
git add package.json package-lock.json && git commit -m "feat: add Dropbox SDK dependency"

Task 2.3.2: Create Dropbox OAuth Routes

Files:

  • Create: next-app/app/api/dropbox/oauth/route.ts
  • Create: next-app/app/api/dropbox/callback/route.ts
  • Create: next-app/app/api/dropbox/status/route.ts
  • Create: next-app/app/api/dropbox/unlink/route.ts

Step 1: Create OAuth initiation route

Create next-app/app/api/dropbox/oauth/route.ts:

typescript
import { NextResponse } from "next/server";
import { Dropbox } from "dropbox";

export async function GET() {
  const clientId = process.env.DROPBOX_APP_KEY;
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;

  if (!clientId || !baseUrl) {
    return NextResponse.json(
      { error: "Dropbox OAuth not configured" },
      { status: 500 }
    );
  }

  const redirectUri = `${baseUrl}/api/dropbox/callback`;

  const dbx = new Dropbox({ clientId });
  const authUrl = await dbx.auth.getAuthenticationUrl(
    redirectUri,
    undefined,
    "code",
    "offline",
    undefined,
    undefined,
    false
  );

  return NextResponse.redirect(authUrl.toString());
}

Step 2: Create callback route

Create next-app/app/api/dropbox/callback/route.ts:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 3: Create status route

Create next-app/app/api/dropbox/status/route.ts:

typescript
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { Dropbox } from "dropbox";

export async function GET() {
  const cookieStore = await cookies();
  const tokenCookie = cookieStore.get("dropbox_token")?.value;

  if (!tokenCookie) {
    return NextResponse.json({ connected: false });
  }

  try {
    const { access_token } = JSON.parse(tokenCookie);

    const dbx = new Dropbox({ accessToken: access_token });
    const account = await dbx.usersGetCurrentAccount();

    return NextResponse.json({
      connected: true,
      user: {
        name: account.result.name.display_name,
        email: account.result.email,
      },
    });
  } catch (error) {
    return NextResponse.json({ connected: false });
  }
}

Step 4: Create unlink route

Create next-app/app/api/dropbox/unlink/route.ts:

typescript
import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function POST() {
  const cookieStore = await cookies();
  cookieStore.delete("dropbox_token");

  return NextResponse.json({ success: true });
}

Step 5: Commit

bash
git add app/api/dropbox/ && git commit -m "feat: add Dropbox OAuth routes"

Task 2.3.3: Create Dropbox Data Routes

Files:

  • Create: next-app/app/api/dropbox/files/route.ts
  • Create: next-app/app/api/dropbox/save/route.ts

Step 1: Create files route

Create next-app/app/api/dropbox/files/route.ts:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Create save route

Create next-app/app/api/dropbox/save/route.ts:

typescript
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { Dropbox } from "dropbox";

export async function POST(request: NextRequest) {
  const cookieStore = await cookies();
  const tokenCookie = cookieStore.get("dropbox_token")?.value;

  if (!tokenCookie) {
    return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
  }

  try {
    const { path, content } = await request.json();

    if (!path || content === undefined) {
      return NextResponse.json(
        { error: "Path and content are required" },
        { status: 400 }
      );
    }

    const { access_token } = JSON.parse(tokenCookie);
    const dbx = new Dropbox({ accessToken: access_token });

    // Ensure path starts with /
    const filePath = path.startsWith("/") ? path : `/${path}`;

    const response = await dbx.filesUpload({
      path: filePath,
      contents: content,
      mode: { ".tag": "overwrite" },
    });

    return NextResponse.json({
      success: true,
      path: response.result.path_lower,
      name: response.result.name,
    });
  } catch (error) {
    console.error("Dropbox save error:", error);
    return NextResponse.json({ error: "Failed to save file" }, { status: 500 });
  }
}

Step 3: Commit

bash
git add app/api/dropbox/files/route.ts app/api/dropbox/save/route.ts && git commit -m "feat: add Dropbox files and save routes"

Task 2.3.4: Create Dropbox Hook

Files:

  • Create: next-app/hooks/useDropbox.ts

Step 1: Create Dropbox hook

Create next-app/hooks/useDropbox.ts:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add hooks/useDropbox.ts && git commit -m "feat: add useDropbox hook for Dropbox integration"

Task 2.3.5: Create Dropbox Modal

Files:

  • Create: next-app/components/modals/DropboxModal.tsx

Step 1: Create Dropbox modal component

Create next-app/components/modals/DropboxModal.tsx:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add components/modals/DropboxModal.tsx && git commit -m "feat: add DropboxModal component"

Phase 2.4: Integration with Main App

Task 2.4.1: Update Sidebar with Cloud Services

Files:

  • Modify: next-app/components/sidebar/Sidebar.tsx

Step 1: Update Sidebar to include cloud service buttons

Update next-app/components/sidebar/Sidebar.tsx:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add components/sidebar/Sidebar.tsx && git commit -m "feat: integrate GitHub and Dropbox into Sidebar"

Task 2.4.2: Final Verification

Step 1: Run development server

bash
npm run dev

Step 2: Manual testing checklist

  • GitHub OAuth flow works (link/unlink)
  • Can browse GitHub orgs โ†’ repos โ†’ branches โ†’ files
  • Can import markdown file from GitHub
  • Can save document to GitHub (new file and update existing)
  • Dropbox OAuth flow works (link/unlink)
  • Can browse Dropbox folders and files
  • Can import markdown file from Dropbox
  • Can save document to Dropbox
  • All Phase 1 features still work

Step 3: Run build

bash
npm run build

Expected: Build succeeds without errors

Step 4: Final commit

bash
git add -A && git commit -m "feat: complete Phase 2 - GitHub and Dropbox integrations"

Summary

Phase 2 adds cloud integrations:

Completed:

  • GitHub OAuth flow (connect/disconnect)
  • GitHub file browsing (orgs โ†’ repos โ†’ branches โ†’ files)
  • GitHub import and save
  • Dropbox OAuth flow (connect/disconnect)
  • Dropbox file browsing
  • Dropbox import and save
  • Updated Sidebar with Services, Import, and Save sections

Environment Variables Required:

text
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=xxx
DROPBOX_APP_KEY=xxx
DROPBOX_APP_SECRET=xxx
NEXT_PUBLIC_BASE_URL=http://localhost:3000

Deferred to Phase 3:

  • Google Drive integration
  • OneDrive integration
  • Medium integration
  • Bitbucket integration
  • Resizable editor/preview panels
  • Vim/Emacs keybindings
  • Word count display

Plans 2026 01 19 Nextjs Migration (`docs/plans/2026-01-19-nextjs-migration.md`)

Dillinger Next.js Migration Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Migrate Dillinger from Angular.js to Next.js 14 with App Router, maintaining 100% functional parity.

Architecture: Single-page editor app with Zustand state management, Monaco Editor, markdown-it rendering, and Next.js API routes for exports and OAuth. All state persisted to localStorage.

Tech Stack: Next.js 14, TypeScript, Tailwind CSS, Zustand, Monaco Editor, markdown-it, Lucide React


Phase 1: Project Setup

Task 1.1: Initialize Next.js Project

Files:

  • Create: next-app/ (new Next.js project)
  • Create: next-app/package.json
  • Create: next-app/tsconfig.json
  • Create: next-app/tailwind.config.ts

Step 1: Create Next.js app with TypeScript and Tailwind

bash
cd /Users/joemccann/dev/vercel/dillinger/.worktrees/nextjs-migration
npx create-next-app@latest next-app --typescript --tailwind --eslint --app --src-dir=false --import-alias="@/*" --use-npm

Step 2: Verify project created

bash
ls next-app/

Expected: app/, public/, package.json, tsconfig.json, tailwind.config.ts

Step 3: Commit

bash
cd next-app && git add -A && git commit -m "feat: initialize Next.js 14 project with TypeScript and Tailwind"

Task 1.2: Install Core Dependencies

Files:

  • Modify: next-app/package.json

Step 1: Install editor and state management

bash
cd /Users/joemccann/dev/vercel/dillinger/.worktrees/nextjs-migration/next-app
npm install @monaco-editor/react zustand lucide-react

Step 2: Install markdown rendering stack

bash
npm install markdown-it markdown-it-abbr markdown-it-checkbox markdown-it-deflist markdown-it-footnote markdown-it-ins markdown-it-mark markdown-it-sub markdown-it-sup markdown-it-toc-done-right highlight.js katex

Step 3: Install types

bash
npm install -D @types/markdown-it

Step 4: Verify installation

bash
npm ls @monaco-editor/react zustand markdown-it

Expected: All packages listed without errors

Step 5: Commit

bash
git add package.json package-lock.json && git commit -m "feat: add core dependencies (Monaco, Zustand, markdown-it)"

Task 1.3: Configure Tailwind with Design Tokens

Files:

  • Modify: next-app/tailwind.config.ts

Step 1: Update Tailwind config with Dillinger design tokens

Replace contents of next-app/tailwind.config.ts:

typescript
import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  darkMode: "class",
  theme: {
    extend: {
      colors: {
        plum: "#35D7BB",
        "bg-primary": "#ffffff",
        "bg-sidebar": "#2B2F36",
        "bg-navbar": "#373D49",
        "bg-highlight": "#1D212A",
        "bg-button-save": "#4A5261",
        "text-primary": "#373D49",
        "text-invert": "#ffffff",
        "text-muted": "#A0AABF",
        "border-light": "#E8E8E8",
        "border-settings": "#4F535B",
        "icon-default": "#D3DAEA",
        "dropdown-link": "#D0D6E2",
        switchery: "#4B5363",
      },
      fontFamily: {
        sans: ['"Source Sans Pro"', '"Helvetica Neue"', "Helvetica", "Arial", "sans-serif"],
        serif: ["Georgia", "Cambria", "serif"],
        mono: ['"Ubuntu Mono"', "Monaco", "monospace"],
      },
      spacing: {
        sidebar: "270px",
        gutter: "32px",
      },
      zIndex: {
        sidebar: "1",
        page: "2",
        editor: "3",
        preview: "4",
        overlay: "5",
        navbar: "6",
        settings: "7",
      },
    },
  },
  plugins: [],
};

export default config;

Step 2: Add Google Fonts to layout

Update next-app/app/layout.tsx:

typescript
import type { Metadata } from "next";
import { Source_Sans_3 } from "next/font/google";
import "./globals.css";

const sourceSans = Source_Sans_3({
  subsets: ["latin"],
  variable: "--font-source-sans",
});

export const metadata: Metadata = {
  title: "Dillinger - Online Markdown Editor",
  description: "The last Markdown editor you'll ever need",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={`${sourceSans.variable} font-sans`}>{children}</body>
    </html>
  );
}

Step 3: Verify build

bash
npm run build

Expected: Build succeeds

Step 4: Commit

bash
git add tailwind.config.ts app/layout.tsx && git commit -m "feat: configure Tailwind with Dillinger design tokens"

Phase 2: State Management

Task 2.1: Create Zustand Store Types

Files:

  • Create: next-app/lib/types.ts

Step 1: Create types file

Create next-app/lib/types.ts:

typescript
export interface Document {
  id: string;
  title: string;
  body: string;
  createdAt: string;
  github?: {
    sha: string;
    path: string;
    repo: string;
    owner: string;
    branch: string;
  };
}

export interface UserSettings {
  enableAutoSave: boolean;
  enableWordsCount: boolean;
  enableCharactersCount: boolean;
  enableScrollSync: boolean;
  tabSize: number;
  keybindings: "default" | "vim" | "emacs";
  enableNightMode: boolean;
  enableGitHubComment: boolean;
}

export const DEFAULT_SETTINGS: UserSettings = {
  enableAutoSave: true,
  enableWordsCount: true,
  enableCharactersCount: true,
  enableScrollSync: false,
  tabSize: 4,
  keybindings: "default",
  enableNightMode: false,
  enableGitHubComment: true,
};

export const DEFAULT_DOCUMENT_BODY = `# Welcome to Dillinger

Dillinger is a cloud-enabled, mobile-ready, offline-storage compatible,
AngularJS-powered HTML5 Markdown editor.

- Type some Markdown on the left
- See HTML in the right
- Magic

## Features

- Import a HTML file and watch it magically convert to Markdown
- Drag and drop images (requires your Dropbox account be linked)
- Import and save files from GitHub, Dropbox, Google Drive and One Drive
- Drag and drop markdown and HTML files into Dillinger
- Export documents as Markdown, HTML and PDF
`;

Step 2: Commit

bash
git add lib/types.ts && git commit -m "feat: add TypeScript types for documents and settings"

Task 2.2: Create Zustand Store

Files:

  • Create: next-app/stores/store.ts

Step 1: Create store with persistence

Create next-app/stores/store.ts:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add stores/store.ts && git commit -m "feat: add Zustand store with document and settings management"

Task 2.3: Create Store Provider

Files:

  • Create: next-app/components/providers/StoreProvider.tsx

Step 1: Create provider component

Create directory and file next-app/components/providers/StoreProvider.tsx:

typescript
"use client";

import { useEffect } from "react";
import { useStore } from "@/stores/store";

export function StoreProvider({ children }: { children: React.ReactNode }) {
  const hydrate = useStore((state) => state.hydrate);

  useEffect(() => {
    hydrate();
  }, [hydrate]);

  return <>{children}</>;
}

Step 2: Update layout to use provider

Update next-app/app/layout.tsx:

typescript
import type { Metadata } from "next";
import { Source_Sans_3 } from "next/font/google";
import { StoreProvider } from "@/components/providers/StoreProvider";
import "./globals.css";

const sourceSans = Source_Sans_3({
  subsets: ["latin"],
  variable: "--font-source-sans",
});

export const metadata: Metadata = {
  title: "Dillinger - Online Markdown Editor",
  description: "The last Markdown editor you'll ever need",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={`${sourceSans.variable} font-sans`}>
        <StoreProvider>{children}</StoreProvider>
      </body>
    </html>
  );
}

Step 3: Verify build

bash
npm run build

Expected: Build succeeds

Step 4: Commit

bash
git add components/providers/StoreProvider.tsx app/layout.tsx && git commit -m "feat: add StoreProvider for state hydration"

Phase 3: Markdown Rendering

Task 3.1: Create Markdown Library

Files:

  • Create: next-app/lib/markdown.ts

Step 1: Create markdown-it configuration

Create next-app/lib/markdown.ts:

typescript
import MarkdownIt from "markdown-it";
import markdownItAbbr from "markdown-it-abbr";
import markdownItCheckbox from "markdown-it-checkbox";
import markdownItDeflist from "markdown-it-deflist";
import markdownItFootnote from "markdown-it-footnote";
import markdownItIns from "markdown-it-ins";
import markdownItMark from "markdown-it-mark";
import markdownItSub from "markdown-it-sub";
import markdownItSup from "markdown-it-sup";
import hljs from "highlight.js";

export const md = new MarkdownIt({
  html: true,
  linkify: true,
  typographer: true,
  highlight: (str: string, lang: string): string => {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
      } catch {
        // Fall through to default
      }
    }
    return ""; // Use external default escaping
  },
})
  .use(markdownItAbbr)
  .use(markdownItCheckbox)
  .use(markdownItDeflist)
  .use(markdownItFootnote)
  .use(markdownItIns)
  .use(markdownItMark)
  .use(markdownItSub)
  .use(markdownItSup);

export function renderMarkdown(content: string): string {
  return md.render(content);
}

Step 2: Commit

bash
git add lib/markdown.ts && git commit -m "feat: add markdown-it configuration with all plugins"

Task 3.2: Create Preview Component

Files:

  • Create: next-app/components/preview/MarkdownPreview.tsx
  • Modify: next-app/app/globals.css

Step 1: Create preview component

Create next-app/components/preview/MarkdownPreview.tsx:

typescript
"use client";

import { useMemo } from "react";
import { useStore } from "@/stores/store";
import { renderMarkdown } from "@/lib/markdown";

export function MarkdownPreview() {
  const currentDocument = useStore((state) => state.currentDocument);

  const html = useMemo(() => {
    if (!currentDocument?.body) return "";
    return renderMarkdown(currentDocument.body);
  }, [currentDocument?.body]);

  return (
    <div
      id="preview"
      className="preview-html h-full overflow-auto p-6 bg-bg-primary"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

Step 2: Add preview styles to globals.css

Append to next-app/app/globals.css:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 3: Verify build

bash
npm run build

Expected: Build succeeds

Step 4: Commit

bash
git add components/preview/MarkdownPreview.tsx app/globals.css && git commit -m "feat: add MarkdownPreview component with styling"

Phase 4: Monaco Editor

Task 4.1: Create Monaco Editor Component

Files:

  • Create: next-app/components/editor/MonacoEditor.tsx

Step 1: Create editor component

Create next-app/components/editor/MonacoEditor.tsx:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add components/editor/MonacoEditor.tsx && git commit -m "feat: add Monaco Editor component with auto-save"

Phase 5: UI Components

Task 5.1: Create Toast Notification System

Files:

  • Create: next-app/components/ui/Toast.tsx

Step 1: Create toast component and context

Create next-app/components/ui/Toast.tsx:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Add animation to Tailwind config

Update next-app/tailwind.config.ts to add animation:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 3: Add ToastProvider to layout

Update next-app/app/layout.tsx:

typescript
import type { Metadata } from "next";
import { Source_Sans_3 } from "next/font/google";
import { StoreProvider } from "@/components/providers/StoreProvider";
import { ToastProvider } from "@/components/ui/Toast";
import "./globals.css";

const sourceSans = Source_Sans_3({
  subsets: ["latin"],
  variable: "--font-source-sans",
});

export const metadata: Metadata = {
  title: "Dillinger - Online Markdown Editor",
  description: "The last Markdown editor you'll ever need",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={`${sourceSans.variable} font-sans`}>
        <StoreProvider>
          <ToastProvider>{children}</ToastProvider>
        </StoreProvider>
      </body>
    </html>
  );
}

Step 4: Commit

bash
git add components/ui/Toast.tsx tailwind.config.ts app/layout.tsx && git commit -m "feat: add Toast notification system"

Task 5.2: Create Sidebar Component

Files:

  • Create: next-app/components/sidebar/Sidebar.tsx
  • Create: next-app/components/sidebar/DocumentList.tsx

Step 1: Create DocumentList component

Create next-app/components/sidebar/DocumentList.tsx:

typescript
"use client";

import { useStore } from "@/stores/store";
import { FileText } from "lucide-react";

export function DocumentList() {
  const documents = useStore((state) => state.documents);
  const currentDocument = useStore((state) => state.currentDocument);
  const selectDocument = useStore((state) => state.selectDocument);

  return (
    <ul className="space-y-1">
      {documents.map((doc) => (
        <li key={doc.id}>
          <button
            onClick={() => selectDocument(doc.id)}
            className={`w-full text-left px-3 py-2 rounded flex items-center gap-2 text-sm transition-colors ${
              currentDocument?.id === doc.id
                ? "bg-bg-highlight text-text-invert"
                : "text-dropdown-link hover:bg-bg-highlight/50"
            }`}
          >
            <FileText size={16} />
            <span className="truncate">{doc.title}</span>
          </button>
        </li>
      ))}
    </ul>
  );
}

Step 2: Create Sidebar component

Create next-app/components/sidebar/Sidebar.tsx:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 3: Commit

bash
git add components/sidebar/ && git commit -m "feat: add Sidebar with document list and actions"

Task 5.3: Create Navbar Component

Files:

  • Create: next-app/components/navbar/Navbar.tsx

Step 1: Create navbar component

Create next-app/components/navbar/Navbar.tsx:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add components/navbar/Navbar.tsx && git commit -m "feat: add Navbar with export dropdown and toggles"

Task 5.4: Create Document Title Component

Files:

  • Create: next-app/components/editor/DocumentTitle.tsx

Step 1: Create editable title component

Create next-app/components/editor/DocumentTitle.tsx:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add components/editor/DocumentTitle.tsx && git commit -m "feat: add editable DocumentTitle component"

Task 5.5: Create Settings Modal

Files:

  • Create: next-app/components/modals/SettingsModal.tsx

Step 1: Create settings modal component

Create next-app/components/modals/SettingsModal.tsx:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add components/modals/SettingsModal.tsx && git commit -m "feat: add SettingsModal component"

Phase 6: Main Page Assembly

Task 6.1: Create Main Editor Page

Files:

  • Modify: next-app/app/page.tsx

Step 1: Assemble the main page

Update next-app/app/page.tsx:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Verify dev server works

bash
npm run dev

Expected: App runs at http://localhost:3000 with editor and preview visible

Step 3: Commit

bash
git add app/page.tsx && git commit -m "feat: assemble main editor page with all components"

Phase 7: API Routes

Task 7.1: Create Markdown Export Route

Files:

  • Create: next-app/app/api/export/markdown/route.ts

Step 1: Create markdown export API

Create next-app/app/api/export/markdown/route.ts:

typescript
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  try {
    const { markdown, title } = await request.json();

    if (!markdown) {
      return NextResponse.json(
        { error: "Markdown content is required" },
        { status: 400 }
      );
    }

    const filename = `${title || "document"}.md`;

    return new NextResponse(markdown, {
      headers: {
        "Content-Type": "text/markdown",
        "Content-Disposition": `attachment; filename="${filename}"`,
      },
    });
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to export markdown" },
      { status: 500 }
    );
  }
}

Step 2: Commit

bash
git add app/api/export/markdown/route.ts && git commit -m "feat: add markdown export API route"

Task 7.2: Create HTML Export Route

Files:

  • Create: next-app/app/api/export/html/route.ts

Step 1: Create HTML export API

Create next-app/app/api/export/html/route.ts:

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Commit

bash
git add app/api/export/html/route.ts && git commit -m "feat: add HTML export API route with styling"

Task 7.3: Create PDF Export Route

Files:

  • Create: next-app/app/api/export/pdf/route.ts
  • Modify: next-app/package.json (add md-to-pdf)

Step 1: Install md-to-pdf

bash
npm install md-to-pdf

Step 2: Create PDF export API

Create next-app/app/api/export/pdf/route.ts:

typescript
import { NextRequest, NextResponse } from "next/server";
import { mdToPdf } from "md-to-pdf";

export async function POST(request: NextRequest) {
  try {
    const { markdown, title } = await request.json();

    if (!markdown) {
      return NextResponse.json(
        { error: "Markdown content is required" },
        { status: 400 }
      );
    }

    const pdf = await mdToPdf(
      { content: markdown },
      {
        pdf_options: {
          format: "A4",
          margin: { top: "20mm", bottom: "20mm", left: "20mm", right: "20mm" },
        },
      }
    );

    if (!pdf || !pdf.content) {
      throw new Error("PDF generation failed");
    }

    const filename = `${title || "document"}.pdf`;

    return new NextResponse(pdf.content, {
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": `attachment; filename="${filename}"`,
      },
    });
  } catch (error) {
    console.error("PDF export error:", error);
    return NextResponse.json(
      { error: "Failed to export PDF" },
      { status: 500 }
    );
  }
}

Step 3: Commit

bash
git add app/api/export/pdf/route.ts package.json package-lock.json && git commit -m "feat: add PDF export API route using md-to-pdf"

Phase 8: Final Integration & Testing

Task 8.1: Verify Full Application

Step 1: Run development server

bash
npm run dev

Step 2: Manual testing checklist

  • App loads with default document
  • Can edit markdown in Monaco editor
  • Preview updates in real-time
  • Can create new document
  • Can switch between documents
  • Can delete document (if more than one)
  • Can rename document
  • Can toggle preview visibility
  • Settings modal opens/closes
  • Night mode toggle works
  • Export to Markdown works
  • Export to HTML works
  • Export to PDF works
  • Documents persist across page reload

Step 3: Run build

bash
npm run build

Expected: Build succeeds without errors

Step 4: Final commit

bash
git add -A && git commit -m "feat: complete Dillinger Next.js migration - Phase 1"

Summary

This plan covers Phase 1 of the migration:

Completed:

  • Next.js project setup with TypeScript and Tailwind
  • Zustand state management with localStorage persistence
  • Monaco Editor integration
  • markdown-it rendering with all plugins
  • Sidebar with document list
  • Navbar with export dropdown
  • Settings modal
  • Export API routes (Markdown, HTML, PDF)

Deferred to Phase 2:

  • GitHub OAuth integration
  • Dropbox OAuth integration
  • Resizable panels
  • Vim/Emacs keybindings
  • Word count display
  • Scroll sync

Plans 2026 01 19 Phase3 Design (`docs/plans/2026-01-19-phase3-design.md`)

Phase 3: Complete Feature Parity - Design Document

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Date: 2026-01-19
Status: Approved
Goal: Complete the Dillinger Next.js migration with all deferred features


Overview

Phase 3 delivers full functional parity with the Angular.js original by implementing:

  1. Google Drive integration - OAuth, browse folders, import/save .md files
  2. OneDrive integration - Same pattern as Google Drive
  3. Medium integration - Publish documents to Medium as drafts
  4. Bitbucket integration - Same pattern as GitHub
  5. Zen mode - Editor-only fullscreen for distraction-free writing
  6. Scroll sync - Preview follows editor scroll position (on by default)
  7. File handling - Drag & drop .md import, image upload to cloud

Implementation order:

  1. Google Drive (establishes patterns for other cloud services)
  2. Editor UX (Zen mode, scroll sync)
  3. OneDrive, Bitbucket, Medium
  4. File handling (drag & drop, image upload)

1. Google Drive Integration

OAuth Flow

  • Register app in Google Cloud Console with Drive API enabled
  • Scopes: https://www.googleapis.com/auth/drive.file
  • Redirect URI: /api/google-drive/callback
  • Token storage: HTTP-only cookies (matches Dropbox/GitHub pattern)

API Routes

text
/api/google-drive/
โ”œโ”€โ”€ oauth/route.ts      # Initiates OAuth, redirects to Google
โ”œโ”€โ”€ callback/route.ts   # Exchanges code for tokens, sets cookie
โ”œโ”€โ”€ status/route.ts     # Returns connection status
โ”œโ”€โ”€ files/route.ts      # GET: list folders/files, POST: get file content
โ”œโ”€โ”€ save/route.ts       # POST: create or update file
โ””โ”€โ”€ unlink/route.ts     # POST: clear tokens, disconnect

Implementation

Direct REST API calls (no SDK) to avoid Next.js server compatibility issues.

Hook Interface

typescript
interface UseGoogleDrive {
  isConnected: boolean;
  files: DriveFile[];
  currentPath: string;
  pathHistory: string[];
  connect: () => void;
  disconnect: () => void;
  fetchFiles: (folderId?: string) => Promise<void>;
  fetchFileContent: (fileId: string) => Promise<FileContent | null>;
  saveFile: (name: string, content: string, folderId?: string) => Promise<boolean>;
  navigateToFolder: (folderId: string) => void;
  navigateBack: () => void;
}

Reuse DropboxModal structure:

  • Folder browser with back navigation
  • File list (folders and .md files)
  • Save mode: filename input field
  • Consistent styling with existing modals

2. Editor UX

Zen Mode

State: zenMode: boolean in Zustand store (not persisted)

Activation:

  • Keyboard shortcut: Cmd/Ctrl + Shift + Z
  • Navbar button with expand icon

Behavior:

  • Hides: sidebar, navbar, document title bar, preview panel
  • Shows: Monaco editor centered, max-width 800px
  • Exit: Escape key or ร— button in corner

Implementation:

typescript
// EditorContainer.tsx
if (zenMode) {
  return (
    <div className="h-dvh bg-bg-primary flex items-center justify-center">
      <div className="w-full max-w-3xl h-full py-12 px-4 relative">
        <button
          onClick={() => setZenMode(false)}
          className="absolute top-4 right-4 text-text-muted hover:text-text-invert"
          aria-label="Exit zen mode"
        >
          <X size={20} />
        </button>
        <MonacoEditor className="h-full" />
      </div>
    </div>
  );
}

Scroll Sync

State: scrollSync: boolean in settings (persisted, default true)

Behavior:

  • Editor scroll position syncs to preview proportionally
  • Formula: (editorScrollTop / editorScrollHeight) * previewScrollHeight
  • Debounced at 50ms to prevent jank

Implementation:

  • Monaco onDidScrollChange event triggers sync
  • Preview container ref receives calculated scroll position
  • Settings toggle to disable

3. OneDrive Integration

Follows Google Drive pattern exactly:

  • OAuth via Microsoft Identity Platform
  • Scopes: Files.ReadWrite
  • API routes: /api/onedrive/{oauth,callback,status,files,save,unlink}
  • Hook: useOneDrive() with identical interface
  • Modal: Same folder browser UI pattern

4. Bitbucket Integration

Follows GitHub pattern:

  • OAuth via Atlassian/Bitbucket
  • Multi-step navigation: Workspaces โ†’ Repos โ†’ Branches โ†’ Files
  • API routes: /api/bitbucket/{oauth,callback,status,workspaces,repos,branches,files,save,unlink}
  • Hook: useBitbucket() mirrors useGitHub() interface
  • Modal: Same multi-step UI as GitHubModal

5. Medium Integration

Different pattern - publish only:

  • OAuth via Medium API
  • Single action: "Publish to Medium" in export dropdown
  • Creates draft post (user publishes on Medium)
  • API routes: /api/medium/{oauth,callback,status,publish,unlink}

Hook interface:

typescript
interface UseMedium {
  isConnected: boolean;
  connect: () => void;
  disconnect: () => void;
  publish: (title: string, content: string, tags?: string[]) => Promise<boolean>;
}

Modal: Simple form with title, tags input, publish button.


6. File Handling

Drag & Drop Import

Behavior:

  • Drop zone covers entire editor area
  • Accepts: .md, .markdown, .txt files
  • Creates new document with file content and filename as title
  • Visual feedback: overlay on drag enter

Implementation:

typescript
const handleDrop = (e: DragEvent) => {
  const file = e.dataTransfer.files[0];
  if (file && /\.(md|markdown|txt)$/i.test(file.name)) {
    const reader = new FileReader();
    reader.onload = () => {
      createDocument();
      updateDocumentTitle(file.name.replace(/\.(md|markdown|txt)$/i, ''));
      updateDocumentBody(reader.result as string);
    };
    reader.readAsText(file);
  }
};

Image Upload

Behavior:

  • Paste or drag image into editor
  • Modal: "Upload to [service]?" with service selector
  • Uploads to selected cloud service
  • Inserts markdown: `` at cursor

Requirements:

  • At least one cloud service must be connected
  • API route per service: /api/[service]/upload-image
  • Returns shareable URL

Fallback: If no service connected, show message prompting user to connect one.


Store Updates

typescript
interface AppState {
  // Existing...

  // Phase 3 additions
  zenMode: boolean;
  setZenMode: (enabled: boolean) => void;
}

interface Settings {
  // Existing...

  // Phase 3 additions
  scrollSync: boolean;  // default: true
}

UI Updates

Add to existing services list:

  • Google Drive (with connect/unlink)
  • OneDrive (with connect/unlink)
  • Medium (with connect/unlink)
  • Bitbucket (with connect/unlink)

Add entries for each new service in "Import from" and "Save to" sections.

  • Add Zen mode toggle button (expand icon)
  • Add "Publish to Medium" in export dropdown (when connected)

Settings Modal

  • Add "Scroll Sync" toggle (default on)

Environment Variables

env
# Google Drive
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# OneDrive
ONEDRIVE_CLIENT_ID=
ONEDRIVE_CLIENT_SECRET=

# Medium
MEDIUM_CLIENT_ID=
MEDIUM_CLIENT_SECRET=

# Bitbucket
BITBUCKET_CLIENT_ID=
BITBUCKET_CLIENT_SECRET=

Success Criteria

  • All four cloud services authenticate and sync files
  • Zen mode hides all UI, Escape exits
  • Scroll sync works smoothly, can be disabled
  • Drag & drop imports .md files correctly
  • Image upload works with connected services
  • All features accessible via keyboard
  • TypeScript compiles without errors
  • ESLint passes

Plans 2026 01 19 Phase3 Implementation (`docs/plans/2026-01-19-phase3-implementation.md`)

Phase 3 Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement all Phase 3 features: Google Drive, OneDrive, Bitbucket, Medium integrations, Zen mode, scroll sync, and file handling.

Architecture: Follow established patterns from Phase 2 - OAuth via API routes with HTTP-only cookies, React hooks for service state, modal UI for file browsing. Direct REST API calls (no SDKs) for cloud services.

Tech Stack: Next.js 14 App Router, TypeScript, Zustand, Tailwind CSS, Monaco Editor

Verification: After each task, run npx tsc --noEmit && npm run lint to verify. Start dev server with npm run dev for manual testing.


Part 1: Google Drive Integration

Task 1: Add Google Drive Environment Variables

Files:

  • Modify: .env.local
  • Modify: app/api/ (reference only)

Step 1: Add environment variables to .env.local

env
# Google Drive OAuth
GOOGLE_CLIENT_ID=your_client_id_here
GOOGLE_CLIENT_SECRET=your_client_secret_here

Step 2: Verify TypeScript still compiles

Run: npx tsc --noEmit
Expected: No errors

Step 3: Commit

bash
git add -A
git commit -m "chore: add Google Drive env variables placeholder"

Task 2: Create Google Drive OAuth Route

Files:

  • Create: app/api/google-drive/oauth/route.ts

Step 1: Create the OAuth initiation route

typescript
import { NextResponse } from "next/server";

export async function GET() {
  const clientId = process.env.GOOGLE_CLIENT_ID;

  if (!clientId) {
    return NextResponse.json(
      { error: "Google Drive not configured" },
      { status: 500 }
    );
  }

  const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/google-drive/callback`;

  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    response_type: "code",
    scope: "https://www.googleapis.com/auth/drive.file",
    access_type: "offline",
    prompt: "consent",
  });

  const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;

  return NextResponse.redirect(authUrl);
}

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 3: Commit

bash
git add app/api/google-drive/oauth/route.ts
git commit -m "feat: add Google Drive OAuth initiation route"

Task 3: Create Google Drive Callback Route

Files:

  • Create: app/api/google-drive/callback/route.ts

Step 1: Create the callback route

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 3: Commit

bash
git add app/api/google-drive/callback/route.ts
git commit -m "feat: add Google Drive OAuth callback route"

Task 4: Create Google Drive Status Route

Files:

  • Create: app/api/google-drive/status/route.ts

Step 1: Create the status route

typescript
import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function GET() {
  try {
    const cookieStore = await cookies();
    const tokenCookie = cookieStore.get("google_drive_token");

    if (!tokenCookie) {
      return NextResponse.json({ connected: false });
    }

    const tokens = JSON.parse(tokenCookie.value);

    // Verify token is still valid by calling userinfo
    const response = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
      headers: {
        Authorization: `Bearer ${tokens.access_token}`,
      },
    });

    if (!response.ok) {
      // Token expired or invalid
      return NextResponse.json({ connected: false });
    }

    const userInfo = await response.json();

    return NextResponse.json({
      connected: true,
      email: userInfo.email,
    });
  } catch (error) {
    console.error("Google Drive status error:", error);
    return NextResponse.json({ connected: false });
  }
}

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 3: Commit

bash
git add app/api/google-drive/status/route.ts
git commit -m "feat: add Google Drive status route"

Files:

  • Create: app/api/google-drive/unlink/route.ts

Step 1: Create the unlink route

typescript
import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function POST() {
  try {
    const cookieStore = await cookies();
    cookieStore.delete("google_drive_token");

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error("Google Drive unlink error:", error);
    return NextResponse.json(
      { error: "Failed to unlink Google Drive" },
      { status: 500 }
    );
  }
}

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 3: Commit

bash
git add app/api/google-drive/unlink/route.ts
git commit -m "feat: add Google Drive unlink route"

Task 6: Create Google Drive Files Route

Files:

  • Create: app/api/google-drive/files/route.ts

Step 1: Create the files route

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 3: Commit

bash
git add app/api/google-drive/files/route.ts
git commit -m "feat: add Google Drive files route"

Task 7: Create Google Drive Save Route

Files:

  • Create: app/api/google-drive/save/route.ts

Step 1: Create the save route

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 3: Commit

bash
git add app/api/google-drive/save/route.ts
git commit -m "feat: add Google Drive save route"

Task 8: Create useGoogleDrive Hook

Files:

  • Create: hooks/useGoogleDrive.ts

Step 1: Create the hook

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 3: Commit

bash
git add hooks/useGoogleDrive.ts
git commit -m "feat: add useGoogleDrive hook"

Task 9: Create GoogleDriveModal Component

Files:

  • Create: components/modals/GoogleDriveModal.tsx

Step 1: Create the modal (copy pattern from DropboxModal)

text
/* Detailed source-code truncated for AI context efficiency. */

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 3: Commit

bash
git add components/modals/GoogleDriveModal.tsx
git commit -m "feat: add GoogleDriveModal component"

Task 10: Integrate Google Drive into Sidebar

Files:

  • Modify: components/sidebar/Sidebar.tsx

Step 1: Add imports and state

Add to imports at top of file:

typescript
import { useGoogleDrive } from "@/hooks/useGoogleDrive";
import { GoogleDriveModal } from "@/components/modals/GoogleDriveModal";
import { HardDrive } from "lucide-react";

Step 2: Add hook and modal state inside Sidebar component

After existing hook calls:

typescript
const googleDrive = useGoogleDrive();

const [googleDriveModal, setGoogleDriveModal] = useState<{ open: boolean; mode: "import" | "save" }>({
  open: false,
  mode: "import",
});

Step 3: Add Google Drive to Services section

In the Services section (inside {servicesOpen && ...}), add after Dropbox ServiceButton:

typescript
<ServiceButton
  icon={<HardDrive size={16} />}
  label="Google Drive"
  connected={googleDrive.isConnected}
  onConnect={googleDrive.connect}
  onDisconnect={googleDrive.disconnect}
/>

Step 4: Add Google Drive to Import section

In the Import section (inside {importOpen && ...}), add:

typescript
<button
  onClick={() => setGoogleDriveModal({ open: true, mode: "import" })}
  className="w-full flex items-center gap-2 py-2 px-2 text-dropdown-link hover:text-text-invert text-sm rounded hover:bg-bg-highlight
             focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-plum"
>
  <HardDrive size={16} />
  <span>Google Drive</span>
</button>

Step 5: Add Google Drive to Save section

In the Save section (inside {saveOpen && ...}), add:

typescript
<button
  onClick={() => setGoogleDriveModal({ open: true, mode: "save" })}
  className="w-full flex items-center gap-2 py-2 px-2 text-dropdown-link hover:text-text-invert text-sm rounded hover:bg-bg-highlight
             focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-plum"
>
  <HardDrive size={16} />
  <span>Google Drive</span>
</button>

Step 6: Add modal to render

After existing modals (before closing </>):

typescript
<GoogleDriveModal
  isOpen={googleDriveModal.open}
  onClose={() => setGoogleDriveModal({ ...googleDriveModal, open: false })}
  mode={googleDriveModal.mode}
/>

Step 7: Verify TypeScript compiles and lint passes

Run: npx tsc --noEmit && npm run lint
Expected: No errors

Step 8: Commit

bash
git add components/sidebar/Sidebar.tsx
git commit -m "feat: integrate Google Drive into sidebar"

Part 2: Editor UX (Zen Mode & Scroll Sync)

Task 11: Add Zen Mode and Scroll Sync to Store

Files:

  • Modify: stores/store.ts

Step 1: Add zen mode state

Add to the AppState interface:

typescript
zenMode: boolean;
setZenMode: (enabled: boolean) => void;

Add to Settings interface (if not already there, add scrollSync):

typescript
scrollSync: boolean;

Step 2: Add to initial state and actions

In the create function, add to initial state:

typescript
zenMode: false,

Add to settings default:

typescript
scrollSync: true,

Add action:

typescript
setZenMode: (enabled) => set({ zenMode: enabled }),

Step 3: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 4: Commit

bash
git add stores/store.ts
git commit -m "feat: add zenMode and scrollSync to store"

Task 12: Implement Zen Mode in EditorContainer

Files:

  • Modify: components/editor/EditorContainer.tsx

Step 1: Add zen mode imports and state

Add to imports:

typescript
import { X, Maximize2 } from "lucide-react";

In EditorContent, add:

typescript
const zenMode = useStore((state) => state.zenMode);
const setZenMode = useStore((state) => state.setZenMode);

Step 2: Add keyboard shortcut effect

Add useEffect for keyboard shortcut:

typescript
useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    // Cmd/Ctrl + Shift + Z for zen mode
    if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "z") {
      e.preventDefault();
      setZenMode(!zenMode);
    }
    // Escape to exit zen mode
    if (e.key === "Escape" && zenMode) {
      setZenMode(false);
    }
  };

  document.addEventListener("keydown", handleKeyDown);
  return () => document.removeEventListener("keydown", handleKeyDown);
}, [zenMode, setZenMode]);

Step 3: Add zen mode render

Before the main return, add:

typescript
if (zenMode) {
  return (
    <div className="h-dvh bg-bg-primary flex items-center justify-center">
      <div className="w-full max-w-3xl h-full py-12 px-4 relative">
        <button
          onClick={() => setZenMode(false)}
          className="absolute top-4 right-4 text-text-muted hover:text-text-invert transition-colors rounded
                     focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-plum"
          aria-label="Exit zen mode"
        >
          <X size={24} />
        </button>
        <div className="h-full border border-border-light rounded-lg overflow-hidden">
          <MonacoEditor />
        </div>
      </div>
    </div>
  );
}

Step 4: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 5: Commit

bash
git add components/editor/EditorContainer.tsx
git commit -m "feat: implement zen mode in editor"

Task 13: Add Zen Mode Button to Navbar

Files:

  • Modify: components/navbar/Navbar.tsx

Step 1: Add imports

Add to imports:

typescript
import { Maximize2 } from "lucide-react";

Step 2: Add zen mode state

Add to component:

typescript
const setZenMode = useStore((state) => state.setZenMode);

Step 3: Add zen mode button

Add button in the navbar (before settings button):

typescript
<button
  onClick={() => setZenMode(true)}
  aria-label="Enter zen mode"
  className="text-icon-default hover:text-plum transition-colors rounded
             focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-plum"
>
  <Maximize2 size={20} />
</button>

Step 4: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 5: Commit

bash
git add components/navbar/Navbar.tsx
git commit -m "feat: add zen mode button to navbar"

Task 14: Implement Scroll Sync

Files:

  • Modify: components/editor/MonacoEditor.tsx
  • Modify: components/preview/MarkdownPreview.tsx
  • Modify: stores/store.ts (add scroll position)

Step 1: Add scroll position to store

In stores/store.ts, add to AppState:

typescript
editorScrollPercent: number;
setEditorScrollPercent: (percent: number) => void;

Add initial state:

typescript
editorScrollPercent: 0,

Add action:

typescript
setEditorScrollPercent: (percent) => set({ editorScrollPercent: percent }),

Step 2: Update MonacoEditor to emit scroll position

In MonacoEditor.tsx, add to imports:

typescript
import { useStore } from "@/stores/store";

Add state:

typescript
const settings = useStore((state) => state.settings);
const setEditorScrollPercent = useStore((state) => state.setEditorScrollPercent);

In handleEditorDidMount, add scroll listener:

typescript
editor.onDidScrollChange((e) => {
  if (settings.scrollSync) {
    const scrollTop = e.scrollTop;
    const scrollHeight = editor.getScrollHeight() - editor.getLayoutInfo().height;
    const percent = scrollHeight > 0 ? scrollTop / scrollHeight : 0;
    setEditorScrollPercent(percent);
  }
});

Step 3: Update MarkdownPreview to sync scroll

In MarkdownPreview.tsx, add:

typescript
const containerRef = useRef<HTMLDivElement>(null);
const settings = useStore((state) => state.settings);
const editorScrollPercent = useStore((state) => state.editorScrollPercent);

Add useEffect for scroll sync:

typescript
useEffect(() => {
  if (!settings.scrollSync || !containerRef.current) return;

  const container = containerRef.current;
  const scrollHeight = container.scrollHeight - container.clientHeight;
  container.scrollTop = scrollHeight * editorScrollPercent;
}, [editorScrollPercent, settings.scrollSync]);

Add ref to container div:

typescript
<div ref={containerRef} className="...existing classes...">

Step 4: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 5: Commit

bash
git add stores/store.ts components/editor/MonacoEditor.tsx components/preview/MarkdownPreview.tsx
git commit -m "feat: implement scroll sync between editor and preview"

Task 15: Add Scroll Sync Toggle to Settings

Files:

  • Modify: components/modals/SettingsModal.tsx

Step 1: Add scroll sync toggle

Add after existing toggles:

typescript
{/* Scroll Sync */}
<SettingToggle
  id="scroll-sync"
  label="Scroll Sync"
  checked={settings.scrollSync}
  onChange={(v) => updateSettings({ scrollSync: v })}
/>

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 3: Commit

bash
git add components/modals/SettingsModal.tsx
git commit -m "feat: add scroll sync toggle to settings"

Part 3: Remaining Integrations

Task 16: Create OneDrive Integration (Following Google Drive Pattern)

Files:

  • Create: app/api/onedrive/oauth/route.ts
  • Create: app/api/onedrive/callback/route.ts
  • Create: app/api/onedrive/status/route.ts
  • Create: app/api/onedrive/unlink/route.ts
  • Create: app/api/onedrive/files/route.ts
  • Create: app/api/onedrive/save/route.ts
  • Create: hooks/useOneDrive.ts
  • Create: components/modals/OneDriveModal.tsx
  • Modify: components/sidebar/Sidebar.tsx

Follow the exact same pattern as Google Drive, replacing:

  • API endpoints: Microsoft Graph API (graph.microsoft.com)
  • OAuth URL: login.microsoftonline.com
  • Scopes: Files.ReadWrite
  • Cookie name: onedrive_token
  • Icon: Use CloudCog from lucide-react

Commit after each major piece:

bash
git commit -m "feat: add OneDrive OAuth routes"
git commit -m "feat: add OneDrive files and save routes"
git commit -m "feat: add useOneDrive hook"
git commit -m "feat: add OneDriveModal component"
git commit -m "feat: integrate OneDrive into sidebar"

Task 17: Create Bitbucket Integration (Following GitHub Pattern)

Files:

  • Create: app/api/bitbucket/oauth/route.ts
  • Create: app/api/bitbucket/callback/route.ts
  • Create: app/api/bitbucket/status/route.ts
  • Create: app/api/bitbucket/unlink/route.ts
  • Create: app/api/bitbucket/workspaces/route.ts
  • Create: app/api/bitbucket/repos/route.ts
  • Create: app/api/bitbucket/branches/route.ts
  • Create: app/api/bitbucket/files/route.ts
  • Create: app/api/bitbucket/save/route.ts
  • Create: hooks/useBitbucket.ts
  • Create: components/modals/BitbucketModal.tsx
  • Modify: components/sidebar/Sidebar.tsx

Follow GitHub pattern with Bitbucket API:

  • OAuth URL: bitbucket.org/site/oauth2
  • API: api.bitbucket.org/2.0
  • Uses "workspaces" instead of "orgs"

Commit after each major piece.


Task 18: Create Medium Integration (Publish Only)

Files:

  • Create: app/api/medium/oauth/route.ts
  • Create: app/api/medium/callback/route.ts
  • Create: app/api/medium/status/route.ts
  • Create: app/api/medium/unlink/route.ts
  • Create: app/api/medium/publish/route.ts
  • Create: hooks/useMedium.ts
  • Create: components/modals/MediumPublishModal.tsx
  • Modify: components/navbar/Navbar.tsx (add to export dropdown)
  • Modify: components/sidebar/Sidebar.tsx (add to services)

Simpler pattern - publish only:

  • OAuth via Medium API
  • Publish creates draft post
  • No file browsing needed

Commit after each major piece.


Part 4: File Handling

Task 19: Implement Drag & Drop Import

Files:

  • Modify: components/editor/EditorContainer.tsx

Step 1: Add drag and drop state and handlers

Add state:

typescript
const [isDragging, setIsDragging] = useState(false);

Add to store usage:

typescript
const createDocument = useStore((state) => state.createDocument);
const updateDocumentBody = useStore((state) => state.updateDocumentBody);
const updateDocumentTitle = useStore((state) => state.updateDocumentTitle);

Add handlers:

typescript
const handleDragEnter = (e: React.DragEvent) => {
  e.preventDefault();
  setIsDragging(true);
};

const handleDragLeave = (e: React.DragEvent) => {
  e.preventDefault();
  if (e.currentTarget === e.target) {
    setIsDragging(false);
  }
};

const handleDragOver = (e: React.DragEvent) => {
  e.preventDefault();
};

const handleDrop = (e: React.DragEvent) => {
  e.preventDefault();
  setIsDragging(false);

  const file = e.dataTransfer.files[0];
  if (file && /\.(md|markdown|txt)$/i.test(file.name)) {
    const reader = new FileReader();
    reader.onload = () => {
      createDocument();
      updateDocumentTitle(file.name.replace(/\.(md|markdown|txt)$/i, ""));
      updateDocumentBody(reader.result as string);
      notify("File imported");
    };
    reader.readAsText(file);
  }
};

Step 2: Add drag overlay to render

Wrap the main content div and add overlay:

typescript
<div
  onDragEnter={handleDragEnter}
  onDragLeave={handleDragLeave}
  onDragOver={handleDragOver}
  onDrop={handleDrop}
  className="h-dvh flex overflow-hidden relative"
>
  {isDragging && (
    <div className="absolute inset-0 z-overlay bg-plum/20 flex items-center justify-center pointer-events-none">
      <div className="bg-bg-navbar text-text-invert px-6 py-4 rounded-lg shadow-lg">
        Drop file to import
      </div>
    </div>
  )}
  {/* ...existing content... */}
</div>

Step 3: Verify TypeScript compiles

Run: npx tsc --noEmit
Expected: No errors

Step 4: Commit

bash
git add components/editor/EditorContainer.tsx
git commit -m "feat: implement drag and drop file import"

Task 20: Implement Image Upload

Files:

  • Create: app/api/google-drive/upload-image/route.ts
  • Create: app/api/dropbox/upload-image/route.ts
  • Create: components/modals/ImageUploadModal.tsx
  • Modify: components/editor/MonacoEditor.tsx

Step 1: Create image upload API route for Google Drive

typescript
// app/api/google-drive/upload-image/route.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function POST(request: NextRequest) {
  const cookieStore = await cookies();
  const tokenCookie = cookieStore.get("google_drive_token");

  if (!tokenCookie) {
    return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
  }

  const tokens = JSON.parse(tokenCookie.value);
  const formData = await request.formData();
  const file = formData.get("file") as File;

  if (!file) {
    return NextResponse.json({ error: "No file provided" }, { status: 400 });
  }

  // Upload to Google Drive and get shareable link
  // ... implementation

  return NextResponse.json({ url: shareableUrl });
}

Step 2: Create ImageUploadModal

Modal that shows when user pastes an image, lets them select which service to upload to.

Step 3: Add paste handler to MonacoEditor

Detect image paste, show modal, insert markdown link.

Step 4: Verify and commit

bash
git add app/api/google-drive/upload-image/route.ts app/api/dropbox/upload-image/route.ts
git add components/modals/ImageUploadModal.tsx components/editor/MonacoEditor.tsx
git commit -m "feat: implement image upload to cloud services"

Final Task: Verification

Task 21: Final Verification

Step 1: Run full verification

bash
npx tsc --noEmit && npm run lint

Expected: No errors

Step 2: Start dev server and test

bash
npm run dev

Test checklist:

  • Google Drive: connect, browse, import, save
  • Zen mode: activate with Cmd+Shift+Z, exit with Escape
  • Scroll sync: editor scroll moves preview
  • Drag & drop: drop .md file imports it
  • OneDrive: connect, browse, import, save
  • Bitbucket: connect, navigate repos, import, save
  • Medium: connect, publish document
  • Image upload: paste image, select service, inserts link

Step 3: Final commit

bash
git add -A
git commit -m "feat: complete Phase 3 implementation"

Summary

Phase 3 adds:

  • 4 new cloud integrations (Google Drive, OneDrive, Bitbucket, Medium)
  • Zen mode for distraction-free writing
  • Scroll sync between editor and preview
  • Drag & drop file import
  • Image upload to cloud services

Total new files: ~25
Modified files: ~10
Estimated tasks: 21


Plans CLAUDE (`docs/plans/CLAUDE.md`)

# Recent Activity

Jan 19, 2026

ID Time T Title Read
#1660 10:51 AM โœ… Phase 2 Implementation Plan for GitHub and Dropbox Cloud Integrations ~748
#1557 10:27 AM โš–๏ธ Comprehensive Next.js Migration Implementation Plan Created ~685

--- METRICS ---