diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f623843..dd49e77 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -42,6 +42,7 @@ import { projectUpdateRoutes } from './routes/projects-updates.js'; import { projectBuzzRoutes } from './routes/projects-buzz.js'; import { helpWantedRoutes } from './routes/projects-help-wanted.js'; import { projectMembershipRoutes } from './routes/projects-members.js'; +import { previewRoutes } from './routes/preview.js'; declare module 'fastify' { interface FastifyInstance { @@ -147,6 +148,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise on every authoring screen so that the + * client never invokes a markdown library directly. See + * specs/behaviors/markdown-rendering.md for the rule and pipeline; this route + * is the lone exception to "rendering happens at serialize time" — it's the + * editor preview path. + */ +import type { FastifyInstance } from 'fastify'; +import { renderMarkdown } from '@cfp/shared'; +import { ok } from '../lib/response.js'; +import { ApiValidationError } from '../lib/errors.js'; + +const MAX_PREVIEW_LENGTH = 50_000; + +export async function previewRoutes(fastify: FastifyInstance): Promise { + fastify.post( + '/api/_preview', + { + schema: { + tags: ['preview'], + summary: 'Render a markdown source string to sanitized HTML', + body: { + type: 'object', + properties: { source: { type: 'string' } }, + required: ['source'], + additionalProperties: false, + }, + }, + }, + async (request) => { + const { source } = (request.body ?? {}) as { source: string }; + if (typeof source !== 'string') { + throw new ApiValidationError('source must be a string', { source: 'required' }); + } + if (source.length > MAX_PREVIEW_LENGTH) { + throw new ApiValidationError('source too long for preview', { + source: `must be ≤ ${MAX_PREVIEW_LENGTH} chars`, + }); + } + const { html } = renderMarkdown(source); + return ok({ html }); + }, + ); +} diff --git a/apps/api/tests/preview.test.ts b/apps/api/tests/preview.test.ts new file mode 100644 index 0000000..0beed6f --- /dev/null +++ b/apps/api/tests/preview.test.ts @@ -0,0 +1,87 @@ +/** + * Tests for POST /api/_preview — the markdown editor's server-side preview + * endpoint, per specs/behaviors/markdown-rendering.md. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/app.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; + +let dataRepo: { path: string; cleanup: () => Promise }; +let privateStore: { path: string; cleanup: () => Promise }; +let app: FastifyInstance | undefined; + +beforeEach(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!', + NODE_ENV: 'test', + }, + }); +}); + +afterEach(async () => { + if (app) { + await app.close(); + app = undefined; + } + await dataRepo.cleanup(); + await privateStore.cleanup(); +}); + +describe('POST /api/_preview', () => { + it('renders markdown source to sanitized HTML', async () => { + const res = await app!.inject({ + method: 'POST', + url: '/api/_preview', + payload: { source: '# Hello\n\n**bold** and `code`.' }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + // h1 demotes to h3 per the pipeline; bold/code preserved + expect(body.data.html).toContain('

Hello

'); + expect(body.data.html).toContain('bold'); + expect(body.data.html).toContain('code'); + }); + + it('strips dangerous HTML (script / on-attributes)', async () => { + const res = await app!.inject({ + method: 'POST', + url: '/api/_preview', + payload: { + source: '\n\nx', + }, + }); + expect(res.statusCode).toBe(200); + const html = res.json().data.html as string; + expect(html).not.toContain(' { + const res = await app!.inject({ + method: 'POST', + url: '/api/_preview', + payload: {}, + }); + expect(res.statusCode).toBe(422); + }); + + it('rejects oversized source with 422', async () => { + const giant = 'a '.repeat(30_000); // ~60k chars, exceeds 50k cap + const res = await app!.inject({ + method: 'POST', + url: '/api/_preview', + payload: { source: giant }, + }); + expect(res.statusCode).toBe(422); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 4924d10..42c3189 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@fontsource-variable/geist": "^5.2.8", + "@hookform/resolvers": "^5.2.2", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-query": "^5.100.10", "class-variance-authority": "^0.7.1", @@ -31,10 +32,13 @@ "radix-ui": "^1.4.3", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-hook-form": "^7.76.0", "react-router": "^7.15.1", "shadcn": "^4.7.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 662d6ac..1c98d46 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,5 @@ import { createBrowserRouter, Navigate, RouterProvider } from 'react-router'; +import { Toaster } from 'sonner'; import { TooltipProvider } from '@/components/ui/tooltip'; import { AppShell } from '@/components/AppShell'; import { NetworkErrorProvider } from '@/components/NetworkErrorBanner'; @@ -7,8 +8,11 @@ import { ApiQueryClientProvider } from '@/lib/queryClient'; import { Home } from '@/screens/Home'; import { ProjectsIndex } from '@/screens/ProjectsIndex'; import { ProjectDetail } from '@/screens/ProjectDetail'; +import { ProjectEdit } from '@/screens/ProjectEdit'; import { PeopleIndex } from '@/screens/PeopleIndex'; import { PersonDetail } from '@/screens/PersonDetail'; +import { ProfileEdit } from '@/screens/ProfileEdit'; +import { Account } from '@/screens/Account'; import { HelpWantedIndex } from '@/screens/HelpWantedIndex'; import { ProjectUpdatesFeed } from '@/screens/ProjectUpdatesFeed'; import { ProjectBuzzFeed } from '@/screens/ProjectBuzzFeed'; @@ -27,9 +31,9 @@ const router = createBrowserRouter([ children: [ { path: '/', element: }, { path: '/projects', element: }, - { path: '/projects/create', element: }, + { path: '/projects/create', element: }, { path: '/projects/:slug', element: }, - { path: '/projects/:slug/edit', element: }, + { path: '/projects/:slug/edit', element: }, { path: '/projects/:slug/updates/:number', element: }, { path: '/projects/:slug/buzz/:buzzSlug', element: }, { path: '/projects/:slug/buzz/new', element: }, @@ -37,7 +41,7 @@ const router = createBrowserRouter([ { path: '/people', element: }, { path: '/members', element: }, { path: '/members/:slug', element: }, - { path: '/members/:slug/edit', element: }, + { path: '/members/:slug/edit', element: }, { path: '/project-updates', element: }, { path: '/project-buzz', element: }, { path: '/tags', element: }, @@ -45,7 +49,7 @@ const router = createBrowserRouter([ { path: '/tags/:namespace/:slug', element: }, { path: '/volunteer', element: }, { path: '/sponsor', element: }, - { path: '/account', element: }, + { path: '/account', element: }, { path: '/search', element: }, { path: '/pages/:slug', element: }, { path: '/contact', element: }, @@ -68,6 +72,7 @@ export function App() { + diff --git a/apps/web/src/components/HelpWantedCard.tsx b/apps/web/src/components/HelpWantedCard.tsx index 570b1d1..75cf2d2 100644 --- a/apps/web/src/components/HelpWantedCard.tsx +++ b/apps/web/src/components/HelpWantedCard.tsx @@ -1,8 +1,10 @@ +import { useState } from 'react'; import { Link } from 'react-router'; import { Button } from '@/components/ui/button'; import { TagChip } from '@/components/TagChip'; import { PersonAvatar } from '@/components/PersonAvatar'; import { MarkdownView } from '@/components/MarkdownView'; +import { ExpressInterestModal } from '@/components/modals/ExpressInterestModal'; import { useAuth } from '@/hooks/useAuth'; import { formatRelativeTime } from '@/lib/time'; import type { HelpWantedRoleResponse } from '@/lib/api'; @@ -20,6 +22,7 @@ function commitmentLabel(hours: number | null): string { export function HelpWantedCard({ role, showProjectLink = true }: HelpWantedCardProps) { const { person } = useAuth(); const isSignedIn = person !== null; + const [modalOpen, setModalOpen] = useState(false); return (
@@ -70,7 +73,11 @@ export function HelpWantedCard({ role, showProjectLink = true }: HelpWantedCardP Interest Sent ✓ ) : ( - ) @@ -82,6 +89,13 @@ export function HelpWantedCard({ role, showProjectLink = true }: HelpWantedCardP )} +
); } diff --git a/apps/web/src/components/MarkdownEditor.tsx b/apps/web/src/components/MarkdownEditor.tsx new file mode 100644 index 0000000..b1f8d2d --- /dev/null +++ b/apps/web/src/components/MarkdownEditor.tsx @@ -0,0 +1,211 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { MarkdownView } from '@/components/MarkdownView'; +import { cn } from '@/lib/utils'; +import { api, ApiError } from '@/lib/api'; + +interface MarkdownEditorProps { + label?: string; + description?: string; + value: string; + onChange: (next: string) => void; + placeholder?: string; + maxLength?: number; + minHeight?: number; + error?: string; + required?: boolean; +} + +interface ToolbarButton { + label: string; + insert: string; + wrap?: { before: string; after: string }; +} + +const TOOLBAR: ToolbarButton[] = [ + { label: 'B', insert: 'bold text', wrap: { before: '**', after: '**' } }, + { label: 'I', insert: 'italic text', wrap: { before: '_', after: '_' } }, + { label: 'Link', insert: 'link text', wrap: { before: '[', after: '](https://)' } }, + { label: 'List', insert: '- item' }, + { label: 'Code', insert: 'code', wrap: { before: '`', after: '`' } }, + { label: 'Quote', insert: '> quote' }, +]; + +/** + * Shared markdown editor used across authoring screens. + * + * Per specs/behaviors/markdown-rendering.md, preview is rendered server-side + * via POST /api/_preview — there is intentionally no client-side markdown + * parser bundled. We debounce the round-trip; the textarea remains responsive + * because rendering happens asynchronously. + */ +export function MarkdownEditor({ + label, + description, + value, + onChange, + placeholder, + maxLength, + minHeight = 220, + error, + required, +}: MarkdownEditorProps) { + const id = useId(); + const textareaRef = useRef(null); + const [previewHtml, setPreviewHtml] = useState(''); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + + useEffect(() => { + let cancelled = false; + const trimmed = value.trim(); + if (!trimmed) { + // No content → no async work. Stale state is cleared in render below. + return; + } + const startTimer = setTimeout(() => { + if (cancelled) return; + // Mark loading right before the network call (not in effect body) — this + // keeps the effect free of cascading setState per react-hooks rules. + setPreviewLoading(true); + api + .preview(value) + .then((res) => { + if (cancelled) return; + setPreviewHtml(res.data.html); + setPreviewError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err instanceof ApiError) { + setPreviewError(err.message); + } else { + setPreviewError('Preview unavailable'); + } + }) + .finally(() => { + if (!cancelled) setPreviewLoading(false); + }); + }, 350); + return () => { + cancelled = true; + clearTimeout(startTimer); + }; + }, [value]); + + // Sync render-derived clears so an empty source shows the placeholder + // without setState-in-effect. + const trimmedValue = value.trim(); + if (!trimmedValue && previewHtml !== '') { + setPreviewHtml(''); + } + if (!trimmedValue && previewError !== null) { + setPreviewError(null); + } + if (!trimmedValue && previewLoading) { + setPreviewLoading(false); + } + + const applyToolbar = (btn: ToolbarButton) => { + const ta = textareaRef.current ?? (document.getElementById(id) as HTMLTextAreaElement | null); + if (!ta) return; + const start = ta.selectionStart ?? value.length; + const end = ta.selectionEnd ?? value.length; + const selected = value.slice(start, end) || btn.insert; + let inserted: string; + let cursorOffset: number; + if (btn.wrap) { + inserted = `${btn.wrap.before}${selected}${btn.wrap.after}`; + cursorOffset = start + btn.wrap.before.length + selected.length + btn.wrap.after.length; + } else { + inserted = selected; + cursorOffset = start + inserted.length; + } + const next = value.slice(0, start) + inserted + value.slice(end); + onChange(next); + requestAnimationFrame(() => { + ta.focus(); + ta.setSelectionRange(cursorOffset, cursorOffset); + }); + }; + + const count = value.length; + const overSoftLimit = maxLength !== undefined && count > maxLength; + + return ( +
+ {label && ( + + )} + {description && ( +

{description}

+ )} +
+
+ {TOOLBAR.map((btn) => ( + + ))} +
+
+