From 32ab1b34cc1d390456dac19ba5ba7db94c3514a9 Mon Sep 17 00:00:00 2001 From: sembauke Date: Sat, 14 Feb 2026 10:35:40 +0100 Subject: [PATCH 1/3] feat(turbo): enhance build outputs and add dev task configuration - Updated build task to include outputs from the .next directory. - Introduced a new dev task with caching disabled and persistent mode enabled. - Modified codegen task to include additional generated files from the GraphQL schema. --- packages/studio/.prettierrc | 7 + packages/studio/README.md | 159 + packages/studio/codegen.ts | 21 + packages/studio/next-env.d.ts | 6 + packages/studio/next.config.ts | 7 + packages/studio/package.json | 42 + packages/studio/postcss.config.mjs | 8 + .../src/app/blocks/[dashedName]/page.tsx | 338 + .../studio/src/app/challenges/[id]/page.tsx | 155 + packages/studio/src/app/drafts/page.tsx | 97 + packages/studio/src/app/globals.css | 29 + packages/studio/src/app/layout.tsx | 28 + packages/studio/src/app/page.tsx | 108 + .../src/app/superblocks/[dashedName]/page.tsx | 158 + .../studio/src/components/breadcrumbs.tsx | 33 + .../studio/src/components/challenge-table.tsx | 121 + .../studio/src/components/diff-viewer.tsx | 78 + .../studio/src/components/draft-indicator.tsx | 23 + packages/studio/src/components/providers.tsx | 8 + packages/studio/src/components/sidebar.tsx | 89 + packages/studio/src/components/ui/badge.tsx | 34 + packages/studio/src/components/ui/button.tsx | 45 + packages/studio/src/components/ui/card.tsx | 32 + packages/studio/src/components/ui/input.tsx | 31 + packages/studio/src/components/ui/select.tsx | 44 + packages/studio/src/graphql/queries.ts | 87 + packages/studio/src/graphql/types.ts | 124 + .../studio/src/lib/__tests__/drafts.test.ts | 243 + packages/studio/src/lib/drafts.ts | 121 + packages/studio/src/lib/urql.ts | 11 + packages/studio/src/lib/use-draft.ts | 142 + packages/studio/src/lib/utils.ts | 40 + packages/studio/src/lib/validation.ts | 47 + packages/studio/tsconfig.json | 28 + packages/studio/vitest.config.ts | 13 + pnpm-lock.yaml | 6231 ++++++++++++----- turbo.json | 10 +- 37 files changed, 7088 insertions(+), 1710 deletions(-) create mode 100644 packages/studio/.prettierrc create mode 100644 packages/studio/README.md create mode 100644 packages/studio/codegen.ts create mode 100644 packages/studio/next-env.d.ts create mode 100644 packages/studio/next.config.ts create mode 100644 packages/studio/package.json create mode 100644 packages/studio/postcss.config.mjs create mode 100644 packages/studio/src/app/blocks/[dashedName]/page.tsx create mode 100644 packages/studio/src/app/challenges/[id]/page.tsx create mode 100644 packages/studio/src/app/drafts/page.tsx create mode 100644 packages/studio/src/app/globals.css create mode 100644 packages/studio/src/app/layout.tsx create mode 100644 packages/studio/src/app/page.tsx create mode 100644 packages/studio/src/app/superblocks/[dashedName]/page.tsx create mode 100644 packages/studio/src/components/breadcrumbs.tsx create mode 100644 packages/studio/src/components/challenge-table.tsx create mode 100644 packages/studio/src/components/diff-viewer.tsx create mode 100644 packages/studio/src/components/draft-indicator.tsx create mode 100644 packages/studio/src/components/providers.tsx create mode 100644 packages/studio/src/components/sidebar.tsx create mode 100644 packages/studio/src/components/ui/badge.tsx create mode 100644 packages/studio/src/components/ui/button.tsx create mode 100644 packages/studio/src/components/ui/card.tsx create mode 100644 packages/studio/src/components/ui/input.tsx create mode 100644 packages/studio/src/components/ui/select.tsx create mode 100644 packages/studio/src/graphql/queries.ts create mode 100644 packages/studio/src/graphql/types.ts create mode 100644 packages/studio/src/lib/__tests__/drafts.test.ts create mode 100644 packages/studio/src/lib/drafts.ts create mode 100644 packages/studio/src/lib/urql.ts create mode 100644 packages/studio/src/lib/use-draft.ts create mode 100644 packages/studio/src/lib/utils.ts create mode 100644 packages/studio/src/lib/validation.ts create mode 100644 packages/studio/tsconfig.json create mode 100644 packages/studio/vitest.config.ts diff --git a/packages/studio/.prettierrc b/packages/studio/.prettierrc new file mode 100644 index 0000000..5329a56 --- /dev/null +++ b/packages/studio/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "printWidth": 80, + "trailingComma": "es5" +} diff --git a/packages/studio/README.md b/packages/studio/README.md new file mode 100644 index 0000000..80f0fb6 --- /dev/null +++ b/packages/studio/README.md @@ -0,0 +1,159 @@ +# Curriculum Studio + +Visual editor for freeCodeCamp curriculum metadata. Connects to the curriculum GraphQL API to browse and edit superblocks, blocks, and challenges. + +**MVP1** -- read-only GraphQL queries with local-only draft editing. No authentication, no mutations. + +## Setup + +### Prerequisites + +- Node.js 20+ +- pnpm 10+ +- The GraphQL API server running (see root project README) + +### Environment + +Copy the example env file and adjust if needed: + +```bash +cp .env.local.example .env.local +``` + +| Variable | Default | Description | +| ------------------------- | ------------------------------- | -------------------- | +| `NEXT_PUBLIC_GRAPHQL_URL` | `http://localhost:4000/graphql` | GraphQL API endpoint | + +### Commands + +```bash +pnpm dev # Start dev server (Turbopack) +pnpm build # Production build +pnpm type-check # TypeScript strict check +pnpm lint # Lint with oxlint +pnpm test # Run tests with vitest +pnpm codegen # Regenerate GraphQL types from schema +``` + +To run the full stack from the monorepo root: + +```bash +pnpm develop # Starts both the GraphQL server and Studio +``` + +## Architecture + +### Tech stack + +- **Framework**: Next.js 15 (App Router) with TypeScript strict mode +- **GraphQL client**: urql v5 with document cache +- **UI**: Tailwind CSS v4, shadcn-style components (CVA + clsx) +- **Draft system**: localStorage + fast-json-patch (RFC 6902) +- **Testing**: vitest + +### Project structure + +``` +src/ + app/ # Next.js App Router pages + page.tsx # Curriculum overview (/) + layout.tsx # Root layout with sidebar + superblocks/ + [dashedName]/page.tsx # Superblock detail + blocks/ + [dashedName]/page.tsx # Block detail (main editing page) + challenges/ + [id]/page.tsx # Challenge detail + drafts/ + page.tsx # Drafts listing + components/ # React components + providers.tsx # urql Provider + sidebar.tsx # Searchable superblock sidebar + breadcrumbs.tsx # Breadcrumb navigation + challenge-table.tsx # Challenge order table with reorder + diff-viewer.tsx # JSON Patch diff viewer + draft-indicator.tsx # Draft status badges + ui/ # Reusable UI primitives + graphql/ + types.ts # TypeScript types matching GraphQL schema + queries.ts # GraphQL query definitions + lib/ + drafts.ts # Draft store (localStorage + JSON Patch) + use-draft.ts # React hook for draft management + validation.ts # Validation rules + urql.ts # urql client configuration + utils.ts # Shared utilities +``` + +## How drafts work + +Drafts are stored in `localStorage` using keys like `draft:block:{dashedName}` and `draft:challenge:{id}`. + +Each draft record contains: + +- `updatedAt` -- ISO timestamp of last save +- `originalHash` -- djb2 hash of the original data (for drift detection) +- `patch` -- array of RFC 6902 JSON Patch operations + +### Draft lifecycle + +1. Open a block or challenge page +2. If a saved draft exists, it is applied on top of the current server data +3. Edit fields -- changes are held in memory (not written to localStorage on every keystroke) +4. Click **Save Draft** to persist to localStorage +5. Click **Discard Changes** to reset to original server data + +### Drift detection + +When the server data changes after a draft was created, the `originalHash` will not match. The UI shows a "Draft may be out of date" warning. The draft can still be viewed and edited. + +## How to export/import patches + +### Export + +On the block detail page, click **Export Patch**. This downloads a JSON file containing: + +```json +{ + "type": "block", + "id": "basic-html", + "updatedAt": "2025-01-15T10:30:00.000Z", + "originalHash": "abc123", + "patch": [{ "op": "replace", "path": "/helpCategory", "value": "JavaScript" }] +} +``` + +### Import + +Click **Import Patch** and select a previously exported JSON file. The patch operations are applied to the current server data and loaded into the editor. You can then review and save the draft. + +## Schema assumptions + +- `Challenge.content` always returns `null` in MVP. The UI shows "Content not available in MVP". +- `Block.blockLabel` is nullable (some blocks do not have a label). +- `Block.usesMultifileEditor` and `Block.hasEditableBoundaries` are nullable booleans. +- `Block.superblocks` returns parent superblocks (blocks can be shared across superblocks in v9). +- The GraphQL schema uses `BlockLabel` (not `BlockType`) for pedagogical classification. + +## Validation rules + +- `block.helpCategory` must be non-empty +- `challenge.title` must be non-empty +- `challengeOrder` must not contain duplicate challenge IDs +- Enum selects only allow valid values (enforced by the UI) +- Saving a draft is blocked when validation fails + +## GraphQL Code Generation + +The codegen config (`codegen.ts`) points to the server's GraphQL schema file. Running `pnpm codegen` generates typed document nodes in `src/graphql/generated/`. This is optional -- the app uses manually maintained types in `src/graphql/types.ts` by default. + +## Follow-up improvements (MVP2) + +- **Mutations**: persist edits to the server via GraphQL mutations +- **Review workflow**: draft approval flow with multiple reviewers +- **Authentication**: user login and role-based permissions +- **Drag-and-drop**: replace up/down buttons with drag-and-drop reorder (e.g. @dnd-kit) +- **Undo/redo**: operation history for draft edits +- **Collaborative editing**: real-time multi-user editing with conflict resolution +- **Challenge content**: display and edit challenge descriptions, instructions, and tests +- **Search**: global search across all blocks and challenges diff --git a/packages/studio/codegen.ts b/packages/studio/codegen.ts new file mode 100644 index 0000000..d8c650a --- /dev/null +++ b/packages/studio/codegen.ts @@ -0,0 +1,21 @@ +import type { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + schema: '../server/src/schema/schema.graphql', + documents: ['src/**/*.ts', 'src/**/*.tsx'], + generates: { + 'src/graphql/generated/': { + preset: 'client', + config: { + useTypeImports: true, + enumsAsTypes: true, + scalars: { + ID: 'string', + }, + }, + }, + }, + ignoreNoDocuments: true, +}; + +export default config; diff --git a/packages/studio/next-env.d.ts b/packages/studio/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/packages/studio/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/studio/next.config.ts b/packages/studio/next.config.ts new file mode 100644 index 0000000..94647ad --- /dev/null +++ b/packages/studio/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + output: 'standalone', +}; + +export default nextConfig; diff --git a/packages/studio/package.json b/packages/studio/package.json new file mode 100644 index 0000000..3908f7c --- /dev/null +++ b/packages/studio/package.json @@ -0,0 +1,42 @@ +{ + "name": "@freecodecamp/studio", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --turbopack", + "develop": "next dev --turbopack", + "build": "next build", + "start": "next start", + "type-check": "tsc --noEmit", + "lint": "oxlint src", + "format": "prettier --write src/", + "format:check": "prettier --check src/", + "test": "vitest run", + "test:watch": "vitest", + "codegen": "graphql-codegen --config codegen.ts" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "fast-json-patch": "^3.1.1", + "lucide-react": "^0.474.0", + "next": "^15.3.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.0.2", + "urql": "^5.0.1" + }, + "devDependencies": { + "@graphql-codegen/cli": "^6.0.1", + "@graphql-codegen/client-preset": "^4.8.2", + "@tailwindcss/postcss": "^4.1.8", + "@types/node": "^24.9.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "graphql": "^16.11.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.8", + "typescript": "^5.9.3" + } +} diff --git a/packages/studio/postcss.config.mjs b/packages/studio/postcss.config.mjs new file mode 100644 index 0000000..5d6d845 --- /dev/null +++ b/packages/studio/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/packages/studio/src/app/blocks/[dashedName]/page.tsx b/packages/studio/src/app/blocks/[dashedName]/page.tsx new file mode 100644 index 0000000..9cccf17 --- /dev/null +++ b/packages/studio/src/app/blocks/[dashedName]/page.tsx @@ -0,0 +1,338 @@ +'use client'; + +import { useState, useRef, use } from 'react'; +import Link from 'next/link'; +import { useQuery } from 'urql'; +import { BLOCK_DETAIL_QUERY } from '@/graphql/queries'; +import type { BlockDetailResult, BlockDetail } from '@/graphql/types'; +import { BLOCK_LAYOUTS, BLOCK_LABELS } from '@/graphql/types'; +import { useDraft } from '@/lib/use-draft'; +import { validateBlock, type ValidationError } from '@/lib/validation'; +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { DraftIndicator } from '@/components/draft-indicator'; +import { DiffViewer } from '@/components/diff-viewer'; +import { ChallengeTable } from '@/components/challenge-table'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; +import { Card, CardHeader, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Download, Upload, Eye, Save, Undo2 } from 'lucide-react'; + +export default function BlockDetailPage({ + params, +}: { + params: Promise<{ dashedName: string }>; +}) { + const { dashedName } = use(params); + const [result] = useQuery({ + query: BLOCK_DETAIL_QUERY, + variables: { dashedName }, + }); + + const original = result.data?.block ?? undefined; + const draft = useDraft('block', dashedName, original); + + const [showDiff, setShowDiff] = useState(false); + const [errors, setErrors] = useState([]); + const fileInputRef = useRef(null); + + const { data, fetching, error: queryError } = result; + + if (fetching) { + return ( +
+

