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:
commit
f950357096
24 changed files with 3733 additions and 0 deletions
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal 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
37
.htaccess
Normal 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
165
DEPLOY.md
Normal 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
176
README.md
Normal 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
15
next.config.mjs
Normal 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
2238
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
package.json
Normal file
30
package.json
Normal 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
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
22
src/app/layout.tsx
Normal file
22
src/app/layout.tsx
Normal 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
19
src/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
src/components/FileUploader.tsx
Normal file
188
src/components/FileUploader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/HeroSection.tsx
Normal file
72
src/components/HeroSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/MagazineContent.tsx
Normal file
100
src/components/MagazineContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/MarkdownViewer.tsx
Normal file
43
src/components/MarkdownViewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/ViewerContent.tsx
Normal file
78
src/components/ViewerContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/lib/markdown-parser.ts
Normal file
27
src/lib/markdown-parser.ts
Normal 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);
|
||||||
|
}
|
||||||
32
src/lib/useAdaptiveDuration.ts
Normal file
32
src/lib/useAdaptiveDuration.ts
Normal 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
18
src/lib/useClientReady.ts
Normal 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
59
src/lib/useScrollSpeed.ts
Normal 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
82
src/styles/globals.css
Normal 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
76
src/styles/highlight.css
Normal 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
155
src/styles/markdown.css
Normal 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
36
tailwind.config.js
Normal 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
26
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue