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
npm install
npm run devOpen โ 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
Cloud Integrations
- GitHub
- Google Drive
- Dropbox
- OneDrive
- Bitbucket
๐ Environment Setup
Create:
.env.localTemplate
# 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:
/api/google-drive/callbackOneDrive
Azure App Registration
Permissions:
- Files.ReadWrite
- User.Read
Bitbucket
- OAuth consumer
- Repo read/write access
GitHub
- OAuth App
- Callback:
/api/github/callbackDropbox
Scoped app
Enable:
- files.metadata.read
- files.content.write
๐ข Deployment
Update all OAuth callbacks:
https://yourdomain.com/api/{provider}/callbackThen:
npm run build
npm start๐ Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 14 |
| Editor | Monaco |
| Styling | Tailwind CSS |
| State | Zustand |
| Icons | Lucide |
๐ฆ Scripts
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.
npm run test:unit # Run all unit tests
npx vitest run --coverage # With coverage report
npm run test:e2e:headed # E2E in visible browserTests 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
npm install @dillinger/cliSetup
export DILLINGER_API_KEY="your-api-key"Usage
# 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 convertOverview (`packages/mcp/README.md`)
@dillinger/mcp
MCP (Model Context Protocol) server for Dillinger. Lets LLMs use Dillinger as a native markdown tool.
Setup
npm install
npm run buildUsage with Claude Desktop
Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.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:
- Set Root Directory to
next-appin Vercel Dashboard (Settings โ General) - Create
vercel.jsoninsidenext-app/directory (not repository root) - 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:
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:
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:
for file in $(find app/api -name "route.ts"); do
echo 'export const dynamic = "force-dynamic";
' | cat - "$file" > /tmp/tempfile && mv /tmp/tempfile "$file"
doneGoogle 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:
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:
- Check if file with same name exists in target folder
- If exists, use that file ID to update it
- If not, create new file
Implementation:
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:
const authUrl = `https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?${params}`;Problem 2: Graph API returned 401 Unauthorized.
Solution: Added User.Read scope:
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:
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:
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:
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:
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:
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:
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:
.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.filevsdrive- understand access levels- Some APIs require
openidandemailfor userinfo endpoints - Personal vs organizational accounts may have different requirements
API Route Best Practices
- Mark all routes using
cookies()asforce-dynamic - Use server-side filtering when provider API doesn't support it
- Always include
redirect_uriin OAuth token exchange - Handle trailing slashes carefully in REST APIs
Vercel Subdirectory Deployment
- Set Root Directory in dashboard
- Put
vercel.jsonin that subdirectory - Let framework auto-detection work
- Avoid custom build commands unless necessary
References
- Next.js Dynamic Server Usage
- Vercel Root Directory Configuration
- Google Drive API Scopes
- Microsoft Graph API Permissions
- Bitbucket OAuth
CLAUDE (`docs/CLAUDE.md`)
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(orhttps://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:
# 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=productionTesting 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
- NEXT_PUBLIC_APP_URL is critical - All OAuth callbacks use this to construct redirect URIs
- You can keep localhost URLs in OAuth apps for local development alongside production URLs
- OAuth apps allow multiple redirect URIs - add production without removing localhost
- Some providers require review - If you added new scopes, some OAuth apps may need re-approval
- Clear cookies - After deploying, users may need to reconnect their accounts with new tokens
Quick Reference: All Callback URLs
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/callbackPlans 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
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.tsState Management
Zustand Store
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 mountpersist()saves to localStorage (debounced 2 seconds)- Keys:
files,currentDocument,profileV3(compatible with original)
Tailwind Configuration
Design tokens extracted from legacy SCSS _config.scss:
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
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
<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-vimpackage - Emacs:
monaco-emacspackage
Markdown Rendering
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 pluginsAPI 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:
const { notify } = useToast();
notify('Document saved successfully');
notify('Uploading...', 5000); // Custom duration
notify('Processing...', 0); // PersistentIntegration Hooks
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
{
"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
{
"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 arraycurrentDocument- Active documentprofileV3- 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_IDGITHUB_CLIENT_SECRETDROPBOX_APP_KEYDROPBOX_APP_SECRETNEXT_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:
# 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:3000Step 2: Create actual .env.local (user fills in values)
cp .env.local.example .env.localUser must fill in actual OAuth credentials.
Step 3: Ensure .env.local is gitignored
Verify .gitignore contains:
.env*.localStep 4: Commit
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:
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:
/* Detailed source-code truncated for AI context efficiency. */Step 3: Commit
git add app/api/github/oauth/route.ts app/api/github/callback/route.ts && git commit -m "feat: add GitHub OAuth routes"Task 2.2.2: Create GitHub Status and Unlink 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:
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:
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
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:
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:
/* Detailed source-code truncated for AI context efficiency. */Step 3: Create branches route
Create next-app/app/api/github/branches/route.ts:
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:
/* Detailed source-code truncated for AI context efficiency. */Step 5: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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
npm install dropboxStep 2: Commit
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:
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:
/* Detailed source-code truncated for AI context efficiency. */Step 3: Create status route
Create next-app/app/api/dropbox/status/route.ts:
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:
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
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Create save route
Create next-app/app/api/dropbox/save/route.ts:
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
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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
npm run devStep 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
npm run buildExpected: Build succeeds without errors
Step 4: Final commit
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:
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=xxx
DROPBOX_APP_KEY=xxx
DROPBOX_APP_SECRET=xxx
NEXT_PUBLIC_BASE_URL=http://localhost:3000Deferred 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
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-npmStep 2: Verify project created
ls next-app/Expected: app/, public/, package.json, tsconfig.json, tailwind.config.ts
Step 3: Commit
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
cd /Users/joemccann/dev/vercel/dillinger/.worktrees/nextjs-migration/next-app
npm install @monaco-editor/react zustand lucide-reactStep 2: Install markdown rendering stack
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 katexStep 3: Install types
npm install -D @types/markdown-itStep 4: Verify installation
npm ls @monaco-editor/react zustand markdown-itExpected: All packages listed without errors
Step 5: Commit
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:
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:
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
npm run buildExpected: Build succeeds
Step 4: Commit
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:
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
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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:
"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:
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
npm run buildExpected: Build succeeds
Step 4: Commit
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:
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
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:
"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:
/* Detailed source-code truncated for AI context efficiency. */Step 3: Verify build
npm run buildExpected: Build succeeds
Step 4: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Add animation to Tailwind config
Update next-app/tailwind.config.ts to add animation:
/* Detailed source-code truncated for AI context efficiency. */Step 3: Add ToastProvider to layout
Update next-app/app/layout.tsx:
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
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:
"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:
/* Detailed source-code truncated for AI context efficiency. */Step 3: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Verify dev server works
npm run devExpected: App runs at http://localhost:3000 with editor and preview visible
Step 3: Commit
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:
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
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:
/* Detailed source-code truncated for AI context efficiency. */Step 2: Commit
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
npm install md-to-pdfStep 2: Create PDF export API
Create next-app/app/api/export/pdf/route.ts:
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
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
npm run devStep 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
npm run buildExpected: Build succeeds without errors
Step 4: Final commit
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:
- Google Drive integration - OAuth, browse folders, import/save .md files
- OneDrive integration - Same pattern as Google Drive
- Medium integration - Publish documents to Medium as drafts
- Bitbucket integration - Same pattern as GitHub
- Zen mode - Editor-only fullscreen for distraction-free writing
- Scroll sync - Preview follows editor scroll position (on by default)
- File handling - Drag & drop .md import, image upload to cloud
Implementation order:
- Google Drive (establishes patterns for other cloud services)
- Editor UX (Zen mode, scroll sync)
- OneDrive, Bitbucket, Medium
- 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
/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, disconnectImplementation
Direct REST API calls (no SDK) to avoid Next.js server compatibility issues.
Hook Interface
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;
}Modal UI
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:
Escapekey or ร button in corner
Implementation:
// 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
onDidScrollChangeevent 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()mirrorsuseGitHub()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:
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,.txtfiles - Creates new document with file content and filename as title
- Visual feedback: overlay on drag enter
Implementation:
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
interface AppState {
// Existing...
// Phase 3 additions
zenMode: boolean;
setZenMode: (enabled: boolean) => void;
}
interface Settings {
// Existing...
// Phase 3 additions
scrollSync: boolean; // default: true
}UI Updates
Sidebar Services Section
Add to existing services list:
- Google Drive (with connect/unlink)
- OneDrive (with connect/unlink)
- Medium (with connect/unlink)
- Bitbucket (with connect/unlink)
Sidebar Import/Save Sections
Add entries for each new service in "Import from" and "Save to" sections.
Navbar
- 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
# 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
# Google Drive OAuth
GOOGLE_CLIENT_ID=your_client_id_here
GOOGLE_CLIENT_SECRET=your_client_secret_hereStep 2: Verify TypeScript still compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
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
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
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
/* Detailed source-code truncated for AI context efficiency. */Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
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
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
git add app/api/google-drive/status/route.ts
git commit -m "feat: add Google Drive status route"Task 5: Create Google Drive Unlink Route
Files:
- Create:
app/api/google-drive/unlink/route.ts
Step 1: Create the unlink route
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
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
/* Detailed source-code truncated for AI context efficiency. */Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
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
/* Detailed source-code truncated for AI context efficiency. */Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
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
/* Detailed source-code truncated for AI context efficiency. */Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
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)
/* Detailed source-code truncated for AI context efficiency. */Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
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:
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:
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:
<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:
<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:
<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 </>):
<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
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:
zenMode: boolean;
setZenMode: (enabled: boolean) => void;Add to Settings interface (if not already there, add scrollSync):
scrollSync: boolean;Step 2: Add to initial state and actions
In the create function, add to initial state:
zenMode: false,Add to settings default:
scrollSync: true,Add action:
setZenMode: (enabled) => set({ zenMode: enabled }),Step 3: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 4: Commit
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:
import { X, Maximize2 } from "lucide-react";In EditorContent, add:
const zenMode = useStore((state) => state.zenMode);
const setZenMode = useStore((state) => state.setZenMode);Step 2: Add keyboard shortcut effect
Add useEffect for keyboard shortcut:
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:
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
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:
import { Maximize2 } from "lucide-react";Step 2: Add zen mode state
Add to component:
const setZenMode = useStore((state) => state.setZenMode);Step 3: Add zen mode button
Add button in the navbar (before settings button):
<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
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:
editorScrollPercent: number;
setEditorScrollPercent: (percent: number) => void;Add initial state:
editorScrollPercent: 0,Add action:
setEditorScrollPercent: (percent) => set({ editorScrollPercent: percent }),Step 2: Update MonacoEditor to emit scroll position
In MonacoEditor.tsx, add to imports:
import { useStore } from "@/stores/store";Add state:
const settings = useStore((state) => state.settings);
const setEditorScrollPercent = useStore((state) => state.setEditorScrollPercent);In handleEditorDidMount, add scroll listener:
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:
const containerRef = useRef<HTMLDivElement>(null);
const settings = useStore((state) => state.settings);
const editorScrollPercent = useStore((state) => state.editorScrollPercent);Add useEffect for scroll sync:
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:
<div ref={containerRef} className="...existing classes...">Step 4: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors
Step 5: Commit
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:
{/* 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
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
CloudCogfrom lucide-react
Commit after each major piece:
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:
const [isDragging, setIsDragging] = useState(false);Add to store usage:
const createDocument = useStore((state) => state.createDocument);
const updateDocumentBody = useStore((state) => state.updateDocumentBody);
const updateDocumentTitle = useStore((state) => state.updateDocumentTitle);Add handlers:
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:
<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
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
// 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
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
npx tsc --noEmit && npm run lintExpected: No errors
Step 2: Start dev server and test
npm run devTest 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
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`)
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 ---
- Files Extracted: 12
- Estimated Token Budget: ~24583 tokens
- Canonical Reference: https://codewiki.google/github.com/joemccann/dillinger