Loading block...

+
+ ); + } + + if (queryError) { + return ( +
+

Error: {queryError.message}

+
+ ); + } + + if (!data?.block || !draft.edited) { + return ( +
+

Block not found

+
+ ); + } + + const block = draft.edited; + const parentSuperblock = block.superblocks[0]; + + function getFieldError(field: string): string | undefined { + return errors.find((e) => e.field === field)?.message; + } + + function handleSave() { + const validationErrors = validateBlock(block); + setErrors(validationErrors); + if (validationErrors.length > 0) return; + draft.save(); + } + + function handleExport() { + const json = draft.getExportData(); + if (!json) return; + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `patch-block-${dashedName}.json`; + a.click(); + URL.revokeObjectURL(url); + } + + function handleImport(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + draft.importPatchData(reader.result as string); + }; + reader.readAsText(file); + e.target.value = ''; + } + + const titleErrors = new Map(); + for (const err of errors) { + if (err.field.startsWith('challenge:')) { + titleErrors.set(err.field.replace('challenge:', ''), err.message); + } + } + + return ( +
+ + +
+
+

{block.name}

+

+ {block.dashedName} +

+
+ +
+ + {block.superblocks.length > 1 && ( +
+ Shared across: + {block.superblocks.map((sb) => ( + + {sb.name} + + ))} +
+ )} + + {/* Block Metadata */} + + +

Block Metadata

+
+ +
+ + draft.updateEdited((prev) => ({ + ...prev, + helpCategory: e.target.value, + })) + } + error={getFieldError('helpCategory')} + /> + + + draft.updateEdited((prev) => ({ + ...prev, + blockLabel: + (e.target.value as BlockDetail['blockLabel']) || null, + })) + } + options={[ + { value: '', label: 'None' }, + ...BLOCK_LABELS.map((v) => ({ value: v, label: v })), + ]} + /> + +
+ + + + + +
+
+
+
+ + {/* Challenge Order */} +
+

+ Challenge Order ({block.challengeOrder.length}) +

+ {getFieldError('challengeOrder') && ( +

+ {getFieldError('challengeOrder')} +

+ )} + + draft.updateEdited((prev) => ({ ...prev, challengeOrder })) + } + titleErrors={titleErrors} + /> +
+ + {/* Action Buttons */} +
+ + + + + + +
+ + {errors.length > 0 && ( +
+

+ Cannot save draft. Fix the following errors: +

+
    + {errors.map((e, i) => ( +
  • {e.message}
  • + ))} +
+
+ )} + + {/* Diff Viewer */} + {showDiff && ( +
+ setShowDiff(false)} + /> +
+ )} +
+ ); +} diff --git a/packages/studio/src/app/challenges/[id]/page.tsx b/packages/studio/src/app/challenges/[id]/page.tsx new file mode 100644 index 0000000..45ae780 --- /dev/null +++ b/packages/studio/src/app/challenges/[id]/page.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { use } from 'react'; +import Link from 'next/link'; +import { useQuery } from 'urql'; +import { CHALLENGE_DETAIL_QUERY } from '@/graphql/queries'; +import type { ChallengeDetailResult, ChallengeDetail } from '@/graphql/types'; +import { useDraft } from '@/lib/use-draft'; +import { validateChallengeTitle } from '@/lib/validation'; +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { DraftIndicator } from '@/components/draft-indicator'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardHeader, CardContent } from '@/components/ui/card'; +import { Save, Undo2 } from 'lucide-react'; + +export default function ChallengeDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const [result] = useQuery({ + query: CHALLENGE_DETAIL_QUERY, + variables: { id }, + }); + + const original = result.data?.challenge ?? undefined; + const draft = useDraft('challenge', id, original); + + const { fetching, error: queryError } = result; + + if (fetching) { + return ( +
+

Loading challenge...

+
+ ); + } + + if (queryError) { + return ( +
+

Error: {queryError.message}

+
+ ); + } + + if (!original || !draft.edited) { + return ( +
+

Challenge not found

+
+ ); + } + + const challenge = draft.edited; + const titleErrors = validateChallengeTitle(challenge.title); + + function handleSave() { + if (titleErrors.length > 0) return; + draft.save(); + } + + return ( +
+ + +
+

{challenge.title}

+ +
+ + + +

Challenge Metadata

+
+ +
+ +

+ {challenge.id} +

+
+ + + draft.updateEdited((prev) => ({ + ...prev, + title: e.target.value, + })) + } + error={titleErrors[0]?.message} + /> + +
+ +

+ + {challenge.block.name} + +

+
+
+
+ + + +

Content

+
+ +
+

+ Content not available in MVP +

+

+ Challenge content (description, instructions, tests) will be + available in a future version. +

