feat: Initialize IF.docs.md2html - Magazine-style Markdown viewer

Features:
- Magazine-style layout with CSS Grid (2-column + sidebar)
- ICW NextSpread design system (colors, typography, animations)
- Adaptive animation timing based on scroll speed
- URL query parameter support (?parse=https://url/file.md)
- Drag-and-drop file upload
- Syntax highlighting (VS Code Dark+ theme)
- Full-bleed hero section with gradient overlay
- Responsive design (mobile-first, 44px touch targets)
- Static export ready for StackCP deployment

Tech Stack:
- Next.js 14 (static export)
- React 18 + TypeScript
- Framer Motion (animations)
- markdown-it + highlight.js
- Tailwind CSS

Design Ported From:
- ICW NextSpread property pages (icantwait.ca)
- useAdaptiveDuration, useScrollSpeed animation hooks
- Webflow-style easing curves
- Gallery staggered reveal patterns

Deployment:
- Configured for digital-lab.ca/infrafabric/IF.docs.md2html
- .htaccess with SPA routing and CORS headers
- Static files in /out/ directory

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Danny Stocker 2025-11-14 17:51:52 +01:00
commit f950357096
24 changed files with 3733 additions and 0 deletions

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

37
.htaccess Normal file
View file

@ -0,0 +1,37 @@
# IF.docs.md2html - StackCP deployment config
# Enable URL rewriting
RewriteEngine On
RewriteBase /infrafabric/IF.docs.md2html/
# Handle Next.js static export routing
# Allow access to static files
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Route all other requests to index.html (SPA)
RewriteRule ^(.*)$ index.html [L,QSA]
# CORS headers for fetching external markdown files
<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type"
</IfModule>
# Gzip compression for text files
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/json
</IfModule>
# Browser caching
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/html "access plus 1 hour"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
</IfModule>

165
DEPLOY.md Normal file
View file

@ -0,0 +1,165 @@
# Deployment Instructions for StackCP
## Prerequisites
- SSH access to StackCP server
- `digital-lab.ca` domain configured
## Deployment Steps
### 1. Build Production Version
```bash
cd /home/setup/IF.docs.md2html
npm run build
```
This creates `/out/` directory with static files.
### 2. Deploy to StackCP
**Option A: Manual SCP Upload**
```bash
# From local machine
scp -r out/* user@stackcp-server:~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/
# Copy .htaccess
scp .htaccess user@stackcp-server:~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/
```
**Option B: SSH and Copy**
```bash
# SSH into StackCP
ssh user@stackcp-server
# Create directory if not exists
mkdir -p ~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html
# From local machine, rsync the files
rsync -avz --delete out/ user@stackcp-server:~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/
```
### 3. Set Permissions
```bash
# SSH into StackCP
chmod -R 755 ~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/
chmod 644 ~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/.htaccess
```
### 4. Test Deployment
Open in browser:
```
https://digital-lab.ca/infrafabric/IF.docs.md2html
```
Test URL parsing:
```
https://digital-lab.ca/infrafabric/IF.docs.md2html?parse=https://raw.githubusercontent.com/dannystocker/infrafabric-core/main/README.md
```
## Troubleshooting
### 404 Errors on Refresh
- Check `.htaccess` is present and readable
- Verify `mod_rewrite` is enabled in Apache
- Check RewriteBase matches deployment path
### CORS Errors Loading External URLs
- Verify CORS headers in `.htaccess`
- Check external URL allows cross-origin requests
- Test with raw GitHub URLs (they support CORS)
### Blank Page
- Check browser console for errors
- Verify basePath in `next.config.mjs` matches deployment path
- Test with `npm run dev` locally first
### Styles Not Loading
- Check `_next/static/` directory exists
- Verify file permissions (755 for dirs, 644 for files)
- Hard refresh browser (Ctrl+Shift+R)
## File Structure on Server
```
~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/
├── _next/
│ ├── static/
│ │ ├── chunks/
│ │ └── css/
│ └── data/
├── index.html
├── 404.html
└── .htaccess
```
## Quick Deploy Script
Create `deploy.sh`:
```bash
#!/bin/bash
set -e
echo "Building production version..."
npm run build
echo "Deploying to StackCP..."
rsync -avz --delete \
--exclude='.git' \
--exclude='node_modules' \
out/ user@stackcp-server:~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/
echo "Copying .htaccess..."
scp .htaccess user@stackcp-server:~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/
echo "Setting permissions..."
ssh user@stackcp-server "chmod -R 755 ~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/ && chmod 644 ~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/.htaccess"
echo "✅ Deployment complete!"
echo "🌐 https://digital-lab.ca/infrafabric/IF.docs.md2html"
```
Make executable:
```bash
chmod +x deploy.sh
```
Run:
```bash
./deploy.sh
```
## Updating After Changes
1. Make code changes
2. Test locally: `npm run dev`
3. Build: `npm run build`
4. Deploy: `./deploy.sh` or manual SCP
## Rollback
Keep backups of `/out/` directory:
```bash
# Before deployment
cp -r out out-backup-$(date +%Y%m%d-%H%M%S)
```
To rollback:
```bash
rsync -avz --delete out-backup-YYYYMMDD-HHMMSS/ user@stackcp-server:~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/
```

176
README.md Normal file
View file

@ -0,0 +1,176 @@
# IF.docs - Magazine-Style Markdown Viewer
Beautiful markdown documentation viewer with **ICW NextSpread design system**.
## Features
- **Magazine-style layout** with CSS Grid (2-column text, sticky sidebar)
- **ICW animations** (adaptive duration, scroll-reveal, staggered entrance)
- **Full-bleed hero** section with gradient overlay
- **Syntax highlighting** with VS Code Dark+ theme
- **Drag-and-drop** file upload
- **URL parsing** via query parameter: `?parse=https://url/file.md`
- **Responsive design** (mobile-first, 44px+ touch targets)
- **Accessibility** (reduced motion support, semantic HTML)
## Design System
Ported from **ICW NextSpread** property pages:
- Typography: Inter (headings), System fonts (body), JetBrains Mono (code)
- Colors: Neutral palette (50-900), Primary green (#10B981)
- Animations: Webflow ease curves, adaptive timing
- Layout: 8-point grid, generous white space
- Components: Hero, Gallery-style staggered reveals
## Usage
### Local Development
```bash
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000)
### File Upload
1. Drag & drop `.md` or `.markdown` files
2. Or click "Choose File" to browse
### URL Parsing
Load any publicly accessible markdown file:
```
http://localhost:3000?parse=https://raw.githubusercontent.com/user/repo/main/README.md
```
**Production Example:**
```
https://digital-lab.ca/infrafabric/IF.docs.md2html?parse=https://example.com/docs/api.md
```
## Deployment to StackCP
### 1. Build for Production
```bash
npm run build
```
This generates a static export in `/out/` directory.
### 2. Deploy to StackCP
```bash
# SSH into StackCP
ssh user@stackcp-server
# Create directory
mkdir -p ~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html
# Copy build files
scp -r out/* user@stackcp-server:~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/
```
### 3. Configure .htaccess
Create `~/public_html/digital-lab.ca/infrafabric/IF.docs.md2html/.htaccess`:
```apache
# Enable URL rewriting
RewriteEngine On
RewriteBase /infrafabric/IF.docs.md2html/
# Route all requests to index.html (SPA)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.html [L,QSA]
# CORS headers for fetching external markdown
<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin "*"
</IfModule>
```
### 4. Test Deployment
```
https://digital-lab.ca/infrafabric/IF.docs.md2html
https://digital-lab.ca/infrafabric/IF.docs.md2html?parse=https://example.com/file.md
```
## Project Structure
```
IF.docs.md2html/
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout
│ │ └── page.tsx # Home page with URL parsing
│ ├── components/
│ │ ├── FileUploader.tsx # Drag-and-drop UI
│ │ ├── HeroSection.tsx # ICW-style hero
│ │ ├── MagazineContent.tsx # Magazine layout
│ │ └── MarkdownViewer.tsx # Main viewer
│ ├── lib/
│ │ ├── markdown-parser.ts # markdown-it config
│ │ ├── useAdaptiveDuration.ts # ICW animation hook
│ │ ├── useScrollSpeed.ts # Scroll velocity tracker
│ │ └── useClientReady.ts # Hydration guard
│ └── styles/
│ ├── globals.css # Base styles + Tailwind
│ ├── markdown.css # Prose styling
│ └── highlight.css # Code syntax theme
├── package.json
├── next.config.mjs
├── tailwind.config.js
└── README.md
```
## ICW Design System Features
### Animations
- **Adaptive Duration**: Speeds up during fast scrolling
- **Staggered Reveals**: Gallery-style cascade (50ms delay per element)
- **Scroll Triggers**: IntersectionObserver with 10% threshold
- **Reduced Motion**: Respects `prefers-reduced-motion`
### Typography
- **Drop Cap**: First paragraph first letter (6xl, float left)
- **Lead Paragraph**: First paragraph 2xl size
- **Headings**: -0.02em letter spacing (optical tightening)
- **Body**: 1.75 line height (comfortable reading)
### Layout
- **Magazine Grid**: 2fr main column + 1fr sidebar (desktop)
- **Full-Bleed Hero**: 40vh mobile, 50vh desktop
- **Sticky Sidebar**: `sticky top-8` navigation
- **Max Width**: 1280px container, centered
## Browser Support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Mobile browsers (iOS 14+, Android Chrome 90+)
## Dependencies
- **next** (^14.2.0) - React framework
- **react** (^18.3.0) - UI library
- **framer-motion** (^11.0.0) - Animations
- **markdown-it** (^14.1.0) - Markdown parser
- **highlight.js** (^11.9.0) - Syntax highlighting
- **tailwindcss** (^3.4.0) - Utility CSS
## License
MIT
## Credits
Design system ported from **ICW NextSpread** (icantwait.ca property showcase).
Animation patterns inspired by Webflow luxury sites and Airbnb editorial pages.

15
next.config.mjs Normal file
View file

@ -0,0 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'export',
basePath: '/infrafabric/IF.docs.md2html',
images: {
unoptimized: true,
},
webpack: (config) => {
config.resolve.fallback = { fs: false, path: false };
return config;
},
};
export default nextConfig;

2238
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "if-docs-md2html",
"version": "0.1.0",
"private": true,
"description": "Magazine-style Markdown viewer with ICW NextSpread design system",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"framer-motion": "^11.0.0",
"markdown-it": "^14.1.0",
"highlight.js": "^11.9.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/markdown-it": "^14.1.0",
"typescript": "^5",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

22
src/app/layout.tsx Normal file
View file

@ -0,0 +1,22 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import '../styles/globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'IF.docs - Magazine-Style Markdown Viewer',
description: 'Beautiful markdown documentation viewer with InfraFabric design system',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

19
src/app/page.tsx Normal file
View file

@ -0,0 +1,19 @@
'use client';
import { Suspense } from 'react';
import { ViewerContent } from '@/components/ViewerContent';
export default function Home() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="inline-block w-8 h-8 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mb-4"></div>
<p className="regular-m text-neutral-600">Loading...</p>
</div>
</div>
}>
<ViewerContent />
</Suspense>
);
}

View file

@ -0,0 +1,188 @@
'use client';
import { motion } from 'framer-motion';
import { useState, useRef } from 'react';
const webflowEase = [0.25, 0.46, 0.45, 0.94] as const;
interface FileUploaderProps {
onMarkdownLoad: (markdown: string) => void;
onTitleExtract: (title: string) => void;
}
export function FileUploader({ onMarkdownLoad, onTitleExtract }: FileUploaderProps) {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const extractTitle = (markdown: string): string => {
// Extract first H1 heading
const h1Match = markdown.match(/^#\s+(.+)$/m);
if (h1Match) return h1Match[1];
// Fallback to filename or generic title
return 'Untitled Document';
};
const handleFile = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
const title = extractTitle(content);
onTitleExtract(title);
onMarkdownLoad(content);
};
reader.readAsText(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file && (file.name.endsWith('.md') || file.name.endsWith('.markdown'))) {
handleFile(file);
}
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFile(file);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-6">
<motion.div
className="w-full max-w-2xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: webflowEase }}
>
{/* Header */}
<div className="text-center mb-12">
<motion.div
className="inline-flex items-center gap-2 px-4 py-2 bg-white border border-neutral-200 rounded-full mb-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1, ease: webflowEase }}
>
<div className="w-2 h-2 bg-primary-500 rounded-full animate-pulse" />
<span className="regular-s">Magazine-Style Markdown Viewer</span>
</motion.div>
<motion.h1
className="h1 mb-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2, ease: webflowEase }}
>
IF.docs
</motion.h1>
<motion.p
className="regular-m text-neutral-600 max-w-xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: webflowEase }}
>
Transform your Markdown files into beautiful, magazine-style documents
with ICW NextSpread design system
</motion.p>
</div>
{/* Drop zone */}
<motion.div
className={`
relative border-2 border-dashed rounded-3xl p-12 text-center
transition-all duration-300
${isDragging
? 'border-primary-500 bg-primary-50'
: 'border-neutral-300 bg-white hover:border-neutral-400 hover:bg-neutral-50'
}
`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4, ease: webflowEase }}
whileHover={{ scale: 1.02 }}
>
<input
ref={fileInputRef}
type="file"
accept=".md,.markdown"
onChange={handleFileInput}
className="hidden"
/>
<div className="mb-6">
<svg
className="w-16 h-16 mx-auto text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<h3 className="h4 mb-2">Drop your Markdown file here</h3>
<p className="regular-s text-neutral-600 mb-6">
or click to browse (.md, .markdown)
</p>
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 px-8 py-4 bg-neutral-900 text-white rounded-full hover:bg-neutral-800 transition-all"
>
<span className="medium-s">Choose File</span>
</button>
</motion.div>
{/* Sample docs */}
<motion.div
className="mt-8 text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.6, ease: webflowEase }}
>
<p className="regular-xs text-neutral-500 mb-3">Try with sample documents:</p>
<div className="flex flex-wrap gap-2 justify-center">
{[
{ name: 'agents.md', path: '/home/setup/infrafabric/agents.md' },
{ name: 'README.md', path: '/home/setup/navidocs/README.md' },
].map((doc) => (
<button
key={doc.name}
className="px-4 py-2 bg-neutral-100 text-neutral-700 rounded-full hover:bg-neutral-200 transition-colors regular-xs"
onClick={async () => {
try {
const response = await fetch(`/api/load-file?path=${encodeURIComponent(doc.path)}`);
const content = await response.text();
const title = extractTitle(content);
onTitleExtract(title);
onMarkdownLoad(content);
} catch (error) {
console.error('Failed to load sample:', error);
}
}}
>
{doc.name}
</button>
))}
</div>
</motion.div>
</motion.div>
</div>
);
}

