Phase 1 (Foundation) tasks with step-by-step instructions: - Task 1-2: Project initialization (Vite, React, TypeScript) - Task 3-4: Tailwind CSS + Shadcn/ui configuration - Task 5: Zustand stores (editor, settings, theme, sidebar) - Task 6-8: Platform adapter pattern (types, web, tauri stubs) - Task 9: Tauri project initialization - Task 10: Basic layout components Each task includes: - Exact file paths - Complete code snippets - Build verification steps - Commit messages Amit Haridas
39 KiB
React + Tauri + PWA Migration Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Migrate MarkdownConverter from Electron to a React + Tauri + PWA multi-platform architecture.
Architecture: Single codebase with platform adapters pattern. React components use Zustand for state, Shadcn/ui for UI, CodeMirror 6 for editing. Platform-specific code isolated in adapter files that auto-detect Tauri vs Web environment.
Tech Stack: React 18, TypeScript (strict), Vite, Tailwind CSS, Shadcn/ui, Zustand, CodeMirror 6, Tauri 1.5, Rust
Phase 1: Foundation (Tasks 1-15)
Task 1: Create New Project Directory
Files:
- Create:
markdown-converter-v5/(new directory alongside current project)
Step 1: Create the project directory
mkdir -p C:/coding/markdown-converter-v5
cd C:/coding/markdown-converter-v5
Step 2: Initialize git repository
git init
echo "node_modules/" > .gitignore
echo "dist/" >> .gitignore
echo "src-tauri/target/" >> .gitignore
echo ".tauri/" >> .gitignore
git add .gitignore
git commit -m "chore: initialize project"
Task 2: Initialize Vite + React + TypeScript Project
Files:
- Create:
package.json - Create:
tsconfig.json - Create:
tsconfig.node.json - Create:
vite.config.ts - Create:
index.html - Create:
src/main.tsx - Create:
src/App.tsx - Create:
src/vite-env.d.ts
Step 1: Create package.json
cd C:/coding/markdown-converter-v5
Create package.json:
{
"name": "markdown-converter-v5",
"version": "5.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}
Step 2: Install dependencies
npm install
Expected output:
added X packages in Xs
Step 3: Create tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Step 4: Create tsconfig.node.json
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
Step 5: Create vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
target: 'esnext',
minify: 'esbuild',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
},
},
},
},
server: {
port: 5173,
strictPort: true,
},
});
Step 6: Create index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown Converter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Step 7: Create src/vite-env.d.ts
/// <reference types="vite/client" />
Step 8: Create src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Step 9: Create src/App.tsx
function App() {
return (
<div className="app">
<h1>Markdown Converter v5</h1>
<p>React + Tauri + PWA</p>
</div>
);
}
export default App;
Step 10: Create placeholder CSS
mkdir -p C:/coding/markdown-converter-v5/src/styles
Create src/styles/globals.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
}
.app {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
}
h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
Step 11: Verify build works
npm run build
Expected output:
vite v5.x.x building for production...
✓ X modules transformed.
dist/index.html X.xx kB │ gzip: X.xx kB
dist/assets/index-XXXXXXXX.js X.xx kB │ gzip: X.xx kB
✓ built in X.XXs
Step 12: Commit
git add .
git commit -m "chore: initialize Vite + React + TypeScript project"
Task 3: Configure Tailwind CSS
Files:
- Modify:
package.json - Create:
tailwind.config.ts - Create:
postcss.config.js - Modify:
src/styles/globals.css
Step 1: Install Tailwind dependencies
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Expected output:
Created tailwind.config.js
Created postcss.config.js
Step 2: Update tailwind.config.ts
Rename tailwind.config.js to tailwind.config.ts and replace content:
import type { Config } from 'tailwindcss';
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
},
},
plugins: [],
} satisfies Config;
Step 3: Update globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom base styles */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 227 44% 52%;
--primary-foreground: 0 0% 100%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 227 44% 52%;
--primary-foreground: 0 0% 100%;
}
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
Step 4: Update App.tsx to use Tailwind
function App() {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background">
<h1 className="text-4xl font-bold text-primary mb-4">
Markdown Converter v5
</h1>
<p className="text-muted-foreground">React + Tauri + PWA</p>
</div>
);
}
export default App;
Step 5: Verify build still works
npm run build
Expected: Build succeeds without errors
Step 6: Commit
git add .
git commit -m "chore: configure Tailwind CSS"
Task 4: Setup Shadcn/ui
Files:
- Modify:
package.json - Create:
components.json - Create:
src/lib/utils.ts - Create:
src/components/ui/button.tsx
Step 1: Install Shadcn/ui dependencies
npm install class-variance-authority clsx tailwind-merge
npm install -D @types/node
Step 2: Create components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
Step 3: Create src/lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Step 4: Update tailwind.config.ts with Shadcn colors
import type { Config } from 'tailwindcss';
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
Step 5: Install tailwindcss-animate
npm install -D tailwindcss-animate
Step 6: Update globals.css with full Shadcn variables
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 227 44% 52%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 227 44% 52%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 227 44% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 227 44% 52%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
Step 7: Verify build
npm run build
Step 8: Commit
git add .
git commit -m "chore: setup Shadcn/ui configuration"
Task 5: Create Zustand Stores
Files:
- Create:
src/stores/editorStore.ts - Create:
src/stores/settingsStore.ts - Create:
src/stores/themeStore.ts - Create:
src/stores/sidebarStore.ts - Create:
src/types/editor.ts
Step 1: Create type definitions
Create src/types/editor.ts:
export interface CursorPosition {
line: number;
column: number;
}
export interface Tab {
id: string;
title: string;
content: string;
filePath?: string;
isDirty: boolean;
cursorPosition: CursorPosition;
}
export interface FileInfo {
name: string;
isDir: boolean;
size: number;
modified: number;
}
Step 2: Create editorStore.ts
Create src/stores/editorStore.ts:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { Tab, CursorPosition } from '@/types/editor';
interface EditorState {
tabs: Tab[];
activeTabId: string | null;
createTab: (title?: string, content?: string) => string;
closeTab: (id: string) => void;
setActiveTab: (id: string) => void;
updateContent: (id: string, content: string) => void;
updateCursorPosition: (id: string, pos: CursorPosition) => void;
markSaved: (id: string, filePath?: string) => void;
getActiveTab: () => Tab | null;
}
export const useEditorStore = create<EditorState>()(
persist(
(set, get) => ({
tabs: [],
activeTabId: null,
createTab: (title = 'Untitled', content = '') => {
const id = crypto.randomUUID();
const tab: Tab = {
id,
title,
content,
isDirty: false,
cursorPosition: { line: 1, column: 1 },
};
set((state) => ({
tabs: [...state.tabs, tab],
activeTabId: id,
}));
return id;
},
closeTab: (id) => {
set((state) => {
const newTabs = state.tabs.filter((t) => t.id !== id);
const newActiveId =
state.activeTabId === id
? newTabs[newTabs.length - 1]?.id ?? null
: state.activeTabId;
return { tabs: newTabs, activeTabId: newActiveId };
});
},
setActiveTab: (id) => set({ activeTabId: id }),
updateContent: (id, content) => {
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === id ? { ...tab, content, isDirty: true } : tab
),
}));
},
updateCursorPosition: (id, pos) => {
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === id ? { ...tab, cursorPosition: pos } : tab
),
}));
},
markSaved: (id, filePath) => {
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === id
? { ...tab, isDirty: false, filePath, title: filePath?.split('/').pop() ?? tab.title }
: tab
),
}));
},
getActiveTab: () => {
const state = get();
return state.tabs.find((t) => t.id === state.activeTabId) ?? null;
},
}),
{
name: 'editor-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
tabs: state.tabs.map((t) => ({ ...t, content: '' })), // Don't persist content
}),
}
)
);
Step 3: Create settingsStore.ts
Create src/stores/settingsStore.ts:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type PreviewMode = 'split' | 'editor' | 'preview';
interface SettingsState {
fontSize: number;
fontFamily: string;
previewMode: PreviewMode;
showLineNumbers: boolean;
wordWrap: boolean;
autoSave: boolean;
autoSaveInterval: number;
setFontSize: (size: number) => void;
setFontFamily: (family: string) => void;
setPreviewMode: (mode: PreviewMode) => void;
toggleLineNumbers: () => void;
toggleWordWrap: () => void;
toggleAutoSave: () => void;
setAutoSaveInterval: (interval: number) => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
fontSize: 14,
fontFamily: 'JetBrains Mono',
previewMode: 'split',
showLineNumbers: true,
wordWrap: true,
autoSave: true,
autoSaveInterval: 30000,
setFontSize: (fontSize) => set({ fontSize }),
setFontFamily: (fontFamily) => set({ fontFamily }),
setPreviewMode: (previewMode) => set({ previewMode }),
toggleLineNumbers: () => set((state) => ({ showLineNumbers: !state.showLineNumbers })),
toggleWordWrap: () => set((state) => ({ wordWrap: !state.wordWrap })),
toggleAutoSave: () => set((state) => ({ autoSave: !state.autoSave })),
setAutoSaveInterval: (autoSaveInterval) => set({ autoSaveInterval }),
}),
{ name: 'settings-storage' }
)
);
Step 4: Create themeStore.ts
Create src/stores/themeStore.ts:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type ThemeName =
| 'github-light'
| 'github-dark'
| 'solarized'
| 'monokai'
| 'dracula'
| 'nord'
| 'onedark'
| 'atomonelight'
| 'material'
| 'gruvbox-dark'
| 'gruvbox-light'
| 'tokyonight'
| 'palenight';
interface ThemeState {
theme: ThemeName;
isDark: boolean;
setTheme: (theme: ThemeName) => void;
toggleDarkMode: () => void;
}
const darkThemes: ThemeName[] = [
'github-dark',
'monokai',
'dracula',
'nord',
'onedark',
'material',
'gruvbox-dark',
'tokyonight',
'palenight',
];
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'github-light',
isDark: false,
setTheme: (theme) => {
const isDark = darkThemes.includes(theme);
set({ theme, isDark });
// Update document class for Tailwind dark mode
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
},
toggleDarkMode: () => {
set((state) => {
const newIsDark = !state.isDark;
if (newIsDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
return { isDark: newIsDark };
});
},
}),
{ name: 'theme-storage' }
)
);
Step 5: Create sidebarStore.ts
Create src/stores/sidebarStore.ts:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type SidebarPanel = 'explorer' | 'git' | 'snippets' | 'templates';
interface SidebarState {
activePanel: SidebarPanel;
collapsed: boolean;
width: number;
setActivePanel: (panel: SidebarPanel) => void;
toggleCollapse: () => void;
setWidth: (width: number) => void;
}
export const useSidebarStore = create<SidebarState>()(
persist(
(set) => ({
activePanel: 'explorer',
collapsed: false,
width: 280,
setActivePanel: (activePanel) => set({ activePanel, collapsed: false }),
toggleCollapse: () => set((state) => ({ collapsed: !state.collapsed })),
setWidth: (width) => set({ width }),
}),
{ name: 'sidebar-storage' }
)
);
Step 6: Create stores index
Create src/stores/index.ts:
export { useEditorStore } from './editorStore';
export { useSettingsStore } from './settingsStore';
export { useThemeStore } from './themeStore';
export { useSidebarStore } from './sidebarStore';
Step 7: Verify build
npm run build
Step 8: Commit
git add .
git commit -m "feat: add Zustand stores for editor, settings, theme, and sidebar"
Task 6: Create Platform Adapter Types
Files:
- Create:
src/adapters/types.ts - Create:
src/types/platform.ts
Step 1: Create platform types
Create src/types/platform.ts:
import type { FileInfo } from './editor';
export interface PdfOptions {
paperSize: string;
margins: string;
header?: string;
footer?: string;
toc: boolean;
}
export interface DocxOptions {
template?: string;
margins?: string;
}
export interface HtmlOptions {
standalone: boolean;
includeCss: boolean;
}
export interface ConversionResult {
input: string;
output: string;
success: boolean;
error?: string;
}
export interface WatchCallback {
(event: { type: string; path: string }): void;
}
export interface PlatformCapabilities {
hasPandoc: boolean;
hasFfmpeg: boolean;
hasLibreOffice: boolean;
hasDirectFs: boolean;
hasSystemNotifications: boolean;
}
export interface FileSystemAdapter {
readFile: (path: string) => Promise<string>;
writeFile: (path: string, content: string) => Promise<void>;
deleteFile: (path: string) => Promise<void>;
listDirectory: (path: string) => Promise<FileInfo[]>;
exists: (path: string) => Promise<boolean>;
watchDirectory?: (path: string, callback: WatchCallback) => () => void;
}
export interface ConversionAdapter {
toPdf: (content: string, options: PdfOptions, outputPath: string) => Promise<string>;
toDocx: (content: string, options: DocxOptions, outputPath: string) => Promise<string>;
toHtml: (content: string, options: HtmlOptions) => Promise<string>;
batchConvert: (files: string[], outputDir: string, format: string) => Promise<ConversionResult[]>;
}
Step 2: Create adapter types
Create src/adapters/types.ts:
import type {
FileSystemAdapter,
ConversionAdapter,
PlatformCapabilities,
} from '@/types/platform';
export interface PlatformAdapter {
readonly name: 'tauri' | 'web';
readonly fs: FileSystemAdapter;
readonly convert: ConversionAdapter;
readonly capabilities: PlatformCapabilities;
}
Step 3: Verify build
npm run build
Step 4: Commit
git add .
git commit -m "feat: add platform adapter type definitions"
Task 7: Create Web Platform Adapter
Files:
- Create:
src/adapters/web/index.ts - Create:
src/adapters/web/fs.ts - Create:
src/adapters/web/convert.ts - Create:
src/adapters/web/system.ts - Create:
src/lib/db.ts
Step 1: Create IndexedDB helper
Create src/lib/db.ts:
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
interface MarkdownConverterDB extends DBSchema {
files: {
key: string;
value: {
path: string;
content: string;
modified: number;
size: number;
};
};
settings: {
key: string;
value: unknown;
};
}
let db: IDBPDatabase<MarkdownConverterDB> | null = null;
export async function getDB(): Promise<IDBPDatabase<MarkdownConverterDB>> {
if (db) return db;
db = await openDB<MarkdownConverterDB>('markdown-converter', 1, {
upgrade(database) {
database.createObjectStore('files', { keyPath: 'path' });
database.createObjectStore('settings');
},
});
return db;
}
export const dbStorage = {
async saveFile(path: string, content: string): Promise<void> {
const database = await getDB();
await database.put('files', {
path,
content,
modified: Date.now(),
size: content.length,
});
},
async getFile(path: string): Promise<string | undefined> {
const database = await getDB();
const file = await database.get('files', path);
return file?.content;
},
async deleteFile(path: string): Promise<void> {
const database = await getDB();
await database.delete('files', path);
},
async listFiles(): Promise<string[]> {
const database = await getDB();
return (await database.getAllKeys('files')) as string[];
},
};
Step 2: Install idb
npm install idb
Step 3: Create web fs adapter
Create src/adapters/web/fs.ts:
import type { FileSystemAdapter } from '@/types/platform';
import type { FileInfo } from '@/types/editor';
import { dbStorage } from '@/lib/db';
export const webFsAdapter: FileSystemAdapter = {
async readFile(path: string): Promise<string> {
const content = await dbStorage.getFile(path);
if (content === undefined) {
throw new Error(`File not found: ${path}`);
}
return content;
},
async writeFile(path: string, content: string): Promise<void> {
await dbStorage.saveFile(path, content);
},
async deleteFile(path: string): Promise<void> {
await dbStorage.deleteFile(path);
},
async listDirectory(_path: string): Promise<FileInfo[]> {
// In web mode, we use a flat file list from IndexedDB
const paths = await dbStorage.listFiles();
return paths.map((path) => ({
name: path.split('/').pop() ?? path,
isDir: false,
size: 0,
modified: Date.now(),
}));
},
async exists(path: string): Promise<boolean> {
const content = await dbStorage.getFile(path);
return content !== undefined;
},
};
Step 4: Create web convert adapter
Create src/adapters/web/convert.ts:
import type { ConversionAdapter } from '@/types/platform';
export const webConvertAdapter: ConversionAdapter = {
async toPdf(_content: string, _options, _outputPath): Promise<string> {
// Web uses WASM or cloud service - placeholder for now
throw new Error('PDF conversion requires desktop app or cloud service');
},
async toDocx(_content: string, _options, _outputPath): Promise<string> {
throw new Error('DOCX conversion requires desktop app');
},
async toHtml(content: string, options): Promise<string> {
// Basic HTML conversion using marked (will be implemented later)
const { marked } = await import('marked');
const html = await marked(content);
if (options.standalone) {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Exported Markdown</title>
${options.includeCss ? '<link rel="stylesheet" href="styles.css">' : ''}
</head>
<body>
${html}
</body>
</html>`;
}
return html;
},
async batchConvert(_files: string[], _outputDir: string, _format: string) {
throw new Error('Batch conversion requires desktop app');
},
};
Step 5: Create web system adapter
Create src/adapters/web/system.ts:
import type { PlatformCapabilities } from '@/types/platform';
export const webCapabilities: PlatformCapabilities = {
hasPandoc: false,
hasFfmpeg: false,
hasLibreOffice: false,
hasDirectFs: false,
hasSystemNotifications: 'Notification' in window,
};
Step 6: Create web adapter index
Create src/adapters/web/index.ts:
import type { PlatformAdapter } from '../types';
import { webFsAdapter } from './fs';
import { webConvertAdapter } from './convert';
import { webCapabilities } from './system';
export const webAdapter: PlatformAdapter = {
name: 'web',
fs: webFsAdapter,
convert: webConvertAdapter,
capabilities: webCapabilities,
};
Step 7: Install marked
npm install marked
Step 8: Verify build
npm run build
Step 9: Commit
git add .
git commit -m "feat: add web platform adapter with IndexedDB storage"
Task 8: Create Tauri Platform Adapter (Stub)
Files:
- Create:
src/adapters/tauri/index.ts - Create:
src/adapters/tauri/fs.ts - Create:
src/adapters/tauri/convert.ts - Create:
src/adapters/tauri/system.ts
Step 1: Create tauri fs adapter stub
Create src/adapters/tauri/fs.ts:
import type { FileSystemAdapter } from '@/types/platform';
import type { FileInfo } from '@/types/editor';
// These will be implemented when Tauri is set up
export const tauriFsAdapter: FileSystemAdapter = {
async readFile(path: string): Promise<string> {
const { invoke } = await import('@tauri-apps/api/tauri');
return invoke('read_file', { path });
},
async writeFile(path: string, content: string): Promise<void> {
const { invoke } = await import('@tauri-apps/api/tauri');
await invoke('write_file', { path, content });
},
async deleteFile(path: string): Promise<void> {
const { invoke } = await import('@tauri-apps/api/tauri');
await invoke('delete_file', { path });
},
async listDirectory(path: string): Promise<FileInfo[]> {
const { invoke } = await import('@tauri-apps/api/tauri');
return invoke('list_directory', { path });
},
async exists(path: string): Promise<boolean> {
const { invoke } = await import('@tauri-apps/api/tauri');
return invoke('path_exists', { path });
},
watchDirectory(path: string, callback) {
// Will use Tauri events for file watching
console.log('File watching not yet implemented for:', path);
return () => {};
},
};
Step 2: Create tauri convert adapter stub
Create src/adapters/tauri/convert.ts:
import type { ConversionAdapter } from '@/types/platform';
export const tauriConvertAdapter: ConversionAdapter = {
async toPdf(content: string, options, outputPath): Promise<string> {
const { invoke } = await import('@tauri-apps/api/tauri');
return invoke('convert_to_pdf', { content, options, outputPath });
},
async toDocx(content: string, options, outputPath): Promise<string> {
const { invoke } = await import('@tauri-apps/api/tauri');
return invoke('convert_to_docx', { content, options, outputPath });
},
async toHtml(content: string, options): Promise<string> {
const { invoke } = await import('@tauri-apps/api/tauri');
return invoke('convert_to_html', { content, options });
},
async batchConvert(files: string[], outputDir: string, format: string) {
const { invoke } = await import('@tauri-apps/api/tauri');
return invoke('batch_convert', { files, outputDir, format });
},
};
Step 3: Create tauri system adapter stub
Create src/adapters/tauri/system.ts:
import type { PlatformCapabilities } from '@/types/platform';
export const tauriCapabilities: PlatformCapabilities = {
hasPandoc: true, // Will be detected at runtime
hasFfmpeg: true,
hasLibreOffice: true,
hasDirectFs: true,
hasSystemNotifications: true,
};
Step 4: Create tauri adapter index
Create src/adapters/tauri/index.ts:
import type { PlatformAdapter } from '../types';
import { tauriFsAdapter } from './fs';
import { tauriConvertAdapter } from './convert';
import { tauriCapabilities } from './system';
export const tauriAdapter: PlatformAdapter = {
name: 'tauri',
fs: tauriFsAdapter,
convert: tauriConvertAdapter,
capabilities: tauriCapabilities,
};
Step 5: Create main adapter export with platform detection
Create src/adapters/index.ts:
import type { PlatformAdapter } from './types';
import { webAdapter } from './web';
import { tauriAdapter } from './tauri';
// Detect platform at runtime
const isTauri = typeof window !== 'undefined' && '__TAURI__' in window;
export const adapter: PlatformAdapter = isTauri ? tauriAdapter : webAdapter;
export type { PlatformAdapter } from './types';
Step 6: Verify build
npm run build
Step 7: Commit
git add .
git commit -m "feat: add Tauri platform adapter stubs"
Task 9: Initialize Tauri Project
Files:
- Create:
src-tauri/directory with Tauri configuration
Step 1: Install Tauri CLI
npm install -D @tauri-apps/cli
Step 2: Initialize Tauri
npm run tauri init -- --app-name "Markdown Converter" --window-title "Markdown Converter" --dev-url http://localhost:5173 --before-dev-command "npm run dev" --before-build-command "npm run build"
Expected output:
[1/3] Doing some setup...
[2/3] Building initial Tauri app...
[3/3] Setup complete!
Step 3: Update tauri.conf.json
After initialization, update src-tauri/tauri.conf.json:
{
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devPath": "http://localhost:5173",
"distDir": "../dist"
},
"package": {
"productName": "Markdown Converter",
"version": "5.0.0"
},
"tauri": {
"allowlist": {
"all": false,
"fs": {
"all": true,
"scope": ["$HOME/**", "$DOCUMENT/**", "$DOWNLOAD/**"]
},
"path": {
"all": true
},
"shell": {
"all": true,
"execute": true,
"scope": [
{
"name": "pandoc",
"cmd": "pandoc",
"args": true
},
{
"name": "ffmpeg",
"cmd": "ffmpeg",
"args": true
}
]
},
"dialog": {
"all": true
},
"notification": {
"all": true
}
},
"bundle": {
"active": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.concreteinfo.markdownconverter",
"targets": "all"
},
"security": {
"csp": null
},
"windows": [
{
"title": "Markdown Converter",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false
}
]
}
}
Step 4: Verify Tauri builds
npm run tauri build -- --debug
Note: This may fail if Rust toolchain is not installed. Install from https://rustup.rs
Step 5: Commit
git add .
git commit -m "chore: initialize Tauri project structure"
Task 10: Create Basic Layout Components
Files:
- Create:
src/components/layout/MainLayout.tsx - Create:
src/components/layout/TitleBar.tsx - Create:
src/components/layout/StatusBar.tsx - Create:
src/components/layout/index.ts
Step 1: Create TitleBar component
Create src/components/layout/TitleBar.tsx:
import { useThemeStore } from '@/stores';
export function TitleBar() {
const { isDark, toggleDarkMode } = useThemeStore();
// Only show custom title bar in Tauri
const isTauri = typeof window !== 'undefined' && '__TAURI__' in window;
if (!isTauri) return null;
return (
<div
className="h-8 bg-secondary flex items-center justify-between px-4"
data-tauri-drag-region
>
<span className="text-sm font-medium" data-tauri-drag-region>
Markdown Converter
</span>
<button
onClick={toggleDarkMode}
className="p-1 hover:bg-accent rounded"
aria-label="Toggle dark mode"
>
{isDark ? '☀️' : '🌙'}
</button>
</div>
);
}
Step 2: Create StatusBar component
Create src/components/layout/StatusBar.tsx:
import { useEditorStore, useSettingsStore } from '@/stores';
export function StatusBar() {
const { tabs, activeTabId } = useEditorStore();
const { fontSize, previewMode } = useSettingsStore();
const activeTab = tabs.find((t) => t.id === activeTabId);
const lineCount = activeTab?.content.split('\n').length ?? 0;
const cursorPos = activeTab?.cursorPosition ?? { line: 1, column: 1 };
return (
<div className="h-6 bg-secondary border-t border-border flex items-center justify-between px-4 text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span>Ln {cursorPos.line}, Col {cursorPos.column}</span>
<span>{lineCount} lines</span>
</div>
<div className="flex items-center gap-4">
<span>{fontSize}px</span>
<span className="capitalize">{previewMode}</span>
<span>UTF-8</span>
</div>
</div>
);
}
Step 3: Create MainLayout component
Create src/components/layout/MainLayout.tsx:
import { ReactNode } from 'react';
import { TitleBar } from './TitleBar';
import { StatusBar } from './StatusBar';
interface MainLayoutProps {
children: ReactNode;
}
export function MainLayout({ children }: MainLayoutProps) {
return (
<div className="flex flex-col h-screen bg-background text-foreground">
<TitleBar />
<main className="flex-1 overflow-hidden">{children}</main>
<StatusBar />
</div>
);
}
Step 4: Create index export
Create src/components/layout/index.ts:
export { MainLayout } from './MainLayout';
export { TitleBar } from './TitleBar';
export { StatusBar } from './StatusBar';
Step 5: Update App.tsx to use layout
Update src/App.tsx:
import { MainLayout } from '@/components/layout';
import { useEditorStore } from '@/stores';
function App() {
const { tabs, activeTabId, createTab, updateContent } = useEditorStore();
// Create initial tab if none exists
if (tabs.length === 0) {
createTab('Welcome');
}
const activeTab = tabs.find((t) => t.id === activeTabId);
return (
<MainLayout>
<div className="flex h-full">
{/* Editor Pane */}
<div className="flex-1 p-4 border-r border-border">
<textarea
className="w-full h-full bg-background border-none outline-none resize-none font-mono text-sm"
value={activeTab?.content ?? ''}
onChange={(e) => {
if (activeTabId) {
updateContent(activeTabId, e.target.value);
}
}}
placeholder="Start typing markdown..."
/>
</div>
{/* Preview Pane */}
<div className="flex-1 p-4 overflow-auto">
<div className="prose prose-sm max-w-none">
<p className="text-muted-foreground">
Preview will be implemented with marked + highlight.js
</p>
</div>
</div>
</div>
</MainLayout>
);
}
export default App;
Step 6: Verify build
npm run build
Step 7: Commit
git add .
git commit -m "feat: add basic layout components (TitleBar, StatusBar, MainLayout)"
Phase 1 Summary
After completing Tasks 1-10, you will have:
- ✅ New project initialized with Vite + React + TypeScript
- ✅ Tailwind CSS configured with Shadcn/ui design tokens
- ✅ Zustand stores for state management
- ✅ Platform adapter pattern with web implementation
- ✅ Tauri project structure initialized
- ✅ Basic layout components
Next Phase: Core Editor (CodeMirror integration, Markdown preview, themes)
Remaining Phases (High-Level)
Phase 2: Core Editor (Week 3-4)
- Task 11: Integrate CodeMirror 6 editor
- Task 12: Create MarkdownPreview component
- Task 13: Implement theme system
- Task 14: Add tab management UI
- Task 15: Implement keyboard shortcuts
Phase 3: Sidebar & Panels (Week 5)
- Task 16: Create collapsible sidebar
- Task 17: Implement ExplorerPanel
- Task 18: Implement GitPanel
- Task 19: Implement SnippetsPanel
- Task 20: Implement TemplatesPanel
Phase 4: Platform Adapters (Week 6)
- Task 21: Complete Tauri Rust commands
- Task 22: Add capability detection
- Task 23: Implement file watching
Phase 5: Export & Conversion (Week 7)
- Task 24-30: Export dialogs
Phase 6: PWA & Polish (Week 8)
- Task 31-35: PWA configuration and optimization
Plan generated: 2026-03-15 Based on design: docs/plans/2026-03-15-react-tauri-pwa-design.md