+
+
+
+ +
+ + +
+
+ ); +} diff --git a/packages/studio/src/app/drafts/page.tsx b/packages/studio/src/app/drafts/page.tsx new file mode 100644 index 0000000..eab2588 --- /dev/null +++ b/packages/studio/src/app/drafts/page.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { + listAllDrafts, + discardDraftRecord, + type DraftEntry, +} from '@/lib/drafts'; +import { formatDate } from '@/lib/utils'; +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Trash2, ExternalLink, FileText } from 'lucide-react'; + +export default function DraftsPage() { + const [drafts, setDrafts] = useState([]); + + useEffect(() => { + setDrafts(listAllDrafts()); + }, []); + + function removeDraft(entry: DraftEntry) { + discardDraftRecord(entry.type, entry.id); + setDrafts(listAllDrafts()); + } + + function getLink(entry: DraftEntry): string { + if (entry.type === 'block') return `/blocks/${entry.id}`; + return `/challenges/${entry.id}`; + } + + return ( +
+ + +

Draft Changes

+

+ All locally saved drafts. These are stored in your browser and not + persisted to the server. +

+ + {drafts.length === 0 ? ( + + + +

No drafts yet

+

+ Edit a block or challenge and save a draft to see it here. +

+
+
+ ) : ( +
+ {drafts.map((entry) => ( +
+
+ {entry.type} +
+

{entry.id}

+

+ {entry.record.patch.length} change + {entry.record.patch.length !== 1 ? 's' : ''} --{' '} + {formatDate(entry.record.updatedAt)} +

+
+
+
+ + + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/packages/studio/src/app/globals.css b/packages/studio/src/app/globals.css new file mode 100644 index 0000000..aae2820 --- /dev/null +++ b/packages/studio/src/app/globals.css @@ -0,0 +1,29 @@ +@import 'tailwindcss'; + +@theme { + --color-background: #ffffff; + --color-foreground: #0a0a0a; + --color-muted: #f5f5f5; + --color-muted-foreground: #737373; + --color-border: #e5e5e5; + --color-primary: #0a0a0a; + --color-primary-foreground: #ffffff; + --color-accent: #f5f5f5; + --color-accent-foreground: #0a0a0a; + --color-destructive: #ef4444; + --color-destructive-foreground: #ffffff; + --color-warning: #f59e0b; + --color-success: #22c55e; + --color-draft: #3b82f6; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; +} + +@layer base { + body { + font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); + background-color: var(--color-background); + color: var(--color-foreground); + } +} diff --git a/packages/studio/src/app/layout.tsx b/packages/studio/src/app/layout.tsx new file mode 100644 index 0000000..10564e7 --- /dev/null +++ b/packages/studio/src/app/layout.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; +import { Providers } from '@/components/providers'; +import { Sidebar } from '@/components/sidebar'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Curriculum Studio', + description: 'Visual editor for freeCodeCamp curriculum metadata', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
+ +
{children}
+
+
+ + + ); +} diff --git a/packages/studio/src/app/page.tsx b/packages/studio/src/app/page.tsx new file mode 100644 index 0000000..5b99a15 --- /dev/null +++ b/packages/studio/src/app/page.tsx @@ -0,0 +1,108 @@ +'use client'; + +import Link from 'next/link'; +import { useQuery } from 'urql'; +import { CURRICULUM_OVERVIEW_QUERY } from '@/graphql/queries'; +import type { CurriculumOverviewResult } from '@/graphql/types'; +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { BookOpen, Award, Layers } from 'lucide-react'; + +export default function CurriculumOverview() { + const [result] = useQuery({ + query: CURRICULUM_OVERVIEW_QUERY, + }); + + const { data, fetching, error } = result; + + if (fetching) { + return ( +
+

Loading curriculum data...

+
+ ); + } + + if (error) { + return ( +
+

Error: {error.message}

+

+ Make sure the GraphQL server is running at the configured endpoint. +

+
+ ); + } + + if (!data) return null; + + const totalBlocks = data.superblocks.reduce( + (sum, sb) => sum + sb.blocks.length, + 0 + ); + + return ( +
+ + +

Curriculum Overview

+

+ Browse and edit freeCodeCamp curriculum metadata +

+ +
+ + + +
+

{data.superblocks.length}

+

Superblocks

+
+
+
+ + + +
+

{data.certifications.length}

+

Certifications

+
+
+
+ + + +
+

{totalBlocks}

+

Total Blocks

+
+
+
+
+ +

Superblocks

+
+ {data.superblocks.map((sb) => ( + +
+ {sb.name} + + {sb.blocks.length} block{sb.blocks.length !== 1 ? 's' : ''} + +
+
+ {sb.isCertification && ( + Certification + )} +
+ + ))} +
+
+ ); +} diff --git a/packages/studio/src/app/superblocks/[dashedName]/page.tsx b/packages/studio/src/app/superblocks/[dashedName]/page.tsx new file mode 100644 index 0000000..b71908d --- /dev/null +++ b/packages/studio/src/app/superblocks/[dashedName]/page.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useState, use } from 'react'; +import Link from 'next/link'; +import { useQuery } from 'urql'; +import { SUPERBLOCK_DETAIL_QUERY } from '@/graphql/queries'; +import type { SuperblockDetailResult } from '@/graphql/types'; +import { BLOCK_LAYOUTS, BLOCK_LABELS } from '@/graphql/types'; +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { Badge } from '@/components/ui/badge'; +import { Select } from '@/components/ui/select'; + +export default function SuperblockDetailPage({ + params, +}: { + params: Promise<{ dashedName: string }>; +}) { + const { dashedName } = use(params); + const [result] = useQuery({ + query: SUPERBLOCK_DETAIL_QUERY, + variables: { dashedName }, + }); + + const [layoutFilter, setLayoutFilter] = useState(''); + const [labelFilter, setLabelFilter] = useState(''); + const [upcomingFilter, setUpcomingFilter] = useState(''); + + const { data, fetching, error } = result; + + if (fetching) { + return ( +
+

Loading superblock...

+
+ ); + } + + if (error) { + return ( +
+

Error: {error.message}

+
+ ); + } + + const superblock = data?.superblock; + if (!superblock) { + return ( +
+

Superblock not found

+
+ ); + } + + let blocks = superblock.blockObjects; + if (layoutFilter) { + blocks = blocks.filter((b) => b.blockLayout === layoutFilter); + } + if (labelFilter) { + blocks = blocks.filter((b) => b.blockLabel === labelFilter); + } + if (upcomingFilter === 'true') { + blocks = blocks.filter((b) => b.isUpcomingChange); + } else if (upcomingFilter === 'false') { + blocks = blocks.filter((b) => !b.isUpcomingChange); + } + + return ( +
+ + +
+

{superblock.name}

+ {superblock.isCertification && ( + Certification + )} +
+ +
+
+
Dashed Name
+
{superblock.dashedName}
+
+
+
Blocks
+
{superblock.blockObjects.length}
+
+
+ +
+ setLabelFilter(e.target.value)} + options={[ + { value: '', label: 'All labels' }, + ...BLOCK_LABELS.map((v) => ({ value: v, label: v })), + ]} + /> + updateTitle(index, e.target.value)} + className={`w-full rounded border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 ${error ? 'border-destructive' : 'border-border'}`} + /> + {error && ( +

{error}

+ )} + + +
+ + + + + +
+ + + ); + })} + + + {challenges.length === 0 && ( +

+ No challenges in this block +

+ )} +
+ ); +} diff --git a/packages/studio/src/components/diff-viewer.tsx b/packages/studio/src/components/diff-viewer.tsx new file mode 100644 index 0000000..0eda023 --- /dev/null +++ b/packages/studio/src/components/diff-viewer.tsx @@ -0,0 +1,78 @@ +'use client'; + +import type { Operation } from 'fast-json-patch'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { X } from 'lucide-react'; + +interface DiffViewerProps { + patch: Operation[]; + onClose: () => void; +} + +function opColor(op: string): string { + switch (op) { + case 'add': + return 'bg-success/10 text-success'; + case 'remove': + return 'bg-destructive/10 text-destructive'; + case 'replace': + return 'bg-warning/10 text-warning'; + case 'move': + return 'bg-draft/10 text-draft'; + default: + return 'bg-muted text-muted-foreground'; + } +} + +function formatValue(value: unknown): string { + if (value === undefined) return ''; + if (typeof value === 'string') return `"${value}"`; + return JSON.stringify(value); +} + +export function DiffViewer({ patch, onClose }: DiffViewerProps) { + if (patch.length === 0) { + return ( +
+

No changes detected

+ +
+ ); + } + + return ( +
+
+

+ Changes ({patch.length} operation{patch.length !== 1 ? 's' : ''}) +

+ +
+
+ {patch.map((op, i) => ( +
+ + {op.op} + + {op.path} + {'value' in op && ( + + {formatValue(op.value)} + + )} +
+ ))} +
+
+ ); +} diff --git a/packages/studio/src/components/draft-indicator.tsx b/packages/studio/src/components/draft-indicator.tsx new file mode 100644 index 0000000..af5149b --- /dev/null +++ b/packages/studio/src/components/draft-indicator.tsx @@ -0,0 +1,23 @@ +import { Badge } from '@/components/ui/badge'; + +interface DraftIndicatorProps { + hasSavedDraft: boolean; + hasUnsavedChanges: boolean; + isDraftOutdated: boolean; +} + +export function DraftIndicator({ + hasSavedDraft, + hasUnsavedChanges, + isDraftOutdated, +}: DraftIndicatorProps) { + return ( +
+ {hasSavedDraft && Draft changes} + {hasUnsavedChanges && Unsaved changes} + {isDraftOutdated && ( + Draft may be out of date + )} +
+ ); +} diff --git a/packages/studio/src/components/providers.tsx b/packages/studio/src/components/providers.tsx new file mode 100644 index 0000000..f7b04c2 --- /dev/null +++ b/packages/studio/src/components/providers.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { Provider } from 'urql'; +import { client } from '@/lib/urql'; + +export function Providers({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/studio/src/components/sidebar.tsx b/packages/studio/src/components/sidebar.tsx new file mode 100644 index 0000000..5059e8a --- /dev/null +++ b/packages/studio/src/components/sidebar.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useQuery } from 'urql'; +import { SUPERBLOCKS_QUERY } from '@/graphql/queries'; +import type { SuperblockListResult } from '@/graphql/types'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { BookOpen, FileText, Search } from 'lucide-react'; + +export function Sidebar() { + const [search, setSearch] = useState(''); + const [result] = useQuery({ + query: SUPERBLOCKS_QUERY, + }); + const pathname = usePathname(); + + const superblocks = result.data?.superblocks ?? []; + const filtered = superblocks.filter((sb) => + sb.name.toLowerCase().includes(search.toLowerCase()) + ); + + return ( + + ); +} diff --git a/packages/studio/src/components/ui/badge.tsx b/packages/studio/src/components/ui/badge.tsx new file mode 100644 index 0000000..cbe23c5 --- /dev/null +++ b/packages/studio/src/components/ui/badge.tsx @@ -0,0 +1,34 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground', + secondary: 'bg-muted text-muted-foreground', + outline: 'border border-border text-foreground', + draft: 'bg-draft/10 text-draft border border-draft/20', + warning: 'bg-warning/10 text-warning border border-warning/20', + success: 'bg-success/10 text-success border border-success/20', + destructive: + 'bg-destructive/10 text-destructive border border-destructive/20', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends + React.HTMLAttributes, + VariantProps {} + +export function Badge({ className, variant, ...props }: BadgeProps) { + return ( + + ); +} diff --git a/packages/studio/src/components/ui/button.tsx b/packages/studio/src/components/ui/button.tsx new file mode 100644 index 0000000..991df99 --- /dev/null +++ b/packages/studio/src/components/ui/button.tsx @@ -0,0 +1,45 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-border bg-background hover:bg-accent hover:text-accent-foreground', + ghost: 'hover:bg-accent hover:text-accent-foreground', + }, + size: { + sm: 'h-8 px-3 text-xs', + md: 'h-9 px-4', + lg: 'h-10 px-6', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'md', + }, + } +); + +export interface ButtonProps + extends + ButtonHTMLAttributes, + VariantProps {} + +export const Button = forwardRef( + ({ className, variant, size, ...props }, ref) => ( + - @@ -294,7 +349,11 @@ export default function BlockDetailPage({ {showDiff ? 'Hide Diff' : 'View Diff'} - @@ -333,6 +392,20 @@ export default function BlockDetailPage({ />
)} + + setIsDiscardModalOpen(false)} + onConfirm={handleDiscardConfirm} + /> + + ); } diff --git a/packages/studio/src/app/challenges/[id]/page.tsx b/packages/studio/src/app/challenges/[id]/page.tsx index 45ae780..f52d183 100644 --- a/packages/studio/src/app/challenges/[id]/page.tsx +++ b/packages/studio/src/app/challenges/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { use } from 'react'; +import { use, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { useQuery } from 'urql'; import { CHALLENGE_DETAIL_QUERY } from '@/graphql/queries'; @@ -10,6 +10,11 @@ import { validateChallengeTitle } from '@/lib/validation'; import { Breadcrumbs } from '@/components/breadcrumbs'; import { DraftIndicator } from '@/components/draft-indicator'; import { Button } from '@/components/ui/button'; +import { ConfirmModal } from '@/components/ui/confirm-modal'; +import { + FlashMessage, + type FlashMessageVariant, +} from '@/components/ui/flash-message'; import { Input } from '@/components/ui/input'; import { Card, CardHeader, CardContent } from '@/components/ui/card'; import { Save, Undo2 } from 'lucide-react'; @@ -27,9 +32,24 @@ export default function ChallengeDetailPage({ const original = result.data?.challenge ?? undefined; const draft = useDraft('challenge', id, original); + const flashTimeoutRef = useRef | null>(null); + const [flashMessage, setFlashMessage] = useState<{ + text: string; + variant: FlashMessageVariant; + } | null>(null); + const [isDiscardModalOpen, setIsDiscardModalOpen] = useState(false); const { fetching, error: queryError } = result; + useEffect( + () => () => { + if (flashTimeoutRef.current) { + clearTimeout(flashTimeoutRef.current); + } + }, + [] + ); + if (fetching) { return (
@@ -57,9 +77,47 @@ export default function ChallengeDetailPage({ const challenge = draft.edited; const titleErrors = validateChallengeTitle(challenge.title); + function clearFlashMessage() { + if (flashTimeoutRef.current) { + clearTimeout(flashTimeoutRef.current); + flashTimeoutRef.current = null; + } + setFlashMessage(null); + } + + function showFlashMessage( + text: string, + variant: FlashMessageVariant = 'success' + ) { + if (flashTimeoutRef.current) { + clearTimeout(flashTimeoutRef.current); + flashTimeoutRef.current = null; + } + + setFlashMessage({ text, variant }); + flashTimeoutRef.current = setTimeout(() => { + setFlashMessage(null); + flashTimeoutRef.current = null; + }, 2500); + } + function handleSave() { + clearFlashMessage(); + if (titleErrors.length > 0) return; - draft.save(); + + const saveResult = draft.save(); + if (saveResult === 'saved') { + showFlashMessage('Draft saved locally.', 'success'); + } else if (saveResult === 'no_changes') { + showFlashMessage('No active changes to save.', 'info'); + } + } + + function handleDiscardConfirm() { + draft.discard(); + setIsDiscardModalOpen(false); + showFlashMessage('Draft changes discarded.', 'info'); } return ( @@ -145,11 +203,25 @@ export default function ChallengeDetailPage({ Save Draft -
+ + setIsDiscardModalOpen(false)} + onConfirm={handleDiscardConfirm} + /> + + ); } diff --git a/packages/studio/src/app/modules/[dashedName]/page.tsx b/packages/studio/src/app/modules/[dashedName]/page.tsx new file mode 100644 index 0000000..1ee4227 --- /dev/null +++ b/packages/studio/src/app/modules/[dashedName]/page.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { use } from 'react'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { useQuery } from 'urql'; +import { MODULE_DETAIL_QUERY } from '@/graphql/queries'; +import type { ModuleDetailResult } from '@/graphql/types'; +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { Badge } from '@/components/ui/badge'; + +const ACRONYMS = new Set(['api', 'css', 'html', 'json', 'sql']); + +function formatDashedNameLabel(dashedName: string): string { + return dashedName + .split('-') + .filter(Boolean) + .map((word) => + ACRONYMS.has(word.toLowerCase()) + ? word.toUpperCase() + : word.charAt(0).toUpperCase() + word.slice(1) + ) + .join(' '); +} + +export default function ModuleDetailPage({ + params, +}: { + params: Promise<{ dashedName: string }>; +}) { + const { dashedName } = use(params); + const searchParams = useSearchParams(); + const selectedSuperblock = searchParams.get('superblock'); + const selectedChapter = searchParams.get('chapter'); + + const [result] = useQuery({ + query: MODULE_DETAIL_QUERY, + variables: { + superblockDashedName: selectedSuperblock || undefined, + chapterDashedName: selectedChapter || undefined, + }, + }); + + const matches = (result.data?.modules ?? []).filter( + (module) => module.dashedName === dashedName + ); + const moduleMatch = matches[0] ?? null; + const totalMatches = matches.length; + const fetching = result.fetching; + const error = result.error; + + if (fetching) { + return ( +
+

Loading module...

+
+ ); + } + + if (error) { + return ( +
+

Error: {error.message}

+
+ ); + } + + if (!moduleMatch) { + return ( +
+

Module not found

+
+ ); + } + + const moduleName = formatDashedNameLabel(moduleMatch.dashedName); + const chapterName = formatDashedNameLabel(moduleMatch.chapter.dashedName); + + return ( +
+ + +
+

{moduleName}

+ {moduleMatch.moduleType && ( + {moduleMatch.moduleType} + )} + {moduleMatch.comingSoon && Coming Soon} +
+ +
+
+
Dashed Name
+
{moduleMatch.dashedName}
+
+
+
Blocks
+
{moduleMatch.blocks.length}
+
+ {totalMatches > 1 && ( +
+
Note
+
+ Multiple modules share this dashed name +
+
+ )} +
+ +
+ {moduleMatch.blockObjects.map((block) => ( + +
+ {block.name} + + {block.dashedName} + +
+
+ {block.blockLayout} + {block.blockLabel && ( + {block.blockLabel} + )} + {block.isUpcomingChange && ( + Upcoming + )} +
+ + ))} + + {moduleMatch.blockObjects.length === 0 && ( +

+ This module currently has no blocks +

+ )} +
+
+ ); +} diff --git a/packages/studio/src/components/sidebar.tsx b/packages/studio/src/components/sidebar.tsx index 5059e8a..4d24992 100644 --- a/packages/studio/src/components/sidebar.tsx +++ b/packages/studio/src/components/sidebar.tsx @@ -1,25 +1,96 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useQuery } from 'urql'; -import { SUPERBLOCKS_QUERY } from '@/graphql/queries'; -import type { SuperblockListResult } from '@/graphql/types'; +import { SIDEBAR_NAV_QUERY } from '@/graphql/queries'; +import type { + SidebarNavResult, + SidebarSuperblockListItem, +} from '@/graphql/types'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; -import { BookOpen, FileText, Search } from 'lucide-react'; +import { + buildSidebarTree, + filterSidebarTree, + type SidebarModuleNode, +} from '@/lib/sidebar-nav'; +import { BookOpen, ChevronRight, FileText, Search } from 'lucide-react'; + +const HIDDEN_SUPERBLOCKS = new Set(['full-stack-open']); export function Sidebar() { const [search, setSearch] = useState(''); - const [result] = useQuery({ - query: SUPERBLOCKS_QUERY, + const [expandedCertifications, setExpandedCertifications] = useState< + Record + >({}); + const [expandedChapters, setExpandedChapters] = useState< + Record + >({}); + const [result] = useQuery({ + query: SIDEBAR_NAV_QUERY, }); const pathname = usePathname(); - const superblocks = result.data?.superblocks ?? []; - const filtered = superblocks.filter((sb) => - sb.name.toLowerCase().includes(search.toLowerCase()) + const sidebarTree = useMemo( + () => buildSidebarTree(result.data, HIDDEN_SUPERBLOCKS), + [result.data] + ); + const filteredTree = useMemo( + () => filterSidebarTree(sidebarTree, search), + [sidebarTree, search] + ); + const normalizedSearch = search.trim().toLowerCase(); + + const hasNoMatches = + !result.fetching && + !result.error && + filteredTree.certifications.length === 0 && + filteredTree.otherSuperblocks.length === 0; + + const isSuperblockActive = (dashedName: string) => + pathname === `/superblocks/${dashedName}`; + const isModuleActive = (dashedName: string) => + pathname === `/modules/${dashedName}`; + + const renderSuperblockLink = ( + superblock: SidebarSuperblockListItem, + className?: string + ) => ( + + {superblock.name} + {superblock.isCertification && ( + + Cert + + )} + + ); + + const renderModuleItem = (module: SidebarModuleNode, className?: string) => ( + + {module.name} + + {module.blockCount} block{module.blockCount === 1 ? '' : 's'} + + ); return ( @@ -38,7 +109,7 @@ export function Sidebar() { type="text" value={search} onChange={(e) => setSearch(e.target.value)} - placeholder="Search superblocks..." + placeholder="Search certifications and superblocks..." className="w-full rounded-md border border-border bg-background py-2 pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20" /> @@ -53,23 +124,147 @@ export function Sidebar() { Failed to load superblocks

)} - {filtered.map((sb) => ( - - {sb.name} - {sb.isCertification && ( - - Cert - + + {filteredTree.certifications.length > 0 && ( + <> +

+ Certifications +

+ {filteredTree.certifications.map((certification) => { + const hasActiveDescendant = + certification.structure === 'chaptered' + ? isSuperblockActive(certification.dashedName) || + certification.visibleChapters.some((chapter) => + chapter.visibleModules.some((module) => + isModuleActive(module.dashedName) + ) + ) + : certification.visibleSuperblocks.some((superblock) => + isSuperblockActive(superblock.dashedName) + ); + + const isExpanded = + normalizedSearch.length > 0 + ? true + : (expandedCertifications[certification.id] ?? + hasActiveDescendant); + + return ( +
+ + + {isExpanded && certification.structure === 'chaptered' && ( +
+ {certification.visibleChapters.map((chapter) => { + const hasActiveModule = chapter.visibleModules.some( + (module) => isModuleActive(module.dashedName) + ); + const chapterExpanded = + normalizedSearch.length > 0 + ? true + : (expandedChapters[chapter.id] ?? hasActiveModule); + + return ( +
+ + + {chapterExpanded && ( +
+ {chapter.visibleModules.map((module) => + renderModuleItem(module) + )} +
+ )} +
+ ); + })} +
+ )} + + {isExpanded && certification.structure === 'superblocks' && ( +
+ {certification.visibleSuperblocks.map((superblock) => + renderSuperblockLink(superblock) + )} +
+ )} +
+ ); + })} + + )} + + {filteredTree.otherSuperblocks.length > 0 && ( + <> +

+ Superblocks +

+ {filteredTree.otherSuperblocks.map((superblock) => + renderSuperblockLink(superblock) )} - - ))} + + )} + + {hasNoMatches && ( +

+ No matches found +

+ )}
diff --git a/packages/studio/src/components/ui/button.tsx b/packages/studio/src/components/ui/button.tsx index 991df99..af5a6c6 100644 --- a/packages/studio/src/components/ui/button.tsx +++ b/packages/studio/src/components/ui/button.tsx @@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + 'inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { diff --git a/packages/studio/src/components/ui/confirm-modal.tsx b/packages/studio/src/components/ui/confirm-modal.tsx new file mode 100644 index 0000000..f7a268c --- /dev/null +++ b/packages/studio/src/components/ui/confirm-modal.tsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; + +interface ConfirmModalProps { + open: boolean; + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmModal({ + open, + title, + description, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + onConfirm, + onCancel, +}: ConfirmModalProps) { + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onCancel(); + } + }; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open, onCancel]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="confirm-modal-title" + > +

+ {title} +

+

{description}

+
+ + +
+
+
+ ); +} diff --git a/packages/studio/src/components/ui/flash-message.tsx b/packages/studio/src/components/ui/flash-message.tsx new file mode 100644 index 0000000..8d2ee40 --- /dev/null +++ b/packages/studio/src/components/ui/flash-message.tsx @@ -0,0 +1,39 @@ +import { CheckCircle2, Info } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type FlashMessageVariant = 'success' | 'info'; + +interface FlashMessageProps { + message: string | null; + variant?: FlashMessageVariant; +} + +export function FlashMessage({ + message, + variant = 'success', +}: FlashMessageProps) { + if (!message) return null; + + const classes = + variant === 'success' + ? 'border-success/25 bg-success/10 text-success' + : 'border-draft/25 bg-draft/10 text-draft'; + + return ( +
+ {variant === 'success' ? ( + + ) : ( + + )} + {message} +
+ ); +} diff --git a/packages/studio/src/graphql/queries.ts b/packages/studio/src/graphql/queries.ts index 09cd79a..b54ff6c 100644 --- a/packages/studio/src/graphql/queries.ts +++ b/packages/studio/src/graphql/queries.ts @@ -10,6 +10,27 @@ export const SUPERBLOCKS_QUERY = gql` } `; +export const SIDEBAR_NAV_QUERY = gql` + query SidebarNav { + curriculum { + superblocks + certifications + } + superblocks { + name + dashedName + isCertification + chapters { + dashedName + modules { + dashedName + blocks + } + } + } + } +`; + export const CURRICULUM_OVERVIEW_QUERY = gql` query CurriculumOverview { curriculum { @@ -47,6 +68,39 @@ export const SUPERBLOCK_DETAIL_QUERY = gql` } `; +export const MODULE_DETAIL_QUERY = gql` + query ModuleDetail( + $superblockDashedName: String + $chapterDashedName: String + ) { + modules( + superblockDashedName: $superblockDashedName + chapterDashedName: $chapterDashedName + ) { + dashedName + moduleType + comingSoon + blocks + blockObjects { + name + dashedName + helpCategory + blockLayout + blockLabel + isUpcomingChange + } + chapter { + dashedName + superblock { + name + dashedName + isCertification + } + } + } + } +`; + export const BLOCK_DETAIL_QUERY = gql` query BlockDetail($dashedName: String!) { block(dashedName: $dashedName) { diff --git a/packages/studio/src/graphql/types.ts b/packages/studio/src/graphql/types.ts index 87ac75a..21c548e 100644 --- a/packages/studio/src/graphql/types.ts +++ b/packages/studio/src/graphql/types.ts @@ -48,6 +48,28 @@ export interface SuperblockListItem { isCertification: boolean; } +export interface SidebarChapterListItem { + dashedName: string; + modules: SidebarModuleListItem[]; +} + +export interface SidebarModuleListItem { + dashedName: string; + blocks: string[]; +} + +export interface SidebarSuperblockListItem extends SuperblockListItem { + chapters: SidebarChapterListItem[]; +} + +export interface SidebarNavResult { + curriculum: { + superblocks: string[]; + certifications: string[]; + }; + superblocks: SidebarSuperblockListItem[]; +} + export interface SuperblockDetail { name: string; dashedName: string; @@ -56,6 +78,18 @@ export interface SuperblockDetail { blockObjects: BlockListItem[]; } +export interface ModuleDetail { + dashedName: string; + moduleType: string | null; + comingSoon: boolean; + blocks: string[]; + blockObjects: BlockListItem[]; + chapter: { + dashedName: string; + superblock: SuperblockListItem; + }; +} + export interface BlockListItem { name: string; dashedName: string; @@ -115,6 +149,10 @@ export interface SuperblockDetailResult { superblock: SuperblockDetail | null; } +export interface ModuleDetailResult { + modules: ModuleDetail[]; +} + export interface BlockDetailResult { block: BlockDetail | null; } diff --git a/packages/studio/src/lib/sidebar-nav.ts b/packages/studio/src/lib/sidebar-nav.ts new file mode 100644 index 0000000..d9534bd --- /dev/null +++ b/packages/studio/src/lib/sidebar-nav.ts @@ -0,0 +1,257 @@ +import type { + SidebarNavResult, + SidebarSuperblockListItem, +} from '@/graphql/types'; + +type CertificationStructure = 'chaptered' | 'superblocks'; + +export interface SidebarModuleNode { + id: string; + dashedName: string; + name: string; + blockCount: number; + superblockDashedName: string; + chapterDashedName: string; +} + +export interface SidebarChapterNode { + id: string; + dashedName: string; + name: string; + modules: SidebarModuleNode[]; +} + +export interface SidebarCertificationNode { + id: string; + dashedName: string; + name: string; + structure: CertificationStructure; + chapters: SidebarChapterNode[]; + superblocks: SidebarSuperblockListItem[]; +} + +export interface SidebarTree { + certifications: SidebarCertificationNode[]; + otherSuperblocks: SidebarSuperblockListItem[]; +} + +export interface FilteredSidebarChapterNode extends SidebarChapterNode { + visibleModules: SidebarModuleNode[]; +} + +export interface FilteredSidebarCertificationNode extends SidebarCertificationNode { + visibleChapters: FilteredSidebarChapterNode[]; + visibleSuperblocks: SidebarSuperblockListItem[]; +} + +export interface FilteredSidebarTree { + certifications: FilteredSidebarCertificationNode[]; + otherSuperblocks: SidebarSuperblockListItem[]; +} + +const ACRONYMS = new Set(['api', 'css', 'html', 'json', 'sql']); + +function formatDashedNameLabel(dashedName: string): string { + const normalized = dashedName.includes('chapter-') + ? (dashedName.split('chapter-').at(-1) ?? dashedName) + : dashedName; + + return normalized + .split('-') + .filter(Boolean) + .map((word) => + ACRONYMS.has(word.toLowerCase()) + ? word.toUpperCase() + : word.charAt(0).toUpperCase() + word.slice(1) + ) + .join(' '); +} + +function matchesSearch(value: string, normalizedSearch: string): boolean { + return value.toLowerCase().includes(normalizedSearch); +} + +export function buildSidebarTree( + data: SidebarNavResult | undefined, + hiddenSuperblocks: ReadonlySet = new Set() +): SidebarTree { + if (!data) { + return { certifications: [], otherSuperblocks: [] }; + } + + const superblockMap = new Map( + data.superblocks.map((superblock) => [superblock.dashedName, superblock]) + ); + + const orderedSuperblocks = data.curriculum.superblocks + .map((dashedName) => superblockMap.get(dashedName)) + .filter( + (superblock): superblock is SidebarSuperblockListItem => + superblock !== undefined && + !hiddenSuperblocks.has(superblock.dashedName) + ); + + const consumedSuperblocks = new Set(); + const chapteredCertifications: SidebarCertificationNode[] = []; + const superblockCertifications: SidebarCertificationNode[] = []; + + for (const certificationDashedName of data.curriculum.certifications) { + if (hiddenSuperblocks.has(certificationDashedName)) continue; + + const certificationSuperblock = superblockMap.get(certificationDashedName); + if (!certificationSuperblock) continue; + + if (certificationSuperblock.chapters.length > 0) { + const chapters = certificationSuperblock.chapters + .map((chapter) => { + const modules = chapter.modules + .filter((module) => !hiddenSuperblocks.has(module.dashedName)) + .map((module) => ({ + id: `${certificationSuperblock.dashedName}:${chapter.dashedName}:${module.dashedName}`, + dashedName: module.dashedName, + name: formatDashedNameLabel(module.dashedName), + blockCount: module.blocks.length, + superblockDashedName: certificationSuperblock.dashedName, + chapterDashedName: chapter.dashedName, + })); + + if (modules.length === 0) return null; + + return { + id: `${certificationSuperblock.dashedName}:${chapter.dashedName}`, + dashedName: chapter.dashedName, + name: formatDashedNameLabel(chapter.dashedName), + modules, + }; + }) + .filter((chapter): chapter is SidebarChapterNode => chapter !== null); + + chapteredCertifications.push({ + id: certificationSuperblock.dashedName, + dashedName: certificationSuperblock.dashedName, + name: certificationSuperblock.name, + structure: 'chaptered', + chapters, + superblocks: [], + }); + + consumedSuperblocks.add(certificationSuperblock.dashedName); + continue; + } + + superblockCertifications.push({ + id: certificationSuperblock.dashedName, + dashedName: certificationSuperblock.dashedName, + name: certificationSuperblock.name, + structure: 'superblocks', + chapters: [], + superblocks: [certificationSuperblock], + }); + + consumedSuperblocks.add(certificationSuperblock.dashedName); + } + + const otherSuperblocks = orderedSuperblocks.filter( + (superblock) => !consumedSuperblocks.has(superblock.dashedName) + ); + + const certifications = [ + ...chapteredCertifications, + ...superblockCertifications, + ]; + + return { certifications, otherSuperblocks }; +} + +export function filterSidebarTree( + tree: SidebarTree, + rawSearch: string +): FilteredSidebarTree { + const normalizedSearch = rawSearch.trim().toLowerCase(); + + if (!normalizedSearch) { + return { + certifications: tree.certifications.map((certification) => ({ + ...certification, + visibleChapters: certification.chapters.map((chapter) => ({ + ...chapter, + visibleModules: chapter.modules, + })), + visibleSuperblocks: certification.superblocks, + })), + otherSuperblocks: tree.otherSuperblocks, + }; + } + + const certifications: FilteredSidebarCertificationNode[] = []; + + for (const certification of tree.certifications) { + const certificationMatches = + matchesSearch(certification.name, normalizedSearch) || + matchesSearch(certification.dashedName, normalizedSearch); + + if (certification.structure === 'chaptered') { + const visibleChapters: FilteredSidebarChapterNode[] = []; + + for (const chapter of certification.chapters) { + const chapterMatches = + matchesSearch(chapter.name, normalizedSearch) || + matchesSearch(chapter.dashedName, normalizedSearch); + + const matchingModules = chapter.modules.filter( + (module) => + matchesSearch(module.name, normalizedSearch) || + matchesSearch(module.dashedName, normalizedSearch) + ); + + if (certificationMatches || chapterMatches) { + visibleChapters.push({ + ...chapter, + visibleModules: chapter.modules, + }); + continue; + } + + if (matchingModules.length > 0) { + visibleChapters.push({ + ...chapter, + visibleModules: matchingModules, + }); + } + } + + if (!certificationMatches && visibleChapters.length === 0) continue; + + certifications.push({ + ...certification, + visibleChapters, + visibleSuperblocks: [], + }); + continue; + } + + const visibleSuperblocks = certificationMatches + ? certification.superblocks + : certification.superblocks.filter( + (superblock) => + matchesSearch(superblock.name, normalizedSearch) || + matchesSearch(superblock.dashedName, normalizedSearch) + ); + + if (!certificationMatches && visibleSuperblocks.length === 0) continue; + + certifications.push({ + ...certification, + visibleChapters: [], + visibleSuperblocks, + }); + } + + const otherSuperblocks = tree.otherSuperblocks.filter( + (superblock) => + matchesSearch(superblock.name, normalizedSearch) || + matchesSearch(superblock.dashedName, normalizedSearch) + ); + + return { certifications, otherSuperblocks }; +} diff --git a/packages/studio/src/lib/use-draft.ts b/packages/studio/src/lib/use-draft.ts index 4607827..90bb851 100644 --- a/packages/studio/src/lib/use-draft.ts +++ b/packages/studio/src/lib/use-draft.ts @@ -19,7 +19,7 @@ export interface UseDraftReturn { hasUnsavedChanges: boolean; isDraftOutdated: boolean; currentPatch: Operation[]; - save: () => void; + save: () => 'saved' | 'no_changes' | 'no_data'; discard: () => void; getExportData: () => string | null; importPatchData: (json: string) => void; @@ -57,13 +57,13 @@ export function useDraft( setHasUnsavedChanges(true); }, []); - const save = useCallback(() => { - if (!original || !edited) return; + const save = useCallback((): 'saved' | 'no_changes' | 'no_data' => { + if (!original || !edited) return 'no_data'; const patch = compare( original as Record, edited as Record ); - if (patch.length === 0) return; + if (patch.length === 0) return 'no_changes'; const record = { updatedAt: new Date().toISOString(), originalHash: hashObject(original), @@ -72,6 +72,7 @@ export function useDraft( localStorage.setItem(getDraftKey(type, id), JSON.stringify(record)); setHasSavedDraft(true); setHasUnsavedChanges(false); + return 'saved'; }, [original, edited, type, id]); const discard = useCallback(() => { From 338bc39faa60679fe9d1927141d9b18a2f9c4fc1 Mon Sep 17 00:00:00 2001 From: sembauke Date: Sat, 14 Feb 2026 13:09:58 +0100 Subject: [PATCH 3/3] feat(graphql): enhance challenge detail query and types - Added new fields to the CHALLENGE_DETAIL_QUERY including instructions, files, tests, and solutions. - Refactored types in types.ts to utilize generated GraphQL types for better type safety and maintainability. - Introduced new ModuleRouteKey interface and getModuleHref function for constructing module URLs. - Created a new index.ts file to export fragment masking and gql utilities. - Added typings for graphql-typed-document-node-core to improve type definitions for GraphQL documents. --- packages/server/src/schema/types.generated.ts | 60 +- packages/studio/codegen.ts | 1 + .../studio/src/app/challenges/[id]/page.tsx | 83 +- .../modules/[moduleDashedName]/page.tsx | 24 + .../module-detail-page.tsx} | 35 +- packages/studio/src/components/sidebar.tsx | 22 +- .../src/graphql/generated/fragment-masking.ts | 111 ++ packages/studio/src/graphql/generated/gql.ts | 104 ++ .../studio/src/graphql/generated/graphql.ts | 1282 +++++++++++++++++ .../studio/src/graphql/generated/index.ts | 2 + packages/studio/src/graphql/queries.ts | 14 + packages/studio/src/graphql/types.ts | 177 +-- packages/studio/src/lib/sidebar-nav.ts | 14 + .../graphql-typed-document-node-core.d.ts | 18 + 14 files changed, 1746 insertions(+), 201 deletions(-) create mode 100644 packages/studio/src/app/superblocks/[dashedName]/chapters/[chapterDashedName]/modules/[moduleDashedName]/page.tsx rename packages/studio/src/{app/modules/[dashedName]/page.tsx => components/module-detail-page.tsx} (84%) create mode 100644 packages/studio/src/graphql/generated/fragment-masking.ts create mode 100644 packages/studio/src/graphql/generated/gql.ts create mode 100644 packages/studio/src/graphql/generated/graphql.ts create mode 100644 packages/studio/src/graphql/generated/index.ts create mode 100644 packages/studio/src/types/graphql-typed-document-node-core.d.ts diff --git a/packages/server/src/schema/types.generated.ts b/packages/server/src/schema/types.generated.ts index 2effb99..7b9d631 100644 --- a/packages/server/src/schema/types.generated.ts +++ b/packages/server/src/schema/types.generated.ts @@ -685,8 +685,8 @@ export type ResolversParentTypes = { export type BlockResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['Block'] = ResolversParentTypes['Block'], + ParentType extends ResolversParentTypes['Block'] = + ResolversParentTypes['Block'], > = { blockLabel?: Resolver< Maybe, @@ -775,8 +775,8 @@ export type BlockLayoutResolvers = EnumResolverSignature< export type CertificationResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['Certification'] = ResolversParentTypes['Certification'], + ParentType extends ResolversParentTypes['Certification'] = + ResolversParentTypes['Certification'], > = { dashedName?: Resolver; superblock?: Resolver; @@ -784,8 +784,8 @@ export type CertificationResolvers< export type ChallengeResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['Challenge'] = ResolversParentTypes['Challenge'], + ParentType extends ResolversParentTypes['Challenge'] = + ResolversParentTypes['Challenge'], > = { block?: Resolver; content?: Resolver< @@ -799,8 +799,8 @@ export type ChallengeResolvers< export type ChallengeContentResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['ChallengeContent'] = ResolversParentTypes['ChallengeContent'], + ParentType extends ResolversParentTypes['ChallengeContent'] = + ResolversParentTypes['ChallengeContent'], > = { description?: Resolver; files?: Resolver< @@ -819,8 +819,8 @@ export type ChallengeContentResolvers< export type ChallengeFileResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['ChallengeFile'] = ResolversParentTypes['ChallengeFile'], + ParentType extends ResolversParentTypes['ChallengeFile'] = + ResolversParentTypes['ChallengeFile'], > = { contents?: Resolver; editableRegionBoundaries?: Resolver< @@ -834,8 +834,8 @@ export type ChallengeFileResolvers< export type ChapterResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['Chapter'] = ResolversParentTypes['Chapter'], + ParentType extends ResolversParentTypes['Chapter'] = + ResolversParentTypes['Chapter'], > = { comingSoon?: Resolver; dashedName?: Resolver; @@ -845,8 +845,8 @@ export type ChapterResolvers< export type CurriculumResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['Curriculum'] = ResolversParentTypes['Curriculum'], + ParentType extends ResolversParentTypes['Curriculum'] = + ResolversParentTypes['Curriculum'], > = { certifications?: Resolver< Array, @@ -862,8 +862,8 @@ export type CurriculumResolvers< export type DataStoreMetricsResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['DataStoreMetrics'] = ResolversParentTypes['DataStoreMetrics'], + ParentType extends ResolversParentTypes['DataStoreMetrics'] = + ResolversParentTypes['DataStoreMetrics'], > = { blockCount?: Resolver; challengeCount?: Resolver; @@ -875,8 +875,8 @@ export type DataStoreMetricsResolvers< export type HealthCheckResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['HealthCheck'] = ResolversParentTypes['HealthCheck'], + ParentType extends ResolversParentTypes['HealthCheck'] = + ResolversParentTypes['HealthCheck'], > = { dataStore?: Resolver< ResolversTypes['DataStoreMetrics'], @@ -889,8 +889,8 @@ export type HealthCheckResolvers< export type ModuleResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['Module'] = ResolversParentTypes['Module'], + ParentType extends ResolversParentTypes['Module'] = + ResolversParentTypes['Module'], > = { blockObjects?: Resolver< Array, @@ -910,8 +910,8 @@ export type ModuleResolvers< export type QueryResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['Query'] = ResolversParentTypes['Query'], + ParentType extends ResolversParentTypes['Query'] = + ResolversParentTypes['Query'], > = { _health?: Resolver; block?: Resolver< @@ -971,8 +971,8 @@ export type QueryResolvers< export type RequiredResourceResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['RequiredResource'] = ResolversParentTypes['RequiredResource'], + ParentType extends ResolversParentTypes['RequiredResource'] = + ResolversParentTypes['RequiredResource'], > = { link?: Resolver, ParentType, ContextType>; src?: Resolver, ParentType, ContextType>; @@ -980,8 +980,8 @@ export type RequiredResourceResolvers< export type SolutionResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['Solution'] = ResolversParentTypes['Solution'], + ParentType extends ResolversParentTypes['Solution'] = + ResolversParentTypes['Solution'], > = { files?: Resolver< Array, @@ -992,8 +992,8 @@ export type SolutionResolvers< export type SuperblockResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['Superblock'] = ResolversParentTypes['Superblock'], + ParentType extends ResolversParentTypes['Superblock'] = + ResolversParentTypes['Superblock'], > = { blockObjects?: Resolver< Array, @@ -1017,8 +1017,8 @@ export type SuperblockResolvers< export type TestResolvers< ContextType = DataProvider, - ParentType extends - ResolversParentTypes['Test'] = ResolversParentTypes['Test'], + ParentType extends ResolversParentTypes['Test'] = + ResolversParentTypes['Test'], > = { testString?: Resolver; text?: Resolver; diff --git a/packages/studio/codegen.ts b/packages/studio/codegen.ts index d8c650a..349da70 100644 --- a/packages/studio/codegen.ts +++ b/packages/studio/codegen.ts @@ -9,6 +9,7 @@ const config: CodegenConfig = { config: { useTypeImports: true, enumsAsTypes: true, + skipTypename: true, scalars: { ID: 'string', }, diff --git a/packages/studio/src/app/challenges/[id]/page.tsx b/packages/studio/src/app/challenges/[id]/page.tsx index f52d183..ec5641c 100644 --- a/packages/studio/src/app/challenges/[id]/page.tsx +++ b/packages/studio/src/app/challenges/[id]/page.tsx @@ -76,6 +76,7 @@ export default function ChallengeDetailPage({ const challenge = draft.edited; const titleErrors = validateChallengeTitle(challenge.title); + const challengeContent = challenge.content; function clearFlashMessage() { if (flashTimeoutRef.current) { @@ -186,15 +187,79 @@ export default function ChallengeDetailPage({

Content

-
-

- Content not available in MVP -

-

- Challenge content (description, instructions, tests) will be - available in a future version. -

-
+ {challengeContent ? ( +
+
+

Description

+

+ {challengeContent.description} +

+
+ +
+

Instructions

+

+ {challengeContent.instructions} +

+
+ +
+

Starter Files

+ {challengeContent.files.length === 0 ? ( +

No starter files

+ ) : ( +
    + {challengeContent.files.map((file) => ( +
  • + {file.name}.{file.ext} +
  • + ))} +
+ )} +
+ +
+

Tests

+ {challengeContent.tests.length === 0 ? ( +

No tests

+ ) : ( +
    + {challengeContent.tests.slice(0, 10).map((test, index) => ( +
  • + {index + 1}. {test.text} +
  • + ))} +
+ )} + {challengeContent.tests.length > 10 && ( +

+ Showing 10 of {challengeContent.tests.length} tests +

+ )} +
+ +
+

Solutions

+

+ {challengeContent.solutions.length} solution + {challengeContent.solutions.length === 1 ? '' : 's'} +

+
+
+ ) : ( +
+

+ Content not available in MVP +

+

+ Challenge content (description, instructions, tests) will be + available in a future version. +

+
+ )}
diff --git a/packages/studio/src/app/superblocks/[dashedName]/chapters/[chapterDashedName]/modules/[moduleDashedName]/page.tsx b/packages/studio/src/app/superblocks/[dashedName]/chapters/[chapterDashedName]/modules/[moduleDashedName]/page.tsx new file mode 100644 index 0000000..c1311ad --- /dev/null +++ b/packages/studio/src/app/superblocks/[dashedName]/chapters/[chapterDashedName]/modules/[moduleDashedName]/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { use } from 'react'; +import { ModuleDetailPage } from '@/components/module-detail-page'; + +export default function NestedModuleDetailRoute({ + params, +}: { + params: Promise<{ + dashedName: string; + chapterDashedName: string; + moduleDashedName: string; + }>; +}) { + const { dashedName, chapterDashedName, moduleDashedName } = use(params); + + return ( + + ); +} diff --git a/packages/studio/src/app/modules/[dashedName]/page.tsx b/packages/studio/src/components/module-detail-page.tsx similarity index 84% rename from packages/studio/src/app/modules/[dashedName]/page.tsx rename to packages/studio/src/components/module-detail-page.tsx index 1ee4227..8109975 100644 --- a/packages/studio/src/app/modules/[dashedName]/page.tsx +++ b/packages/studio/src/components/module-detail-page.tsx @@ -1,8 +1,6 @@ 'use client'; -import { use } from 'react'; import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; import { useQuery } from 'urql'; import { MODULE_DETAIL_QUERY } from '@/graphql/queries'; import type { ModuleDetailResult } from '@/graphql/types'; @@ -23,33 +21,32 @@ function formatDashedNameLabel(dashedName: string): string { .join(' '); } -export default function ModuleDetailPage({ - params, -}: { - params: Promise<{ dashedName: string }>; -}) { - const { dashedName } = use(params); - const searchParams = useSearchParams(); - const selectedSuperblock = searchParams.get('superblock'); - const selectedChapter = searchParams.get('chapter'); +interface ModuleDetailPageProps { + superblockDashedName: string; + chapterDashedName: string; + moduleDashedName: string; +} +export function ModuleDetailPage({ + superblockDashedName, + chapterDashedName, + moduleDashedName, +}: ModuleDetailPageProps) { const [result] = useQuery({ query: MODULE_DETAIL_QUERY, variables: { - superblockDashedName: selectedSuperblock || undefined, - chapterDashedName: selectedChapter || undefined, + superblockDashedName, + chapterDashedName, }, }); const matches = (result.data?.modules ?? []).filter( - (module) => module.dashedName === dashedName + (module) => module.dashedName === moduleDashedName ); const moduleMatch = matches[0] ?? null; const totalMatches = matches.length; - const fetching = result.fetching; - const error = result.error; - if (fetching) { + if (result.fetching) { return (

Loading module...

@@ -57,10 +54,10 @@ export default function ModuleDetailPage({ ); } - if (error) { + if (result.error) { return (
-

Error: {error.message}

+

Error: {result.error.message}

); } diff --git a/packages/studio/src/components/sidebar.tsx b/packages/studio/src/components/sidebar.tsx index 4d24992..d203203 100644 --- a/packages/studio/src/components/sidebar.tsx +++ b/packages/studio/src/components/sidebar.tsx @@ -14,6 +14,7 @@ import { cn } from '@/lib/utils'; import { buildSidebarTree, filterSidebarTree, + getModuleHref, type SidebarModuleNode, } from '@/lib/sidebar-nav'; import { BookOpen, ChevronRight, FileText, Search } from 'lucide-react'; @@ -51,8 +52,13 @@ export function Sidebar() { const isSuperblockActive = (dashedName: string) => pathname === `/superblocks/${dashedName}`; - const isModuleActive = (dashedName: string) => - pathname === `/modules/${dashedName}`; + const isModuleActive = (module: SidebarModuleNode) => + pathname === + getModuleHref({ + superblockDashedName: module.superblockDashedName, + chapterDashedName: module.chapterDashedName, + moduleDashedName: module.dashedName, + }); const renderSuperblockLink = ( superblock: SidebarSuperblockListItem, @@ -79,10 +85,14 @@ export function Sidebar() { const renderModuleItem = (module: SidebarModuleNode, className?: string) => ( @@ -136,7 +146,7 @@ export function Sidebar() { ? isSuperblockActive(certification.dashedName) || certification.visibleChapters.some((chapter) => chapter.visibleModules.some((module) => - isModuleActive(module.dashedName) + isModuleActive(module) ) ) : certification.visibleSuperblocks.some((superblock) => @@ -188,7 +198,7 @@ export function Sidebar() {
{certification.visibleChapters.map((chapter) => { const hasActiveModule = chapter.visibleModules.some( - (module) => isModuleActive(module.dashedName) + (module) => isModuleActive(module) ); const chapterExpanded = normalizedSearch.length > 0 diff --git a/packages/studio/src/graphql/generated/fragment-masking.ts b/packages/studio/src/graphql/generated/fragment-masking.ts new file mode 100644 index 0000000..f2e0577 --- /dev/null +++ b/packages/studio/src/graphql/generated/fragment-masking.ts @@ -0,0 +1,111 @@ +/* eslint-disable */ +import type { + ResultOf, + DocumentTypeDecoration, + TypedDocumentNode, +} from '@graphql-typed-document-node/core'; +import type { FragmentDefinitionNode } from 'graphql'; +import type { Incremental } from './graphql'; + +export type FragmentType< + TDocumentType extends DocumentTypeDecoration, +> = + TDocumentType extends DocumentTypeDecoration + ? [TType] extends [{ ' $fragmentName'?: infer TKey }] + ? TKey extends string + ? { ' $fragmentRefs'?: { [key in TKey]: TType } } + : never + : never + : never; + +// return non-nullable if `fragmentType` is non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> +): TType; +// return nullable if `fragmentType` is undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | undefined +): TType | undefined; +// return nullable if `fragmentType` is nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null +): TType | null; +// return nullable if `fragmentType` is nullable or undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | FragmentType> + | null + | undefined +): TType | null | undefined; +// return array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> +): Array; +// return array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | Array>> + | null + | undefined +): Array | null | undefined; +// return readonly array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> +): ReadonlyArray; +// return readonly array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | ReadonlyArray>> + | null + | undefined +): ReadonlyArray | null | undefined; +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | FragmentType> + | Array>> + | ReadonlyArray>> + | null + | undefined +): TType | Array | ReadonlyArray | null | undefined { + return fragmentType as any; +} + +export function makeFragmentData< + F extends DocumentTypeDecoration, + FT extends ResultOf, +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType; +} +export function isFragmentReady( + queryNode: DocumentTypeDecoration, + fragmentNode: TypedDocumentNode, + data: + | FragmentType, any>> + | null + | undefined +): data is FragmentType { + const deferredFields = ( + queryNode as { + __meta__?: { deferredFields: Record }; + } + ).__meta__?.deferredFields; + + if (!deferredFields) return true; + + const fragDef = fragmentNode.definitions[0] as + | FragmentDefinitionNode + | undefined; + const fragName = fragDef?.name?.value; + + const fields = (fragName && deferredFields[fragName]) || []; + return fields.length > 0 && fields.every((field) => data && field in data); +} diff --git a/packages/studio/src/graphql/generated/gql.ts b/packages/studio/src/graphql/generated/gql.ts new file mode 100644 index 0000000..97cc201 --- /dev/null +++ b/packages/studio/src/graphql/generated/gql.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ +import * as types from './graphql'; +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel or swc plugin for production. + * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size + */ +type Documents = { + '\n query Superblocks {\n superblocks {\n name\n dashedName\n isCertification\n }\n }\n': typeof types.SuperblocksDocument; + '\n query SidebarNav {\n curriculum {\n superblocks\n certifications\n }\n superblocks {\n name\n dashedName\n isCertification\n chapters {\n dashedName\n modules {\n dashedName\n blocks\n }\n }\n }\n }\n': typeof types.SidebarNavDocument; + '\n query CurriculumOverview {\n curriculum {\n superblocks\n certifications\n }\n superblocks {\n name\n dashedName\n isCertification\n blocks\n }\n certifications {\n dashedName\n }\n }\n': typeof types.CurriculumOverviewDocument; + '\n query SuperblockDetail($dashedName: String!) {\n superblock(dashedName: $dashedName) {\n name\n dashedName\n isCertification\n blocks\n blockObjects {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n }\n }\n }\n': typeof types.SuperblockDetailDocument; + '\n query ModuleDetail(\n $superblockDashedName: String\n $chapterDashedName: String\n ) {\n modules(\n superblockDashedName: $superblockDashedName\n chapterDashedName: $chapterDashedName\n ) {\n dashedName\n moduleType\n comingSoon\n blocks\n blockObjects {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n }\n chapter {\n dashedName\n superblock {\n name\n dashedName\n isCertification\n }\n }\n }\n }\n': typeof types.ModuleDetailDocument; + '\n query BlockDetail($dashedName: String!) {\n block(dashedName: $dashedName) {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n usesMultifileEditor\n hasEditableBoundaries\n challengeOrder {\n id\n title\n }\n superblocks {\n name\n dashedName\n }\n }\n }\n': typeof types.BlockDetailDocument; + '\n query ChallengeDetail($id: ID!) {\n challenge(id: $id) {\n id\n title\n block {\n name\n dashedName\n }\n content {\n description\n instructions\n files {\n name\n ext\n }\n tests {\n text\n }\n solutions {\n files {\n name\n ext\n }\n }\n }\n }\n }\n': typeof types.ChallengeDetailDocument; +}; +const documents: Documents = { + '\n query Superblocks {\n superblocks {\n name\n dashedName\n isCertification\n }\n }\n': + types.SuperblocksDocument, + '\n query SidebarNav {\n curriculum {\n superblocks\n certifications\n }\n superblocks {\n name\n dashedName\n isCertification\n chapters {\n dashedName\n modules {\n dashedName\n blocks\n }\n }\n }\n }\n': + types.SidebarNavDocument, + '\n query CurriculumOverview {\n curriculum {\n superblocks\n certifications\n }\n superblocks {\n name\n dashedName\n isCertification\n blocks\n }\n certifications {\n dashedName\n }\n }\n': + types.CurriculumOverviewDocument, + '\n query SuperblockDetail($dashedName: String!) {\n superblock(dashedName: $dashedName) {\n name\n dashedName\n isCertification\n blocks\n blockObjects {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n }\n }\n }\n': + types.SuperblockDetailDocument, + '\n query ModuleDetail(\n $superblockDashedName: String\n $chapterDashedName: String\n ) {\n modules(\n superblockDashedName: $superblockDashedName\n chapterDashedName: $chapterDashedName\n ) {\n dashedName\n moduleType\n comingSoon\n blocks\n blockObjects {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n }\n chapter {\n dashedName\n superblock {\n name\n dashedName\n isCertification\n }\n }\n }\n }\n': + types.ModuleDetailDocument, + '\n query BlockDetail($dashedName: String!) {\n block(dashedName: $dashedName) {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n usesMultifileEditor\n hasEditableBoundaries\n challengeOrder {\n id\n title\n }\n superblocks {\n name\n dashedName\n }\n }\n }\n': + types.BlockDetailDocument, + '\n query ChallengeDetail($id: ID!) {\n challenge(id: $id) {\n id\n title\n block {\n name\n dashedName\n }\n content {\n description\n instructions\n files {\n name\n ext\n }\n tests {\n text\n }\n solutions {\n files {\n name\n ext\n }\n }\n }\n }\n }\n': + types.ChallengeDetailDocument, +}; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + * + * + * @example + * ```ts + * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`); + * ``` + * + * The query argument is unknown! + * Please regenerate the types. + */ +export function graphql(source: string): unknown; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query Superblocks {\n superblocks {\n name\n dashedName\n isCertification\n }\n }\n' +): (typeof documents)['\n query Superblocks {\n superblocks {\n name\n dashedName\n isCertification\n }\n }\n']; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query SidebarNav {\n curriculum {\n superblocks\n certifications\n }\n superblocks {\n name\n dashedName\n isCertification\n chapters {\n dashedName\n modules {\n dashedName\n blocks\n }\n }\n }\n }\n' +): (typeof documents)['\n query SidebarNav {\n curriculum {\n superblocks\n certifications\n }\n superblocks {\n name\n dashedName\n isCertification\n chapters {\n dashedName\n modules {\n dashedName\n blocks\n }\n }\n }\n }\n']; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query CurriculumOverview {\n curriculum {\n superblocks\n certifications\n }\n superblocks {\n name\n dashedName\n isCertification\n blocks\n }\n certifications {\n dashedName\n }\n }\n' +): (typeof documents)['\n query CurriculumOverview {\n curriculum {\n superblocks\n certifications\n }\n superblocks {\n name\n dashedName\n isCertification\n blocks\n }\n certifications {\n dashedName\n }\n }\n']; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query SuperblockDetail($dashedName: String!) {\n superblock(dashedName: $dashedName) {\n name\n dashedName\n isCertification\n blocks\n blockObjects {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n }\n }\n }\n' +): (typeof documents)['\n query SuperblockDetail($dashedName: String!) {\n superblock(dashedName: $dashedName) {\n name\n dashedName\n isCertification\n blocks\n blockObjects {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n }\n }\n }\n']; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query ModuleDetail(\n $superblockDashedName: String\n $chapterDashedName: String\n ) {\n modules(\n superblockDashedName: $superblockDashedName\n chapterDashedName: $chapterDashedName\n ) {\n dashedName\n moduleType\n comingSoon\n blocks\n blockObjects {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n }\n chapter {\n dashedName\n superblock {\n name\n dashedName\n isCertification\n }\n }\n }\n }\n' +): (typeof documents)['\n query ModuleDetail(\n $superblockDashedName: String\n $chapterDashedName: String\n ) {\n modules(\n superblockDashedName: $superblockDashedName\n chapterDashedName: $chapterDashedName\n ) {\n dashedName\n moduleType\n comingSoon\n blocks\n blockObjects {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n }\n chapter {\n dashedName\n superblock {\n name\n dashedName\n isCertification\n }\n }\n }\n }\n']; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query BlockDetail($dashedName: String!) {\n block(dashedName: $dashedName) {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n usesMultifileEditor\n hasEditableBoundaries\n challengeOrder {\n id\n title\n }\n superblocks {\n name\n dashedName\n }\n }\n }\n' +): (typeof documents)['\n query BlockDetail($dashedName: String!) {\n block(dashedName: $dashedName) {\n name\n dashedName\n helpCategory\n blockLayout\n blockLabel\n isUpcomingChange\n usesMultifileEditor\n hasEditableBoundaries\n challengeOrder {\n id\n title\n }\n superblocks {\n name\n dashedName\n }\n }\n }\n']; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query ChallengeDetail($id: ID!) {\n challenge(id: $id) {\n id\n title\n block {\n name\n dashedName\n }\n content {\n description\n instructions\n files {\n name\n ext\n }\n tests {\n text\n }\n solutions {\n files {\n name\n ext\n }\n }\n }\n }\n }\n' +): (typeof documents)['\n query ChallengeDetail($id: ID!) {\n challenge(id: $id) {\n id\n title\n block {\n name\n dashedName\n }\n content {\n description\n instructions\n files {\n name\n ext\n }\n tests {\n text\n }\n solutions {\n files {\n name\n ext\n }\n }\n }\n }\n }\n']; + +export function graphql(source: string) { + return (documents as any)[source] ?? {}; +} + +export type DocumentType> = + TDocumentNode extends DocumentNode ? TType : never; diff --git a/packages/studio/src/graphql/generated/graphql.ts b/packages/studio/src/graphql/generated/graphql.ts new file mode 100644 index 0000000..9d4ae24 --- /dev/null +++ b/packages/studio/src/graphql/generated/graphql.ts @@ -0,0 +1,1282 @@ +/* eslint-disable */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { + [K in keyof T]: T[K]; +}; +export type MakeOptional = Omit & { + [SubKey in K]?: Maybe; +}; +export type MakeMaybe = Omit & { + [SubKey in K]: Maybe; +}; +export type MakeEmpty< + T extends { [key: string]: unknown }, + K extends keyof T, +> = { [_ in K]?: never }; +export type Incremental = + | T + | { + [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never; + }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string }; + String: { input: string; output: string }; + Boolean: { input: boolean; output: boolean }; + Int: { input: number; output: number }; + Float: { input: number; output: number }; +}; + +/** + * Learning module within a superblock + * Contains challenges, layout information, and pedagogical metadata + */ +export type Block = { + /** + * Pedagogical classification (optional) + * Field name changed from blockType to blockLabel to match actual JSON data + */ + blockLabel?: Maybe; + /** UI layout type for this block */ + blockLayout: BlockLayout; + /** Ordered list of challenges in this block */ + challengeOrder: Array; + /** Unique identifier (e.g., 'basic-html') */ + dashedName: Scalars['String']['output']; + /** + * Disable infinite loop protection in preview (optional) + * Used for challenges that need continuous execution + */ + disableLoopProtectPreview?: Maybe; + /** + * Disable infinite loop protection in tests (optional) + * Used for performance-intensive challenges like algorithms + */ + disableLoopProtectTests?: Maybe; + /** Flag indicating editable region boundaries feature */ + hasEditableBoundaries?: Maybe; + /** Category for help/support (e.g., 'HTML-CSS') */ + helpCategory: Scalars['String']['output']; + /** Flag indicating work-in-progress module */ + isUpcomingChange: Scalars['Boolean']['output']; + /** Human-readable name (e.g., 'Basic HTML') */ + name: Scalars['String']['output']; + /** + * External resources required for challenges (optional) + * CDN scripts for libraries like React, jQuery, D3, Bootstrap + */ + required?: Maybe>; + /** + * Parent superblocks (reverse reference for bidirectional navigation) + * Note: In v9 curriculum, blocks can be shared across multiple superblocks + */ + superblocks: Array; + /** + * HTML template for challenge rendering (optional) + * Contains placeholders for dynamic content injection + */ + template?: Maybe; + /** Flag indicating multi-file editor feature */ + usesMultifileEditor?: Maybe; +}; + +/** + * Pedagogical classification for blocks + * Describes the learning approach or activity type + * Renamed from BlockType to BlockLabel to match actual JSON data + */ +export type BlockLabel = + /** Formal assessment block */ + | 'EXAM' + /** Hands-on practice block */ + | 'LAB' + /** General learning block */ + | 'LEARN' + /** Instructional content block */ + | 'LECTURE' + /** Skill reinforcement block */ + | 'PRACTICE' + /** Knowledge check block */ + | 'QUIZ' + /** Concept review block */ + | 'REVIEW' + /** Preparatory exercises block */ + | 'WARM_UP' + /** Project-based learning block */ + | 'WORKSHOP'; + +/** + * UI layout types for blocks + * Determines how challenges are displayed in the frontend + */ +export type BlockLayout = + /** Grid layout of challenges */ + | 'CHALLENGE_GRID' + /** Vertical list of challenges */ + | 'CHALLENGE_LIST' + /** Interactive dialogue-based layout */ + | 'DIALOGUE_GRID' + /** Legacy grid layout */ + | 'LEGACY_CHALLENGE_GRID' + /** Legacy vertical list layout */ + | 'LEGACY_CHALLENGE_LIST' + /** Legacy link-based layout */ + | 'LEGACY_LINK' + /** Link-based navigation layout */ + | 'LINK' + /** Project-focused list layout */ + | 'PROJECT_LIST'; + +/** + * Certification wrapper around superblock + * Distinguishes certification-eligible curricula + */ +export type Certification = { + /** Certification identifier (same as superblock dashedName) */ + dashedName: Scalars['String']['output']; + /** Reference to underlying superblock */ + superblock: Superblock; +}; + +/** + * Individual coding challenge + * Metadata always available, content lazy-loaded in future v2 + */ +export type Challenge = { + /** Parent block (reverse reference for bidirectional navigation) */ + block: Block; + /** + * Full challenge content - MVP returns null + * Future v2: Lazy-loaded from database with LRU cache + * Enables v2 migration without breaking changes + */ + content?: Maybe; + /** Unique UUID identifier */ + id: Scalars['ID']['output']; + /** Challenge title */ + title: Scalars['String']['output']; +}; + +/** + * Full challenge content (future v2, returns null in MVP) + * Includes description, instructions, starter code, tests, and solutions + */ +export type ChallengeContent = { + /** Challenge overview/description */ + description: Scalars['String']['output']; + /** Starter code files */ + files: Array; + /** Step-by-step instructions */ + instructions: Scalars['String']['output']; + /** Example solutions */ + solutions: Array; + /** Validation tests */ + tests: Array; +}; + +/** + * Code file within a challenge or solution + * Contains file metadata and content + */ +export type ChallengeFile = { + /** File content as string */ + contents: Scalars['String']['output']; + /** Line numbers defining editable regions (optional) */ + editableRegionBoundaries?: Maybe>; + /** File extension (e.g., 'html') */ + ext: Scalars['String']['output']; + /** File name (e.g., 'index.html') */ + name: Scalars['String']['output']; +}; + +/** + * Chapter within a superblock (new v9 curriculum) + * Groups related modules together + */ +export type Chapter = { + /** Flag indicating if chapter is coming soon (not yet available) */ + comingSoon: Scalars['Boolean']['output']; + /** Unique identifier for the chapter (e.g., 'html', 'javascript') */ + dashedName: Scalars['String']['output']; + /** Modules within this chapter */ + modules: Array; + /** Parent superblock (reverse reference for bidirectional navigation) */ + superblock: Superblock; +}; + +/** + * Top-level curriculum structure + * Contains lists of superblocks and certifications + */ +export type Curriculum = { + /** Array of certification identifiers (subset of superblocks) */ + certifications: Array; + /** Array of superblock identifiers (dashedNames) */ + superblocks: Array; +}; + +/** Curriculum data store metrics and memory usage */ +export type DataStoreMetrics = { + /** Number of loaded unique blocks (deduplicated) */ + blockCount: Scalars['Int']['output']; + /** Number of loaded challenge metadata entries */ + challengeCount: Scalars['Int']['output']; + /** Number of loaded chapters (v9 curriculum primitive) */ + chapterCount: Scalars['Int']['output']; + /** Current heap memory usage in megabytes */ + memoryUsageMB: Scalars['Float']['output']; + /** Number of loaded modules (v9 curriculum primitive) */ + moduleCount: Scalars['Int']['output']; + /** Number of loaded superblocks */ + superblockCount: Scalars['Int']['output']; +}; + +/** Server health and operational metrics */ +export type HealthCheck = { + /** Curriculum data store statistics */ + dataStore: DataStoreMetrics; + /** Current server health status (always 'healthy' in MVP) */ + status: Scalars['String']['output']; + /** Uptime in seconds since server became operational */ + uptime: Scalars['Int']['output']; +}; + +/** + * Module within a chapter (new v9 curriculum) + * Contains a set of related blocks + */ +export type Module = { + /** Resolved Block objects (convenience field) */ + blockObjects: Array; + /** Array of block identifiers in this module */ + blocks: Array; + /** Parent chapter (reverse reference for bidirectional navigation) */ + chapter: Chapter; + /** Flag indicating if module is coming soon (not yet available) */ + comingSoon: Scalars['Boolean']['output']; + /** Unique identifier for the module (e.g., 'basic-html', 'semantic-html') */ + dashedName: Scalars['String']['output']; + /** Type of module (e.g., 'review', 'practice') - optional */ + moduleType?: Maybe; +}; + +/** + * freeCodeCamp Curriculum GraphQL API Schema + * Sprint 004 - Schema Definition and Code Generation + * + * This schema defines the complete API contract for curriculum metadata queries. + * All types map to internal TypeScript types via @graphql-codegen type mappers. + * + * Metadata/Content Separation: + * - Challenge metadata (id, title) always available + * - Challenge content (description, instructions, tests) returns null in MVP + * - ChallengeContent types included for future v2 database integration + * + * Type Mappers (configured in codegen.ts): + * - Curriculum → CurriculumData + * - Superblock → SuperblockData + * - Block → BlockData + * - Challenge → ChallengeMetadata (NOT full ChallengeData) + * - BlockLayout → BlockLayout enum + * - BlockType → BlockType enum + */ +export type Query = { + /** + * Server health check query + * Returns current operational status and data store metrics + * Useful for monitoring dashboards and load balancers + */ + _health: HealthCheck; + /** Get single block by identifier */ + block?: Maybe; + /** Get all blocks, optionally filtered by superblock */ + blocks: Array; + /** Get all certification-eligible superblocks */ + certifications: Array; + /** Get single challenge by ID */ + challenge?: Maybe; + /** Get all challenges, optionally filtered by block */ + challenges: Array; + /** + * Get all chapters, optionally filtered by superblock (v9 curriculum) + * Returns empty array for legacy flat curriculum superblocks + */ + chapters: Array; + /** Get complete curriculum structure */ + curriculum: Curriculum; + /** + * Get all modules, optionally filtered by chapter or superblock (v9 curriculum) + * Returns empty array for legacy flat curriculum superblocks + */ + modules: Array; + /** Get single superblock by identifier */ + superblock?: Maybe; + /** Get all superblocks */ + superblocks: Array; +}; + +/** + * freeCodeCamp Curriculum GraphQL API Schema + * Sprint 004 - Schema Definition and Code Generation + * + * This schema defines the complete API contract for curriculum metadata queries. + * All types map to internal TypeScript types via @graphql-codegen type mappers. + * + * Metadata/Content Separation: + * - Challenge metadata (id, title) always available + * - Challenge content (description, instructions, tests) returns null in MVP + * - ChallengeContent types included for future v2 database integration + * + * Type Mappers (configured in codegen.ts): + * - Curriculum → CurriculumData + * - Superblock → SuperblockData + * - Block → BlockData + * - Challenge → ChallengeMetadata (NOT full ChallengeData) + * - BlockLayout → BlockLayout enum + * - BlockType → BlockType enum + */ +export type QueryBlockArgs = { + dashedName: Scalars['String']['input']; +}; + +/** + * freeCodeCamp Curriculum GraphQL API Schema + * Sprint 004 - Schema Definition and Code Generation + * + * This schema defines the complete API contract for curriculum metadata queries. + * All types map to internal TypeScript types via @graphql-codegen type mappers. + * + * Metadata/Content Separation: + * - Challenge metadata (id, title) always available + * - Challenge content (description, instructions, tests) returns null in MVP + * - ChallengeContent types included for future v2 database integration + * + * Type Mappers (configured in codegen.ts): + * - Curriculum → CurriculumData + * - Superblock → SuperblockData + * - Block → BlockData + * - Challenge → ChallengeMetadata (NOT full ChallengeData) + * - BlockLayout → BlockLayout enum + * - BlockType → BlockType enum + */ +export type QueryBlocksArgs = { + superblockDashedName?: InputMaybe; +}; + +/** + * freeCodeCamp Curriculum GraphQL API Schema + * Sprint 004 - Schema Definition and Code Generation + * + * This schema defines the complete API contract for curriculum metadata queries. + * All types map to internal TypeScript types via @graphql-codegen type mappers. + * + * Metadata/Content Separation: + * - Challenge metadata (id, title) always available + * - Challenge content (description, instructions, tests) returns null in MVP + * - ChallengeContent types included for future v2 database integration + * + * Type Mappers (configured in codegen.ts): + * - Curriculum → CurriculumData + * - Superblock → SuperblockData + * - Block → BlockData + * - Challenge → ChallengeMetadata (NOT full ChallengeData) + * - BlockLayout → BlockLayout enum + * - BlockType → BlockType enum + */ +export type QueryChallengeArgs = { + id: Scalars['ID']['input']; +}; + +/** + * freeCodeCamp Curriculum GraphQL API Schema + * Sprint 004 - Schema Definition and Code Generation + * + * This schema defines the complete API contract for curriculum metadata queries. + * All types map to internal TypeScript types via @graphql-codegen type mappers. + * + * Metadata/Content Separation: + * - Challenge metadata (id, title) always available + * - Challenge content (description, instructions, tests) returns null in MVP + * - ChallengeContent types included for future v2 database integration + * + * Type Mappers (configured in codegen.ts): + * - Curriculum → CurriculumData + * - Superblock → SuperblockData + * - Block → BlockData + * - Challenge → ChallengeMetadata (NOT full ChallengeData) + * - BlockLayout → BlockLayout enum + * - BlockType → BlockType enum + */ +export type QueryChallengesArgs = { + blockDashedName?: InputMaybe; +}; + +/** + * freeCodeCamp Curriculum GraphQL API Schema + * Sprint 004 - Schema Definition and Code Generation + * + * This schema defines the complete API contract for curriculum metadata queries. + * All types map to internal TypeScript types via @graphql-codegen type mappers. + * + * Metadata/Content Separation: + * - Challenge metadata (id, title) always available + * - Challenge content (description, instructions, tests) returns null in MVP + * - ChallengeContent types included for future v2 database integration + * + * Type Mappers (configured in codegen.ts): + * - Curriculum → CurriculumData + * - Superblock → SuperblockData + * - Block → BlockData + * - Challenge → ChallengeMetadata (NOT full ChallengeData) + * - BlockLayout → BlockLayout enum + * - BlockType → BlockType enum + */ +export type QueryChaptersArgs = { + superblockDashedName?: InputMaybe; +}; + +/** + * freeCodeCamp Curriculum GraphQL API Schema + * Sprint 004 - Schema Definition and Code Generation + * + * This schema defines the complete API contract for curriculum metadata queries. + * All types map to internal TypeScript types via @graphql-codegen type mappers. + * + * Metadata/Content Separation: + * - Challenge metadata (id, title) always available + * - Challenge content (description, instructions, tests) returns null in MVP + * - ChallengeContent types included for future v2 database integration + * + * Type Mappers (configured in codegen.ts): + * - Curriculum → CurriculumData + * - Superblock → SuperblockData + * - Block → BlockData + * - Challenge → ChallengeMetadata (NOT full ChallengeData) + * - BlockLayout → BlockLayout enum + * - BlockType → BlockType enum + */ +export type QueryModulesArgs = { + chapterDashedName?: InputMaybe; + superblockDashedName?: InputMaybe; +}; + +/** + * freeCodeCamp Curriculum GraphQL API Schema + * Sprint 004 - Schema Definition and Code Generation + * + * This schema defines the complete API contract for curriculum metadata queries. + * All types map to internal TypeScript types via @graphql-codegen type mappers. + * + * Metadata/Content Separation: + * - Challenge metadata (id, title) always available + * - Challenge content (description, instructions, tests) returns null in MVP + * - ChallengeContent types included for future v2 database integration + * + * Type Mappers (configured in codegen.ts): + * - Curriculum → CurriculumData + * - Superblock → SuperblockData + * - Block → BlockData + * - Challenge → ChallengeMetadata (NOT full ChallengeData) + * - BlockLayout → BlockLayout enum + * - BlockType → BlockType enum + */ +export type QuerySuperblockArgs = { + dashedName: Scalars['String']['input']; +}; + +/** + * External resource (CDN script or stylesheet) required for challenges + * Used in blocks that depend on external libraries + */ +export type RequiredResource = { + /** + * CDN URL for CSS stylesheet (optional) + * Note: Either src or link must be present + */ + link?: Maybe; + /** CDN URL for JavaScript library (optional) */ + src?: Maybe; +}; + +/** + * Example solution for a challenge + * Contains solution code files + */ +export type Solution = { + /** Solution code files */ + files: Array; +}; + +/** + * Major curriculum area (e.g., Responsive Web Design) + * Supports both legacy (flat) and new v9 (hierarchical) curriculum structures + */ +export type Superblock = { + /** Resolved Block objects - flattened view (convenience field) */ + blockObjects: Array; + /** Flattened array of all block identifiers (from all chapters/modules) */ + blocks: Array; + /** + * Hierarchical chapter structure (new v9 curriculum) + * Empty array for legacy flat curriculum + */ + chapters: Array; + /** Unique identifier (e.g., 'responsive-web-design') */ + dashedName: Scalars['String']['output']; + /** True if this superblock is certification-eligible */ + isCertification: Scalars['Boolean']['output']; + /** Human-readable name (e.g., 'Responsive Web Design') */ + name: Scalars['String']['output']; +}; + +/** + * Validation test for challenge submission + * Contains human-readable description and assertion code + */ +export type Test = { + /** Test assertion code */ + testString: Scalars['String']['output']; + /** Human-readable test description */ + text: Scalars['String']['output']; +}; + +export type SuperblocksQueryVariables = Exact<{ [key: string]: never }>; + +export type SuperblocksQuery = { + superblocks: Array<{ + name: string; + dashedName: string; + isCertification: boolean; + }>; +}; + +export type SidebarNavQueryVariables = Exact<{ [key: string]: never }>; + +export type SidebarNavQuery = { + curriculum: { superblocks: Array; certifications: Array }; + superblocks: Array<{ + name: string; + dashedName: string; + isCertification: boolean; + chapters: Array<{ + dashedName: string; + modules: Array<{ dashedName: string; blocks: Array }>; + }>; + }>; +}; + +export type CurriculumOverviewQueryVariables = Exact<{ [key: string]: never }>; + +export type CurriculumOverviewQuery = { + curriculum: { superblocks: Array; certifications: Array }; + superblocks: Array<{ + name: string; + dashedName: string; + isCertification: boolean; + blocks: Array; + }>; + certifications: Array<{ dashedName: string }>; +}; + +export type SuperblockDetailQueryVariables = Exact<{ + dashedName: Scalars['String']['input']; +}>; + +export type SuperblockDetailQuery = { + superblock?: { + name: string; + dashedName: string; + isCertification: boolean; + blocks: Array; + blockObjects: Array<{ + name: string; + dashedName: string; + helpCategory: string; + blockLayout: BlockLayout; + blockLabel?: BlockLabel | null; + isUpcomingChange: boolean; + }>; + } | null; +}; + +export type ModuleDetailQueryVariables = Exact<{ + superblockDashedName?: InputMaybe; + chapterDashedName?: InputMaybe; +}>; + +export type ModuleDetailQuery = { + modules: Array<{ + dashedName: string; + moduleType?: string | null; + comingSoon: boolean; + blocks: Array; + blockObjects: Array<{ + name: string; + dashedName: string; + helpCategory: string; + blockLayout: BlockLayout; + blockLabel?: BlockLabel | null; + isUpcomingChange: boolean; + }>; + chapter: { + dashedName: string; + superblock: { + name: string; + dashedName: string; + isCertification: boolean; + }; + }; + }>; +}; + +export type BlockDetailQueryVariables = Exact<{ + dashedName: Scalars['String']['input']; +}>; + +export type BlockDetailQuery = { + block?: { + name: string; + dashedName: string; + helpCategory: string; + blockLayout: BlockLayout; + blockLabel?: BlockLabel | null; + isUpcomingChange: boolean; + usesMultifileEditor?: boolean | null; + hasEditableBoundaries?: boolean | null; + challengeOrder: Array<{ id: string; title: string }>; + superblocks: Array<{ name: string; dashedName: string }>; + } | null; +}; + +export type ChallengeDetailQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + +export type ChallengeDetailQuery = { + challenge?: { + id: string; + title: string; + block: { name: string; dashedName: string }; + content?: { + description: string; + instructions: string; + files: Array<{ name: string; ext: string }>; + tests: Array<{ text: string }>; + solutions: Array<{ files: Array<{ name: string; ext: string }> }>; + } | null; + } | null; +}; + +export const SuperblocksDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Superblocks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'superblocks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'dashedName' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'isCertification' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const SidebarNavDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'SidebarNav' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'curriculum' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'superblocks' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'certifications' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'superblocks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'dashedName' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'isCertification' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'chapters' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'dashedName' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'modules' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'dashedName' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'blocks' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const CurriculumOverviewDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'CurriculumOverview' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'curriculum' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'superblocks' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'certifications' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'superblocks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'dashedName' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'isCertification' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'blocks' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'certifications' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'dashedName' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + CurriculumOverviewQuery, + CurriculumOverviewQueryVariables +>; +export const SuperblockDetailDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'SuperblockDetail' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'dashedName' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'superblock' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'dashedName' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'dashedName' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'dashedName' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'isCertification' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'blocks' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'blockObjects' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'dashedName' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'helpCategory' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'blockLayout' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'blockLabel' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'isUpcomingChange' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + SuperblockDetailQuery, + SuperblockDetailQueryVariables +>; +export const ModuleDetailDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'ModuleDetail' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'superblockDashedName' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'chapterDashedName' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'modules' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'superblockDashedName' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'superblockDashedName' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'chapterDashedName' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'chapterDashedName' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'dashedName' } }, + { kind: 'Field', name: { kind: 'Name', value: 'moduleType' } }, + { kind: 'Field', name: { kind: 'Name', value: 'comingSoon' } }, + { kind: 'Field', name: { kind: 'Name', value: 'blocks' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'blockObjects' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'dashedName' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'helpCategory' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'blockLayout' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'blockLabel' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'isUpcomingChange' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'chapter' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'dashedName' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'superblock' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'dashedName' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'isCertification' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const BlockDetailDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'BlockDetail' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'dashedName' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'block' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'dashedName' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'dashedName' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'dashedName' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'helpCategory' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'blockLayout' } }, + { kind: 'Field', name: { kind: 'Name', value: 'blockLabel' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'isUpcomingChange' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'usesMultifileEditor' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'hasEditableBoundaries' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'challengeOrder' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'superblocks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'dashedName' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const ChallengeDetailDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'ChallengeDetail' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'challenge' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'id' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'id' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'block' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'dashedName' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'content' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'description' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'instructions' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'files' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'ext' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'tests' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'text' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'solutions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'files' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'ext' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + ChallengeDetailQuery, + ChallengeDetailQueryVariables +>; diff --git a/packages/studio/src/graphql/generated/index.ts b/packages/studio/src/graphql/generated/index.ts new file mode 100644 index 0000000..c682b1e --- /dev/null +++ b/packages/studio/src/graphql/generated/index.ts @@ -0,0 +1,2 @@ +export * from './fragment-masking'; +export * from './gql'; diff --git a/packages/studio/src/graphql/queries.ts b/packages/studio/src/graphql/queries.ts index b54ff6c..509c6f9 100644 --- a/packages/studio/src/graphql/queries.ts +++ b/packages/studio/src/graphql/queries.ts @@ -135,6 +135,20 @@ export const CHALLENGE_DETAIL_QUERY = gql` } content { description + instructions + files { + name + ext + } + tests { + text + } + solutions { + files { + name + ext + } + } } } } diff --git a/packages/studio/src/graphql/types.ts b/packages/studio/src/graphql/types.ts index 21c548e..41c5409 100644 --- a/packages/studio/src/graphql/types.ts +++ b/packages/studio/src/graphql/types.ts @@ -1,12 +1,18 @@ -export type BlockLayout = - | 'LINK' - | 'CHALLENGE_LIST' - | 'CHALLENGE_GRID' - | 'DIALOGUE_GRID' - | 'PROJECT_LIST' - | 'LEGACY_CHALLENGE_LIST' - | 'LEGACY_CHALLENGE_GRID' - | 'LEGACY_LINK'; +import type { + BlockDetailQuery, + BlockLabel as GeneratedBlockLabel, + BlockLayout as GeneratedBlockLayout, + ChallengeDetailQuery, + CurriculumOverviewQuery, + ModuleDetailQuery, + SidebarNavQuery, + SuperblockDetailQuery, + SuperblocksQuery, +} from './generated/graphql'; + +type NonNull = NonNullable; + +export type BlockLayout = GeneratedBlockLayout; export const BLOCK_LAYOUTS: BlockLayout[] = [ 'LINK', @@ -19,16 +25,7 @@ export const BLOCK_LAYOUTS: BlockLayout[] = [ 'LEGACY_LINK', ]; -export type BlockLabel = - | 'LECTURE' - | 'LAB' - | 'WORKSHOP' - | 'REVIEW' - | 'QUIZ' - | 'EXAM' - | 'WARM_UP' - | 'PRACTICE' - | 'LEARN'; +export type BlockLabel = GeneratedBlockLabel; export const BLOCK_LABELS: BlockLabel[] = [ 'LECTURE', @@ -42,121 +39,27 @@ export const BLOCK_LABELS: BlockLabel[] = [ 'LEARN', ]; -export interface SuperblockListItem { - name: string; - dashedName: string; - isCertification: boolean; -} - -export interface SidebarChapterListItem { - dashedName: string; - modules: SidebarModuleListItem[]; -} - -export interface SidebarModuleListItem { - dashedName: string; - blocks: string[]; -} - -export interface SidebarSuperblockListItem extends SuperblockListItem { - chapters: SidebarChapterListItem[]; -} - -export interface SidebarNavResult { - curriculum: { - superblocks: string[]; - certifications: string[]; - }; - superblocks: SidebarSuperblockListItem[]; -} - -export interface SuperblockDetail { - name: string; - dashedName: string; - isCertification: boolean; - blocks: string[]; - blockObjects: BlockListItem[]; -} - -export interface ModuleDetail { - dashedName: string; - moduleType: string | null; - comingSoon: boolean; - blocks: string[]; - blockObjects: BlockListItem[]; - chapter: { - dashedName: string; - superblock: SuperblockListItem; - }; -} - -export interface BlockListItem { - name: string; - dashedName: string; - helpCategory: string; - blockLayout: BlockLayout; - blockLabel: BlockLabel | null; - isUpcomingChange: boolean; -} - -export interface BlockDetail { - name: string; - dashedName: string; - helpCategory: string; - blockLayout: BlockLayout; - blockLabel: BlockLabel | null; - isUpcomingChange: boolean; - usesMultifileEditor: boolean | null; - hasEditableBoundaries: boolean | null; - challengeOrder: ChallengeOrderEntry[]; - superblocks: SuperblockRef[]; -} - -export interface SuperblockRef { - name: string; - dashedName: string; -} - -export interface ChallengeOrderEntry { - id: string; - title: string; -} - -export interface ChallengeDetail { - id: string; - title: string; - block: { - name: string; - dashedName: string; - }; - content: { description: string } | null; -} - -export interface CurriculumOverviewResult { - curriculum: { - superblocks: string[]; - certifications: string[]; - }; - superblocks: Array; - certifications: Array<{ dashedName: string }>; -} - -export interface SuperblockListResult { - superblocks: SuperblockListItem[]; -} - -export interface SuperblockDetailResult { - superblock: SuperblockDetail | null; -} - -export interface ModuleDetailResult { - modules: ModuleDetail[]; -} - -export interface BlockDetailResult { - block: BlockDetail | null; -} - -export interface ChallengeDetailResult { - challenge: ChallengeDetail | null; -} +export type SuperblockListItem = SuperblocksQuery['superblocks'][number]; +export type SidebarSuperblockListItem = SidebarNavQuery['superblocks'][number]; +export type SidebarChapterListItem = + SidebarSuperblockListItem['chapters'][number]; +export type SidebarModuleListItem = SidebarChapterListItem['modules'][number]; +export type SidebarNavResult = SidebarNavQuery; + +export type SuperblockDetail = NonNull; +export type ModuleDetail = ModuleDetailQuery['modules'][number]; +export type BlockListItem = SuperblockDetail['blockObjects'][number]; + +export type BlockDetail = NonNull; +export type SuperblockRef = BlockDetail['superblocks'][number]; +export type ChallengeOrderEntry = BlockDetail['challengeOrder'][number]; + +export type ChallengeDetail = NonNull; +export type ChallengeContent = NonNull; + +export type CurriculumOverviewResult = CurriculumOverviewQuery; +export type SuperblockListResult = SuperblocksQuery; +export type SuperblockDetailResult = SuperblockDetailQuery; +export type ModuleDetailResult = ModuleDetailQuery; +export type BlockDetailResult = BlockDetailQuery; +export type ChallengeDetailResult = ChallengeDetailQuery; diff --git a/packages/studio/src/lib/sidebar-nav.ts b/packages/studio/src/lib/sidebar-nav.ts index d9534bd..ff389c5 100644 --- a/packages/studio/src/lib/sidebar-nav.ts +++ b/packages/studio/src/lib/sidebar-nav.ts @@ -14,6 +14,12 @@ export interface SidebarModuleNode { chapterDashedName: string; } +export interface ModuleRouteKey { + superblockDashedName: string; + chapterDashedName: string; + moduleDashedName: string; +} + export interface SidebarChapterNode { id: string; dashedName: string; @@ -71,6 +77,14 @@ function matchesSearch(value: string, normalizedSearch: string): boolean { return value.toLowerCase().includes(normalizedSearch); } +export function getModuleHref({ + superblockDashedName, + chapterDashedName, + moduleDashedName, +}: ModuleRouteKey): string { + return `/superblocks/${superblockDashedName}/chapters/${chapterDashedName}/modules/${moduleDashedName}`; +} + export function buildSidebarTree( data: SidebarNavResult | undefined, hiddenSuperblocks: ReadonlySet = new Set() diff --git a/packages/studio/src/types/graphql-typed-document-node-core.d.ts b/packages/studio/src/types/graphql-typed-document-node-core.d.ts new file mode 100644 index 0000000..b0e1250 --- /dev/null +++ b/packages/studio/src/types/graphql-typed-document-node-core.d.ts @@ -0,0 +1,18 @@ +declare module '@graphql-typed-document-node/core' { + import type { DocumentNode } from 'graphql'; + + export interface DocumentTypeDecoration { + readonly __apiType?: (variables: TVariables) => TResult; + } + + export interface TypedDocumentNode< + TResult = unknown, + TVariables = Record, + > + extends DocumentNode, DocumentTypeDecoration {} + + export type ResultOf = + T extends DocumentTypeDecoration> + ? TResult + : never; +}