View file

@ -0,0 +1,72 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useAdaptiveDuration } from '@/lib/useAdaptiveDuration';
const webflowEase = [0.25, 0.46, 0.45, 0.94] as const;
interface HeroSectionProps {
title: string;
onReset: () => void;
}
export function HeroSection({ title, onReset }: HeroSectionProps) {
const shouldReduceMotion = useReducedMotion();
const titleDuration = useAdaptiveDuration(0.7);
return (
<section className="relative w-full">
{/* Full-bleed hero with gradient (ICW style) */}
<div className="relative w-full h-[40vh] md:h-[50vh] overflow-hidden bg-gradient-to-br from-neutral-900 via-neutral-800 to-neutral-900">
<div className="absolute inset-0 bg-gradient-to-t from-black/65 via-black/30 to-transparent" />
<div className="absolute inset-0 flex items-end">
<div className="w-layout-blockcontainer container w-container pb-8 md:pb-12">
<motion.div
className="caption inline-flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-sm border border-white/20 rounded-full mb-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.5,
ease: webflowEase,
}}
>
<div className="caption-shape w-2 h-2 bg-primary-500 rounded-full animate-pulse" />
<div className="regular-s text-white">IF.docs</div>
</motion.div>
<motion.h1
className="text-white text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight drop-shadow-lg max-w-5xl"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : titleDuration,
ease: webflowEase,
}}
>
{title || 'Document Viewer'}
</motion.h1>
<motion.div
className="mt-6 flex items-center gap-4"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.5,
delay: shouldReduceMotion ? 0 : 0.1,
ease: webflowEase,
}}
>
<button
onClick={onReset}
className="inline-flex items-center gap-2 px-6 py-3 bg-white text-neutral-900 rounded-full hover:bg-neutral-100 transition-all"
>
Load New Document
</button>
</motion.div>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,100 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useAdaptiveDuration } from '@/lib/useAdaptiveDuration';
import { useEffect, useRef } from 'react';
const webflowEase = [0.25, 0.46, 0.45, 0.94] as const;
interface MagazineContentProps {
html: string;
}
export function MagazineContent({ html }: MagazineContentProps) {
const shouldReduceMotion = useReducedMotion();
const sectionDuration = useAdaptiveDuration(0.6);
const contentRef = useRef<HTMLDivElement>(null);
// Apply staggered animations to child elements (ICW Gallery style)
useEffect(() => {
if (!contentRef.current) return;
const elements = contentRef.current.querySelectorAll(
'h1, h2, h3, h4, p, pre, blockquote, ul, ol, img, table'
);
elements.forEach((el, index) => {
const element = el as HTMLElement;
element.style.opacity = '0';
element.style.transform = 'translateY(20px)';
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const delay = shouldReduceMotion ? 0 : index * 0.05;
const duration = shouldReduceMotion ? 0 : 0.6;
setTimeout(() => {
element.style.transition = `opacity ${duration}s cubic-bezier(0.25, 0.46, 0.45, 0.94), transform ${duration}s cubic-bezier(0.25, 0.46, 0.45, 0.94)`;
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
}, delay * 1000);
observer.unobserve(element);
}
});
},
{ threshold: 0.1, rootMargin: '-10%' }
);
observer.observe(element);
});
}, [html, shouldReduceMotion]);
return (
<div className="w-layout-blockcontainer container w-container">
{/* Magazine-style grid layout */}
<div className="magazine-wrap grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-8 lg:gap-12">
{/* Main content column (2-column text on desktop) */}
<motion.article
ref={contentRef}
className="magazine-content prose prose-lg lg:prose-xl max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: shouldReduceMotion ? 0 : sectionDuration,
ease: webflowEase,
}}
/>
{/* Sidebar for metadata/navigation */}
<motion.aside
className="magazine-sidebar bg-neutral-50 rounded-2xl p-6 lg:p-8 h-fit sticky top-8"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : sectionDuration,
delay: shouldReduceMotion ? 0 : 0.2,
ease: webflowEase,
}}
>
<h3 className="h4 mb-4">Navigation</h3>
<div className="space-y-2">
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="w-full text-left px-4 py-2 rounded-lg hover:bg-neutral-100 transition-colors regular-s"
>
Back to top
</button>
<div className="border-t border-neutral-200 my-4" />
<p className="regular-xs text-neutral-600">
Rendered with IF.docs magazine-style viewer
</p>
</div>
</motion.aside>
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useAdaptiveDuration } from '@/lib/useAdaptiveDuration';
import { parseMarkdown } from '@/lib/markdown-parser';
import { HeroSection } from './HeroSection';
import { MagazineContent } from './MagazineContent';
const webflowEase = [0.25, 0.46, 0.45, 0.94] as const;
interface MarkdownViewerProps {
markdown: string;
title: string;
onReset: () => void;
}
export function MarkdownViewer({ markdown, title, onReset }: MarkdownViewerProps) {
const shouldReduceMotion = useReducedMotion();
const contentDuration = useAdaptiveDuration(0.8);
const html = parseMarkdown(markdown);
return (
<div className="relative w-full min-h-screen">
{/* Hero Section with ICW styling */}
<HeroSection title={title} onReset={onReset} />
{/* Magazine-style content */}
<motion.section
className="relative bg-white py-16 md:py-24"
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : contentDuration,
delay: shouldReduceMotion ? 0 : 0.3,
ease: webflowEase,
}}
>
<MagazineContent html={html} />
</motion.section>
</div>
);
}

