Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 30 additions & 30 deletions packages/server/src/schema/types.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolversTypes['BlockLabel']>,
Expand Down Expand Up @@ -775,17 +775,17 @@ export type BlockLayoutResolvers = EnumResolverSignature<

export type CertificationResolvers<
ContextType = DataProvider,
ParentType extends
ResolversParentTypes['Certification'] = ResolversParentTypes['Certification'],
ParentType extends ResolversParentTypes['Certification'] =
ResolversParentTypes['Certification'],
> = {
dashedName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
superblock?: Resolver<ResolversTypes['Superblock'], ParentType, ContextType>;
};

export type ChallengeResolvers<
ContextType = DataProvider,
ParentType extends
ResolversParentTypes['Challenge'] = ResolversParentTypes['Challenge'],
ParentType extends ResolversParentTypes['Challenge'] =
ResolversParentTypes['Challenge'],
> = {
block?: Resolver<ResolversTypes['Block'], ParentType, ContextType>;
content?: Resolver<
Expand All @@ -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<ResolversTypes['String'], ParentType, ContextType>;
files?: Resolver<
Expand All @@ -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<ResolversTypes['String'], ParentType, ContextType>;
editableRegionBoundaries?: Resolver<
Expand All @@ -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<ResolversTypes['Boolean'], ParentType, ContextType>;
dashedName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
Expand All @@ -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<ResolversTypes['String']>,
Expand All @@ -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<ResolversTypes['Int'], ParentType, ContextType>;
challengeCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
Expand All @@ -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'],
Expand All @@ -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<ResolversTypes['Block']>,
Expand All @@ -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<ResolversTypes['HealthCheck'], ParentType, ContextType>;
block?: Resolver<
Expand Down Expand Up @@ -971,17 +971,17 @@ export type QueryResolvers<

export type RequiredResourceResolvers<
ContextType = DataProvider,
ParentType extends
ResolversParentTypes['RequiredResource'] = ResolversParentTypes['RequiredResource'],
ParentType extends ResolversParentTypes['RequiredResource'] =
ResolversParentTypes['RequiredResource'],
> = {
link?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
src?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
};

export type SolutionResolvers<
ContextType = DataProvider,
ParentType extends
ResolversParentTypes['Solution'] = ResolversParentTypes['Solution'],
ParentType extends ResolversParentTypes['Solution'] =
ResolversParentTypes['Solution'],
> = {
files?: Resolver<
Array<ResolversTypes['ChallengeFile']>,
Expand All @@ -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<ResolversTypes['Block']>,
Expand All @@ -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<ResolversTypes['String'], ParentType, ContextType>;
text?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
Expand Down
7 changes: 7 additions & 0 deletions packages/studio/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"printWidth": 80,
"trailingComma": "es5"
}
159 changes: 159 additions & 0 deletions packages/studio/README.md
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions packages/studio/codegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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,
skipTypename: true,
scalars: {
ID: 'string',
},
},
},
},
ignoreNoDocuments: true,
};

export default config;
6 changes: 6 additions & 0 deletions packages/studio/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
7 changes: 7 additions & 0 deletions packages/studio/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
output: 'standalone',
};

export default nextConfig;
Loading