added blog upload functionality
This commit is contained in:
parent
5b07fae911
commit
03dd3571a3
89
README.md
89
README.md
@ -86,6 +86,95 @@ The application supports the following environment variables:
|
|||||||
| `NODE_ENV` | Node.js environment | `development` |
|
| `NODE_ENV` | Node.js environment | `development` |
|
||||||
| `PORT` | Port to run the server on | `3000` |
|
| `PORT` | Port to run the server on | `3000` |
|
||||||
|
|
||||||
|
### Blog Authoring (MDX)
|
||||||
|
|
||||||
|
You can write posts in MDX and have them show up on `/blog`.
|
||||||
|
|
||||||
|
- Local posts: add `.mdx` files to `app/blog/posts/` (these are always included).
|
||||||
|
- Optional GitHub-backed posts: if you configure the `BLOG_*` environment variables, MDX files from your GitHub repo are fetched and merged with local posts. GitHub posts override local ones on duplicate slugs.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
1) Copy `.env.local.example` to `.env.local` and fill in values.
|
||||||
|
2) For GitHub-backed content, set:
|
||||||
|
- `BLOG_REPO=owner/repo`
|
||||||
|
- `BLOG_PATH=path/to/mdx/folder` (relative to repo root)
|
||||||
|
- `BLOG_BRANCH=main` (or your branch)
|
||||||
|
- `GITHUB_TOKEN=` (only required for private repos or higher rate limits)
|
||||||
|
|
||||||
|
Frontmatter template (put at the top of each `.mdx` file):
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
title: "Post Title"
|
||||||
|
publishedAt: "2025-01-15" # YYYY-MM-DD
|
||||||
|
summary: "One-liner summary"
|
||||||
|
tags: ["tag1", "tag2"]
|
||||||
|
image: "https://your.cdn/path-or-absolute-url.jpg"
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Images:
|
||||||
|
- Prefer absolute URLs (CDN or repo raw URLs) to avoid build-time asset issues.
|
||||||
|
- The MDX renderer handles links and images for you (see `components/mdx.tsx`).
|
||||||
|
|
||||||
|
How updates appear on the site:
|
||||||
|
- Incremental Static Regeneration (ISR): GitHub responses are cached with `next: { revalidate: BLOG_REVALIDATE_SECONDS }`. New/edited posts appear automatically after the configured window (default 300s).
|
||||||
|
- On-demand revalidation: trigger an immediate refresh via the `/api/revalidate` endpoint (see below) or configure a GitHub webhook to call it on each push.
|
||||||
|
|
||||||
|
### Blog Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `BLOG_REPO` | GitHub `owner/repo` containing your MDX files. Leave empty to use only local posts. | — |
|
||||||
|
| `BLOG_PATH` | Path in the repo where `.mdx` files live (relative to repo root). | `app/blog/posts` |
|
||||||
|
| `BLOG_BRANCH` | Branch name to read from. | `main` |
|
||||||
|
| `GITHUB_TOKEN` | GitHub token. Required for private repos or higher rate limits. | — |
|
||||||
|
| `BLOG_REVALIDATE_SECONDS` | ISR interval (seconds) for GitHub fetch cache. | `300` |
|
||||||
|
| `BLOG_CACHE_TAG` | Cache tag used for on-demand invalidation (`revalidateTag`). | `blog-content` |
|
||||||
|
| `REVALIDATE_SECRET` | Shared secret for the revalidate API and GitHub webhook signature. | — |
|
||||||
|
|
||||||
|
### On-demand Revalidation
|
||||||
|
|
||||||
|
An API route at `/api/revalidate` invalidates the blog listing and post pages, and also busts the cache tag used for GitHub content.
|
||||||
|
|
||||||
|
- GET (simple manual trigger):
|
||||||
|
- Revalidate listing:
|
||||||
|
```bash
|
||||||
|
curl -sS "https://your-domain/api/revalidate?secret=REVALIDATE_SECRET"
|
||||||
|
```
|
||||||
|
- Revalidate a specific post (by slug):
|
||||||
|
```bash
|
||||||
|
curl -sS "https://your-domain/api/revalidate?secret=REVALIDATE_SECRET&slug=my-post-slug"
|
||||||
|
```
|
||||||
|
- Optionally revalidate additional paths:
|
||||||
|
```bash
|
||||||
|
curl -sS "https://your-domain/api/revalidate?secret=REVALIDATE_SECRET&path=/blog/my-post-slug"
|
||||||
|
```
|
||||||
|
|
||||||
|
- POST (advanced, supports multiple slugs/paths):
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "https://your-domain/api/revalidate" \
|
||||||
|
-H "x-revalidate-secret: REVALIDATE_SECRET" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"slugs": ["my-post-slug"],
|
||||||
|
"paths": ["/blog", "/blog/my-post-slug"]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- GitHub Webhook (recommended):
|
||||||
|
1) In your repo settings, add a Webhook:
|
||||||
|
- Payload URL: `https://your-domain/api/revalidate`
|
||||||
|
- Content type: `application/json`
|
||||||
|
- Secret: set to the same value as `REVALIDATE_SECRET`
|
||||||
|
- Event: “Just the push event”
|
||||||
|
2) On push, the webhook sends changed file paths. The API will:
|
||||||
|
- Revalidate the `BLOG_CACHE_TAG` to refresh GitHub fetches.
|
||||||
|
- Revalidate `/blog` and any changed post slugs under `BLOG_PATH`.
|
||||||
|
|
||||||
|
Security notes:
|
||||||
|
- Do not expose `REVALIDATE_SECRET` publicly. For manual GET usage, keep the URL private.
|
||||||
|
- For CI/CD, use the POST form with the `x-revalidate-secret` header.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is open source, take it. I don't give a fuck. I am not your dad.
|
This project is open source, take it. I don't give a fuck. I am not your dad.
|
||||||
|
|||||||
160
app/api/revalidate/route.ts
Normal file
160
app/api/revalidate/route.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
const SECRET = process.env.REVALIDATE_SECRET || ''
|
||||||
|
const BLOG_CACHE_TAG = process.env.BLOG_CACHE_TAG || 'blog-content'
|
||||||
|
const BLOG_PATH = (process.env.BLOG_PATH || '').replace(/^\/+|\/+$/g, '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare strings in a timing-safe way
|
||||||
|
*/
|
||||||
|
function timingSafeEqual(a: string, b: string) {
|
||||||
|
const aBuf = Buffer.from(a)
|
||||||
|
const bBuf = Buffer.from(b)
|
||||||
|
if (aBuf.length !== bBuf.length) return false
|
||||||
|
return crypto.timingSafeEqual(aBuf, bBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate GitHub's HMAC signature header (X-Hub-Signature-256)
|
||||||
|
*/
|
||||||
|
function verifyGitHubSignature(rawBody: string, signatureHeader: string | null) {
|
||||||
|
if (!SECRET || !signatureHeader) return false
|
||||||
|
const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}`
|
||||||
|
return timingSafeEqual(expected, signatureHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorize request via:
|
||||||
|
* - query param ?secret=
|
||||||
|
* - header x-revalidate-secret
|
||||||
|
* - GitHub webhook signature X-Hub-Signature-256
|
||||||
|
*/
|
||||||
|
function isAuthorized(req: NextRequest, rawBody?: string) {
|
||||||
|
if (!SECRET) return false
|
||||||
|
|
||||||
|
const urlSecret = req.nextUrl.searchParams.get('secret')
|
||||||
|
if (urlSecret && timingSafeEqual(urlSecret, SECRET)) return true
|
||||||
|
|
||||||
|
const headerSecret = req.headers.get('x-revalidate-secret')
|
||||||
|
if (headerSecret && timingSafeEqual(headerSecret, SECRET)) return true
|
||||||
|
|
||||||
|
const ghSig = req.headers.get('x-hub-signature-256')
|
||||||
|
if (ghSig && rawBody && verifyGitHubSignature(rawBody, ghSig)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveSlugsFromChangedFiles(files: string[], blogPath: string) {
|
||||||
|
const normalized = blogPath ? blogPath.replace(/^\/+|\/+$/g, '') : ''
|
||||||
|
return files
|
||||||
|
.filter((f) => f.endsWith('.mdx'))
|
||||||
|
.filter((f) => {
|
||||||
|
if (!normalized) return true
|
||||||
|
// match if file is in configured BLOG_PATH
|
||||||
|
const fNorm = f.replace(/^\/+|\/+$/g, '')
|
||||||
|
return fNorm === normalized || fNorm.startsWith(`${normalized}/`)
|
||||||
|
})
|
||||||
|
.map((f) => {
|
||||||
|
const name = f.split('/').pop() || f
|
||||||
|
return name.replace(/\.mdx$/i, '')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
if (!isAuthorized(req)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = new Set<string>(['/blog'])
|
||||||
|
const tag = req.nextUrl.searchParams.get('tag') || BLOG_CACHE_TAG
|
||||||
|
|
||||||
|
const slug = req.nextUrl.searchParams.get('slug')
|
||||||
|
if (slug) paths.add(`/blog/${slug}`)
|
||||||
|
|
||||||
|
const pathParam = req.nextUrl.searchParams.get('path')
|
||||||
|
if (pathParam) paths.add(pathParam)
|
||||||
|
|
||||||
|
// Revalidate cache tag (refreshes GitHub fetches using next: { tags })
|
||||||
|
revalidateTag(tag)
|
||||||
|
// Revalidate listing and any provided paths
|
||||||
|
for (const p of paths) revalidatePath(p)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
revalidated: true,
|
||||||
|
tag,
|
||||||
|
paths: Array.from(paths),
|
||||||
|
mode: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChangedFilesFromGitHubPush(body: unknown): string[] {
|
||||||
|
if (!isRecord(body) || !Array.isArray((body as Record<string, unknown>).commits)) return []
|
||||||
|
const files: string[] = []
|
||||||
|
const commits = (body as Record<string, unknown>).commits as unknown[]
|
||||||
|
for (const c of commits) {
|
||||||
|
if (!isRecord(c)) continue
|
||||||
|
for (const key of ['added', 'modified', 'removed'] as const) {
|
||||||
|
const arr = (c as Record<string, unknown>)[key]
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
for (const f of arr) {
|
||||||
|
if (typeof f === 'string') files.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const raw = await req.text()
|
||||||
|
|
||||||
|
if (!isAuthorized(req, raw)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {}
|
||||||
|
try {
|
||||||
|
body = raw ? JSON.parse(raw) : {}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors for signature validation; continue with empty body
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = new Set<string>(['/blog'])
|
||||||
|
const tag = BLOG_CACHE_TAG
|
||||||
|
|
||||||
|
// Direct payload usage
|
||||||
|
if (isRecord(body) && typeof body.path === 'string') paths.add(body.path)
|
||||||
|
if (isRecord(body) && typeof body.slug === 'string') paths.add(`/blog/${body.slug}`)
|
||||||
|
if (isRecord(body) && Array.isArray(body.paths)) {
|
||||||
|
for (const p of body.paths as unknown[]) if (typeof p === 'string') paths.add(p)
|
||||||
|
}
|
||||||
|
if (isRecord(body) && Array.isArray(body.slugs)) {
|
||||||
|
for (const s of body.slugs as unknown[]) if (typeof s === 'string') paths.add(`/blog/${s}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHub webhook (push) payload
|
||||||
|
const ghEvent = req.headers.get('x-github-event')
|
||||||
|
if (ghEvent === 'push') {
|
||||||
|
const changed = getChangedFilesFromGitHubPush(body)
|
||||||
|
const slugs = deriveSlugsFromChangedFiles(changed, BLOG_PATH)
|
||||||
|
for (const s of slugs) paths.add(`/blog/${s}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateTag(tag)
|
||||||
|
for (const p of paths) revalidatePath(p)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
revalidated: true,
|
||||||
|
tag,
|
||||||
|
paths: Array.from(paths),
|
||||||
|
mode: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -9,6 +9,13 @@ export const metadata = {
|
|||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const posts = await getAllPosts()
|
const posts = await getAllPosts()
|
||||||
|
const hasRepo = Boolean(process.env.BLOG_REPO)
|
||||||
|
const repo = process.env.BLOG_REPO
|
||||||
|
const repoPath = process.env.BLOG_PATH || ''
|
||||||
|
const repoBranch = process.env.BLOG_BRANCH || 'main'
|
||||||
|
const sourceLabel = hasRepo
|
||||||
|
? `Local MDX + GitHub (${repo}${repoPath ? `/${repoPath}` : ''}@${repoBranch})`
|
||||||
|
: 'Local MDX'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-5xl px-4 md:px-6">
|
<section className="mx-auto max-w-5xl px-4 md:px-6">
|
||||||
@ -22,6 +29,9 @@ export default async function Page() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-semibold text-2xl mb-6 tracking-tighter">My Blog</h1>
|
<h1 className="font-semibold text-2xl mb-6 tracking-tighter">My Blog</h1>
|
||||||
|
<div className="mb-4 text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
|
Content source: {sourceLabel}
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<PostCard key={post.slug} post={post} />
|
<PostCard key={post.slug} post={post} />
|
||||||
|
|||||||
@ -102,6 +102,8 @@ const BLOG_REPO = process.env.BLOG_REPO // 'owner/repo'
|
|||||||
const BLOG_PATH = process.env.BLOG_PATH || ''
|
const BLOG_PATH = process.env.BLOG_PATH || ''
|
||||||
const BLOG_BRANCH = process.env.BLOG_BRANCH || 'main'
|
const BLOG_BRANCH = process.env.BLOG_BRANCH || 'main'
|
||||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN
|
const GITHUB_TOKEN = process.env.GITHUB_TOKEN
|
||||||
|
const BLOG_REVALIDATE_SECONDS = Number(process.env.BLOG_REVALIDATE_SECONDS || 300)
|
||||||
|
const BLOG_CACHE_TAG = process.env.BLOG_CACHE_TAG || 'blog-content'
|
||||||
|
|
||||||
type GithubContentItem = {
|
type GithubContentItem = {
|
||||||
name: string
|
name: string
|
||||||
@ -119,7 +121,10 @@ async function githubApi<T>(url: string): Promise<T> {
|
|||||||
}
|
}
|
||||||
if (GITHUB_TOKEN) headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
if (GITHUB_TOKEN) headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
||||||
|
|
||||||
const res = await fetch(url, { headers, cache: 'no-store' })
|
const res = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
next: { revalidate: BLOG_REVALIDATE_SECONDS, tags: [BLOG_CACHE_TAG] },
|
||||||
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`GitHub API error ${res.status} on ${url}`)
|
throw new Error(`GitHub API error ${res.status} on ${url}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,43 +1,168 @@
|
|||||||
# Implementation Plan
|
# Implementation Plan
|
||||||
|
|
||||||
## Overview
|
[Overview]
|
||||||
Integrate the navigation and blog functionality from the EXAMPLE/blog into the main site, ensuring all dependencies are properly installed and configured.
|
Add a Doom emulator that launches in a modal window (“floating over the page”) when the user clicks a new “running on a potato” entry in the existing sidebar menu. The emulator will be loaded on-demand, client-only, and isolated from SSR to protect page performance and stability.
|
||||||
|
|
||||||
## Types
|
Multiple paragraphs outlining the scope, context, and high-level approach. Explain why this implementation is needed and how it fits into the existing system.
|
||||||
No new type definitions are required for this implementation.
|
- This Next.js 15 app uses the App Router, React 19, Tailwind, and several client-side providers (Lenis, Motion). The request is to add a lightweight, non-intrusive Doom experience without altering the primary site navigation. The user wants a single menu item in the sidebar labeled “running on a potato” that, when clicked, opens an overlay modal containing the emulator.
|
||||||
|
- The overlay will be implemented as a client-only modal and mounted at the root layout so it renders above all content, and remains accessible across pages. We will add a React Context Provider to manage the “open/close” state of the modal globally. The sidebar menu will receive an action hook to open the modal.
|
||||||
|
- For the emulator engine, we will use js-dos (DOSBox compiled to WebAssembly) via the npm package “js-dos”. It can boot a packaged .jsdos archive containing DOS Doom and run entirely in the browser. This approach avoids adding complex custom build steps and does not require special COOP/COEP headers. The emulator will be lazy-loaded via dynamic import to keep initial page loads fast.
|
||||||
|
- To provide an immediate out-of-the-box experience, the emulator will load a remotely hosted demo .jsdos archive from the official js-dos CDN (subject to its availability). If remote loading fails (network/CORS), the overlay will show a prompt allowing the user to drag-and-drop or select a local .jsdos archive to run. This keeps the feature functional while avoiding bundling large binary assets into the repository.
|
||||||
|
- Accessibility and usability: the modal will trap focus, provide keyboard controls (ESC to close), restore focus on close, and disable background scroll while open.
|
||||||
|
|
||||||
## Files
|
[Types]
|
||||||
- Create app/robots.ts for SEO optimization
|
Single sentence describing the type system changes.
|
||||||
- Update app/layout.tsx to include Footer component
|
We will add a small set of TypeScript types for the modal provider context and emulator configuration.
|
||||||
- Update app/components/footer.tsx to remove Vercel references
|
|
||||||
|
|
||||||
## Functions
|
Detailed type definitions, interfaces, enums, or data structures with complete specifications. Include field names, types, validation rules, and relationships.
|
||||||
No new functions are required, but existing functions need to be properly connected.
|
- app/providers/DoomOverlayProvider.tsx
|
||||||
|
- export type DoomOverlayContextValue = {
|
||||||
|
isOpen: boolean;
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
- Constraints:
|
||||||
|
- isOpen: true when modal is visible.
|
||||||
|
- open/close: stable callbacks; must be usable from any client component.
|
||||||
|
- app/components/doom/JsDosPlayer.tsx
|
||||||
|
- export type JsDosPlayerProps = {
|
||||||
|
zipUrl?: string; // optional remote .jsdos archive URL
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
- export type JsDosHandle = {
|
||||||
|
stop: () => void; // shutdown emulator and free resources
|
||||||
|
}
|
||||||
|
- Emulator engine choice type:
|
||||||
|
- export enum DoomEngine { JsDos = "js-dos" }
|
||||||
|
- export type DoomConfig = { engine: DoomEngine; jsdos?: { zipUrl?: string } }
|
||||||
|
|
||||||
## Classes
|
[Files]
|
||||||
No new classes are required for this implementation.
|
Single sentence describing file modifications.
|
||||||
|
We will add a provider, modal overlay, emulator player component, and update the sidebar menu and root layout to integrate the overlay trigger and mount point.
|
||||||
|
|
||||||
## Dependencies
|
Detailed breakdown:
|
||||||
Install missing dependencies:
|
- New files to be created (with full paths and purpose)
|
||||||
- next-mdx-remote
|
1) app/providers/DoomOverlayProvider.tsx
|
||||||
- sugar-high
|
- Purpose: Client-side React context that exposes open/close state and actions for the Doom modal. Wraps children so any component can open the overlay.
|
||||||
|
2) app/components/overlay/Modal.tsx
|
||||||
|
- Purpose: Accessible, reusable modal component using a portal, focus trap, ESC-to-close, backdrop click-to-close, and scroll lock.
|
||||||
|
3) app/components/doom/DoomOverlay.tsx
|
||||||
|
- Purpose: The top-level container rendered inside the modal. It displays loading/error states and hosts the emulator player. Includes a close button and optional instructions if remote loading fails.
|
||||||
|
4) app/components/doom/JsDosPlayer.tsx
|
||||||
|
- Purpose: Client-only component that lazy-loads “js-dos”, renders the emulator surface, runs a provided .jsdos archive via a URL, and tears down cleanly on unmount.
|
||||||
|
- Existing files to be modified (with specific changes)
|
||||||
|
1) app/layout.tsx
|
||||||
|
- Wrap current body content with <DoomOverlayProvider>. Mount <DoomOverlay /> near the end of <body> (still inside providers) so it renders above app content, but shares styles/providers.
|
||||||
|
2) app/components/sidebar-menu.tsx
|
||||||
|
- Add a new “running on a potato” menu action above or below current items (About/Writing/Contact).
|
||||||
|
- This should be a button (not a Link) that calls open() from DoomOverlayProvider to display the modal. Maintain current styling conventions via cn() and Tailwind.
|
||||||
|
- Files to be deleted or moved
|
||||||
|
- None.
|
||||||
|
- Configuration file updates
|
||||||
|
- package.json (Dependencies): add "js-dos".
|
||||||
|
- No changes to next.config.ts or tsconfig.json are required for this plan.
|
||||||
|
|
||||||
Remove unnecessary dependencies:
|
[Functions]
|
||||||
- @vercel/analytics
|
Single sentence describing function modifications.
|
||||||
- @vercel/speed-insights
|
We will add modal state management functions, emulator lifecycle controls, and integrate a new onclick handler in the sidebar menu.
|
||||||
|
|
||||||
## Testing
|
Detailed breakdown:
|
||||||
Verify that:
|
- New functions (name, signature, file path, purpose)
|
||||||
- Blog posts are properly displayed
|
1) open ((): void) — app/providers/DoomOverlayProvider.tsx
|
||||||
- Navigation works correctly
|
- Purpose: Set modal open state to true.
|
||||||
- All dependencies are properly installed
|
2) close ((): void) — app/providers/DoomOverlayProvider.tsx
|
||||||
- RSS feed is working
|
- Purpose: Set modal open state to false.
|
||||||
- OG images are generated correctly
|
3) DoomOverlay ((): JSX.Element) — app/components/doom/DoomOverlay.tsx
|
||||||
|
- Purpose: Renders Modal and inside it lazy-loads the emulator (JsDosPlayer). Handles errors, shows loading fallback, exposes close control.
|
||||||
|
4) JsDosPlayer ((props: JsDosPlayerProps) => JSX.Element) — app/components/doom/JsDosPlayer.tsx
|
||||||
|
- Purpose: Initializes js-dos in a container div/canvas and runs a .jsdos ZIP archive via props.zipUrl. Disposes emulator instance on unmount.
|
||||||
|
5) Modal ((props: { open: boolean; onClose: () => void; children: React.ReactNode; title?: string; className?: string }) => JSX.Element) — app/components/overlay/Modal.tsx
|
||||||
|
- Purpose: Accessible modal with portal, focus trap, backdrop, escape close, body scroll lock.
|
||||||
|
- Modified functions (exact name, current file path, required changes)
|
||||||
|
1) default export SidebarMenu — app/components/sidebar-menu.tsx
|
||||||
|
- Add one extra item rendered as a button:
|
||||||
|
- Label: "running on a potato"
|
||||||
|
- onClick: calls open() via DoomOverlayProvider context
|
||||||
|
- Style: consistent with other menu items (Tailwind classes), ensure focus-visible styles for accessibility.
|
||||||
|
- Removed functions (name, file path, reason, migration strategy)
|
||||||
|
- None.
|
||||||
|
|
||||||
## Implementation Order
|
[Classes]
|
||||||
1. Install missing dependencies
|
Single sentence describing class modifications.
|
||||||
2. Create robots.ts file
|
No class-based components are used; all additions are functional React components.
|
||||||
3. Update layout to include Footer
|
|
||||||
4. Update footer to remove Vercel references
|
Detailed breakdown:
|
||||||
5. Test blog functionality
|
- New classes
|
||||||
6. Verify all links and navigation work correctly
|
- None.
|
||||||
|
- Modified classes
|
||||||
|
- None.
|
||||||
|
- Removed classes
|
||||||
|
- None.
|
||||||
|
|
||||||
|
[Dependencies]
|
||||||
|
Single sentence describing dependency modifications.
|
||||||
|
We will add js-dos to support running Doom in-browser via a WASM-powered DOSBox.
|
||||||
|
|
||||||
|
Details of new packages, version changes, and integration requirements.
|
||||||
|
- Add: "js-dos": "^7.5.0" (or latest v7)
|
||||||
|
- Import usage in client-only component:
|
||||||
|
- import "js-dos/css/js-dos.css"
|
||||||
|
- const { Dos } = await import("js-dos")
|
||||||
|
- Runtime:
|
||||||
|
- Provide a container element; initialize: const dos = Dos(container)
|
||||||
|
- Run: dos.run(zipUrl)
|
||||||
|
- No special COOP/COEP headers required for basic usage.
|
||||||
|
- Asset loading:
|
||||||
|
- Default: remote demo archive (subject to availability), e.g. https://v8.js-dos.com/v7/build/doom.jsdos
|
||||||
|
- Fallback: file input to select a local .jsdos package. We will show instructions in the overlay if remote load fails.
|
||||||
|
- No changes to Next.js, React, Tailwind, or other existing dependencies.
|
||||||
|
|
||||||
|
[Testing]
|
||||||
|
Single sentence describing testing approach.
|
||||||
|
We will verify overlay behavior, emulator load success and teardown, and ensure no SSR/runtime errors across pages.
|
||||||
|
|
||||||
|
Test file requirements, existing test modifications, and validation strategies.
|
||||||
|
- Manual QA (dev server):
|
||||||
|
- Open site, click “running on a potato” in sidebar:
|
||||||
|
- Modal opens, background scroll locked, focus lands on modal.
|
||||||
|
- Emulator loads and becomes interactive; performance acceptable.
|
||||||
|
- Close via ESC, backdrop click (if enabled), or close button — focus returns to triggering button.
|
||||||
|
- Network-offline test:
|
||||||
|
- Remote .jsdos load should fail gracefully; overlay shows a message and a file picker to choose a local .jsdos archive. After selecting, emulator runs.
|
||||||
|
- Navigation test:
|
||||||
|
- Navigate between sections while modal closed; ensure global provider doesn’t interfere with Lenis/scroll.
|
||||||
|
- Accessibility:
|
||||||
|
- Confirm appropriate aria attributes, focus management, and keyboard support.
|
||||||
|
- Performance:
|
||||||
|
- Confirm dynamic import defers js-dos until modal opens.
|
||||||
|
- Confirm emulator stops and frees resources when modal closes.
|
||||||
|
|
||||||
|
[Implementation Order]
|
||||||
|
Single sentence describing the implementation sequence.
|
||||||
|
Implement provider and modal primitives first, then the overlay and emulator, finally wire up the sidebar, and add the dependency.
|
||||||
|
|
||||||
|
Numbered steps showing the logical order of changes to minimize conflicts and ensure successful integration.
|
||||||
|
1) Add dependency: js-dos to package.json and install.
|
||||||
|
2) Create app/providers/DoomOverlayProvider.tsx with context, open/close, and a useDoomOverlay() hook.
|
||||||
|
3) Create app/components/overlay/Modal.tsx with portal, focus trap, ESC handling, backdrop, and scroll lock.
|
||||||
|
4) Create app/components/doom/JsDosPlayer.tsx that:
|
||||||
|
- Dynamically imports "js-dos" on mount (client-only).
|
||||||
|
- Imports "js-dos/css/js-dos.css".
|
||||||
|
- Initializes Dos(container) and calls .run(zipUrl).
|
||||||
|
- Cleans up on unmount or when parent closes.
|
||||||
|
5) Create app/components/doom/DoomOverlay.tsx that:
|
||||||
|
- Reads overlay state from context.
|
||||||
|
- Uses Modal to render content.
|
||||||
|
- Attempts to load remote .jsdos: https://v8.js-dos.com/v7/build/doom.jsdos
|
||||||
|
- If loading fails, renders a file input and drag-and-drop area to run a local .jsdos file.
|
||||||
|
- Provides close control UI.
|
||||||
|
6) Modify app/layout.tsx to wrap existing providers with <DoomOverlayProvider> and mount <DoomOverlay /> near the end of <body>.
|
||||||
|
7) Modify app/components/sidebar-menu.tsx to add a new button-styled item labeled “running on a potato” that calls open() from the provider.
|
||||||
|
8) Run dev server and test:
|
||||||
|
- Modal open/close behavior.
|
||||||
|
- Emulator loads, runs, and shuts down cleanly.
|
||||||
|
- Accessibility and performance checks.
|
||||||
|
9) Optional polish:
|
||||||
|
- Add loading spinner.
|
||||||
|
- Persist last used source (remote vs local) in session storage.
|
||||||
|
- Add instructions link about providing your own .jsdos archive if desired.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user