View file

@ -0,0 +1,78 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { MarkdownViewer } from '@/components/MarkdownViewer';
import { FileUploader } from '@/components/FileUploader';
export function ViewerContent() {
const [markdown, setMarkdown] = useState('');
const [title, setTitle] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const searchParams = useSearchParams();
// Load markdown from URL query parameter ?parse=https://url/file.md
useEffect(() => {
const parseUrl = searchParams.get('parse');
if (parseUrl) {
setLoading(true);
fetch(parseUrl)
.then((res) => {
if (!res.ok) throw new Error(`Failed to fetch: ${res.statusText}`);
return res.text();
})
.then((content) => {
// Extract title from first H1
const h1Match = content.match(/^#\s+(.+)$/m);
const extractedTitle = h1Match ? h1Match[1] : 'Document';
setTitle(extractedTitle);
setMarkdown(content);
setLoading(false);
})
.catch((err) => {
setError(`Error loading document: ${err.message}`);
setLoading(false);
});
}
}, [searchParams]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="inline-block w-8 h-8 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mb-4"></div>
<p className="regular-m text-neutral-600">Loading document...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-lg text-center">
<div className="text-6xl mb-4"></div>
<h2 className="h3 mb-2">Error Loading Document</h2>
<p className="regular-m text-neutral-600 mb-6">{error}</p>
<button
onClick={() => { setError(''); }}
className="inline-flex items-center gap-2 px-6 py-3 bg-neutral-900 text-white rounded-full hover:bg-neutral-800 transition-all"
>
Try Again
</button>
</div>
</div>
);
}
return (
<main className="min-h-screen bg-neutral-50">
{!markdown ? (
<FileUploader onMarkdownLoad={setMarkdown} onTitleExtract={setTitle} />
) : (
<MarkdownViewer markdown={markdown} title={title} onReset={() => { setMarkdown(''); setTitle(''); }} />
)}
</main>
);
}

