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