View file

@ -0,0 +1,27 @@
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
const md: MarkdownIt = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
highlight: function (str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code class="language-${lang}">${
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
}</code></pre>`;
} catch (__) {}
}
return `<pre class="hljs"><code>${MarkdownIt().utils.escapeHtml(str)}</code></pre>`;
},
});
/**
* Parse Markdown to HTML with syntax highlighting
* @param markdown - Raw markdown string
* @returns Rendered HTML string
*/
export function parseMarkdown(markdown: string): string {
return md.render(markdown);
}

View file

@ -0,0 +1,32 @@
'use client';
import { useEffect, useState } from 'react';
import { useReducedMotion } from 'framer-motion';
import { useScrollSpeed } from './useScrollSpeed';
/**
* Adaptive animation duration hook from ICW NextSpread
* Adjusts animation speed based on user scroll velocity
* @param baseDuration - Base duration in seconds
* @returns Adjusted duration (0 if reduced motion preferred)
*/
export function useAdaptiveDuration(baseDuration: number): number {
const shouldReduceMotion = useReducedMotion();
const scrollSpeed = useScrollSpeed();
const [duration, setDuration] = useState(baseDuration);
useEffect(() => {
if (shouldReduceMotion) {
setDuration(0);
return;
}
// Map scroll speed (0-100) to duration multiplier (0.5x-1.5x)
// Faster scroll = shorter animations
const speedFactor = 1 - (scrollSpeed / 200);
const clampedFactor = Math.max(0.5, Math.min(1.5, speedFactor));
setDuration(baseDuration * clampedFactor);
}, [baseDuration, scrollSpeed, shouldReduceMotion]);
return duration;
}

18
src/lib/useClientReady.ts Normal file
View file

@ -0,0 +1,18 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Client-side hydration guard from ICW NextSpread
* Prevents hydration mismatches for client-only features
* @returns true when component is mounted on client
*/
export function useClientReady(): boolean {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
setIsReady(true);
}, []);
return isReady;
}

59
src/lib/useScrollSpeed.ts Normal file
View file

@ -0,0 +1,59 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Scroll speed detection hook from ICW NextSpread
* Measures scroll velocity to adapt animation timing
* @returns Current scroll speed (0-100)
*/
export function useScrollSpeed(): number {
const [scrollSpeed, setScrollSpeed] = useState(0);
useEffect(() => {
let lastScrollY = window.scrollY;
let lastTimestamp = Date.now();
let ticking = false;
const updateScrollSpeed = () => {
const currentScrollY = window.scrollY;
const currentTimestamp = Date.now();
const deltaY = Math.abs(currentScrollY - lastScrollY);
const deltaTime = currentTimestamp - lastTimestamp;
// Calculate pixels per second
const speed = deltaTime > 0 ? (deltaY / deltaTime) * 1000 : 0;
// Normalize to 0-100 range (assuming max speed of 5000px/s)
const normalizedSpeed = Math.min(100, (speed / 5000) * 100);
setScrollSpeed(normalizedSpeed);
lastScrollY = currentScrollY;
lastTimestamp = currentTimestamp;
ticking = false;
};
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateScrollSpeed);
ticking = true;
}
};
// Decay speed over time when not scrolling
const decayInterval = setInterval(() => {
setScrollSpeed((prev) => Math.max(0, prev * 0.9));
}, 100);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
clearInterval(decayInterval);
};
}, []);
return scrollSpeed;
}

82
src/styles/globals.css Normal file
View file

@ -0,0 +1,82 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import './markdown.css';
@import './highlight.css';
:root {
--webflow-ease: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
body {
@apply bg-neutral-50 text-neutral-700;
}
/* ICW Design System Typography */
.h1 {
@apply text-5xl md:text-7xl font-bold tracking-tight text-neutral-900;
}
.h2 {
@apply text-3xl md:text-5xl font-semibold tracking-tight text-neutral-900;
}
.h3 {
@apply text-2xl md:text-3xl font-semibold text-neutral-900;
}
.h4 {
@apply text-xl md:text-2xl font-semibold text-neutral-900;
}
.regular-s {
@apply text-sm;
}
.regular-m {
@apply text-base;
}
.regular-xs {
@apply text-xs;
}
.medium-s {
@apply text-sm font-medium;
}
.medium-m {
@apply text-base font-medium;
}
/* ICW Container System */
.w-layout-blockcontainer {
@apply max-w-7xl mx-auto px-6 md:px-8;
}
.container {
@apply w-full;
}
.big-container {
@apply max-w-screen-2xl;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-neutral-100;
}
::-webkit-scrollbar-thumb {
@apply bg-neutral-400 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-neutral-600;
}

76
src/styles/highlight.css Normal file
View file

@ -0,0 +1,76 @@
/* VS Code Dark+ theme for syntax highlighting */
.hljs {
display: block;
overflow-x: auto;
padding: 1.5em;
background: #1e1e1e;
color: #d4d4d4;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-section,
.hljs-link {
color: #569cd6;
}
.hljs-string {
color: #ce9178;
}
.hljs-title,
.hljs-name,
.hljs-type,
.hljs-attribute,
.hljs-symbol,
.hljs-bullet,
.hljs-built_in,
.hljs-addition,
.hljs-variable,
.hljs-template-tag,
.hljs-template-variable {
color: #4ec9b0;
}
.hljs-comment,
.hljs-quote,
.hljs-deletion,
.hljs-meta {
color: #6a9955;
}
.hljs-number,
.hljs-regexp,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #b5cea8;
}
.hljs-function {
color: #dcdcaa;
}
.hljs-tag,
.hljs-doctag {
color: #569cd6;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-attr {
color: #9cdcfe;
}
.hljs-params {
color: #d4d4d4;
}

155
src/styles/markdown.css Normal file
View file

@ -0,0 +1,155 @@
/* Magazine-style Markdown prose styling (ICW inspired) */
.magazine-content.prose {
@apply text-neutral-700;
color: #404040;
font-size: 1.125rem;
line-height: 1.75;
}
/* Headings with ICW typography */
.magazine-content.prose h1 {
@apply text-5xl md:text-6xl font-bold tracking-tight text-neutral-900 mt-12 mb-6;
letter-spacing: -0.02em;
}
.magazine-content.prose h2 {
@apply text-3xl md:text-4xl font-semibold tracking-tight text-neutral-900 mt-10 mb-4;
letter-spacing: -0.01em;
}
.magazine-content.prose h3 {
@apply text-2xl md:text-3xl font-semibold text-neutral-900 mt-8 mb-3;
}
.magazine-content.prose h4 {
@apply text-xl md:text-2xl font-semibold text-neutral-900 mt-6 mb-2;
}
.magazine-content.prose h5 {
@apply text-lg md:text-xl font-semibold text-neutral-900 mt-4 mb-2;
}
.magazine-content.prose h6 {
@apply text-base md:text-lg font-semibold text-neutral-900 mt-4 mb-2;
}
/* Paragraphs */
.magazine-content.prose p {
@apply mb-6 leading-relaxed;
}
.magazine-content.prose p:first-of-type {
@apply text-xl md:text-2xl leading-relaxed text-neutral-800;
font-weight: 400;
}
/* Links with ICW hover effect */
.magazine-content.prose a {
@apply text-primary-600 no-underline hover:text-primary-500 transition-colors;
border-bottom: 1px solid currentColor;
}
.magazine-content.prose a:hover {
border-bottom: 2px solid currentColor;
}
/* Lists */
.magazine-content.prose ul,
.magazine-content.prose ol {
@apply my-6 space-y-2;
}
.magazine-content.prose li {
@apply leading-relaxed;
}
.magazine-content.prose ul > li {
@apply relative pl-6;
}
.magazine-content.prose ul > li::before {
content: '';
@apply absolute left-0 top-[0.6em] w-1.5 h-1.5 bg-primary-500 rounded-full;
}
.magazine-content.prose ol {
@apply list-decimal pl-6;
}
/* Blockquotes (magazine pull-quote style) */
.magazine-content.prose blockquote {
@apply my-8 py-4 px-6 border-l-4 border-primary-500 bg-neutral-50 rounded-r-xl;
font-style: italic;
font-size: 1.25rem;
color: #262626;
}
.magazine-content.prose blockquote p {
@apply mb-2;
}
/* Code blocks with syntax highlighting */
.magazine-content.prose pre {
@apply my-6 rounded-2xl overflow-hidden;
background: #1e1e1e;
}
.magazine-content.prose pre code {
@apply block p-6 overflow-x-auto text-sm leading-relaxed;
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', monospace;
}
/* Inline code */
.magazine-content.prose :not(pre) > code {
@apply px-2 py-1 bg-neutral-100 text-neutral-800 rounded text-sm;
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', monospace;
}
/* Images (full-bleed with rounded corners) */
.magazine-content.prose img {
@apply my-8 rounded-2xl shadow-lg w-full;
max-width: 100%;
height: auto;
}
/* Tables */
.magazine-content.prose table {
@apply my-8 w-full border-collapse;
}
.magazine-content.prose thead {
@apply bg-neutral-100;
}
.magazine-content.prose th {
@apply px-4 py-3 text-left font-semibold text-neutral-900 border-b-2 border-neutral-300;
}
.magazine-content.prose td {
@apply px-4 py-3 border-b border-neutral-200;
}
.magazine-content.prose tbody tr:hover {
@apply bg-neutral-50;
}
/* Horizontal rule (section divider) */
.magazine-content.prose hr {
@apply my-12 border-t-2 border-neutral-200;
}
/* Strong and emphasis */
.magazine-content.prose strong {
@apply font-semibold text-neutral-900;
}
.magazine-content.prose em {
@apply italic;
}
/* Magazine-style drop cap (first letter) */
.magazine-content.prose p:first-of-type::first-letter {
@apply text-6xl font-bold float-left mr-2 leading-none text-neutral-900;
margin-top: 0.1em;
}

36
tailwind.config.js Normal file
View file

@ -0,0 +1,36 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
500: '#10B981',
600: '#059669',
},
neutral: {
50: '#FAFAFA',
100: '#F5F5F5',
200: '#E5E5E5',
600: '#737373',
700: '#404040',
800: '#262626',
900: '#171717',
},
},
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
serif: ['Georgia', 'Cambria', 'Times New Roman', 'serif'],
mono: ['JetBrains Mono', 'Consolas', 'Monaco', 'monospace'],
},
animation: {
'pulse': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
},
},
plugins: [],
}

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}