From 2b87ca7fae5e15213248a1d701a0602de25d4fd6 Mon Sep 17 00:00:00 2001 From: andyrodrigues30 Date: Fri, 28 Aug 2026 15:02:48 +0100 Subject: [PATCH 1/5] feat: update contributionStorage for multi drafts and guides Signed-off-by: andyrodrigues30 --- app/src/lib/contributionStorage.ts | 616 +++++++++++++++++++++++++---- 1 file changed, 535 insertions(+), 81 deletions(-) diff --git a/app/src/lib/contributionStorage.ts b/app/src/lib/contributionStorage.ts index 5e23daa2..87a220d4 100644 --- a/app/src/lib/contributionStorage.ts +++ b/app/src/lib/contributionStorage.ts @@ -7,121 +7,465 @@ import { variantContributionSchema, } from "@/types/contributions"; -const STORAGE_KEYS: Record = { - guide: "bluelearn:contrib:guide", - variant: "bluelearn:contrib:variant", - objective: "bluelearn:contrib:objective", -}; +/** + * All locally stored contribution drafts live under one localStorage key. + */ +const STORAGE_KEY = "bluelearn:contrib:drafts"; +/** + * localDraftId: + * Identifies the draft inside this browser. + * + * revisionId: + * Identifies the corresponding database revision, if one exists. + */ export interface PersistedContributionDraft { + localDraftId: string; + type: ContributionType; data: T; revisionId: string | null; step?: string; updatedAt: number; } -// Checks a draft read back from local storage. -const persistedDraftSchema = (data: z.ZodType) => +/** + * Raw localStorage structure. + * + * Values are unknown until they are validated against the + * appropriate contribution schema. + */ +type StoredDrafts = Record; + +/** + * Creates the complete validation schema for a particular + * contribution type. + */ +const persistedDraftSchema = ( + type: ContributionType, + dataSchema: T +) => z.object({ - data, + localDraftId: z.string().min(1), + type: z.literal(type), + data: dataSchema, revisionId: z .string() .nullish() - .transform((v) => v ?? null), + .transform((value) => value ?? null), step: z.string().optional(), updatedAt: z.number(), }); -const DRAFT_SCHEMAS: Record = { - guide: persistedDraftSchema(guideContributionSchema), - variant: persistedDraftSchema(variantContributionSchema), - objective: persistedDraftSchema(objectiveContributionSchema), +/** + * Schema used to determine which contribution schema should + * validate an individual draft. + */ +const draftEnvelopeSchema = z.object({ + localDraftId: z.string().min(1), + type: z.enum(["guide", "variant", "objective"]), +}); + +/** + * Schemas for each contribution type. + * + * The literal `type` on each schema creates the discriminated + * relationship between `type` and `data`. + */ +const DRAFT_SCHEMAS = { + guide: persistedDraftSchema("guide", guideContributionSchema), + variant: persistedDraftSchema("variant", variantContributionSchema), + objective: persistedDraftSchema("objective", objectiveContributionSchema), }; /** - * Safely reads a contribution draft from localStorage. - * Handles SSR (returns null when window is not defined) and JSON parse errors. - * A draft that doesn't match the expected shape is dropped and cleared, since - * loading one into form state crashes the first render that touches it. + * Types generated directly from the Zod schemas. */ -export function getStoredDraft( - type: ContributionType -): PersistedContributionDraft | null { - if (typeof window === "undefined") return null; +type StoredGuideDraft = z.infer; + +type StoredVariantDraft = z.infer; + +type StoredObjectiveDraft = z.infer; + +type AnyStoredDraft = + | StoredGuideDraft + | StoredVariantDraft + | StoredObjectiveDraft; + +/** + * Safely reads the complete raw draft store from localStorage. + * + * Individual drafts are validated later - each contribution + * type has a different data schema. + */ +function readStoredDrafts(): StoredDrafts { + if (typeof window === "undefined") { + return {}; + } - let parsed: unknown; try { - const raw = window.localStorage.getItem(STORAGE_KEYS[type]); - if (!raw) return null; - parsed = JSON.parse(raw); + const raw = window.localStorage.getItem(STORAGE_KEY); + + if (!raw) { + return {}; + } + + const parsed: unknown = JSON.parse(raw); + + const result = z.record(z.string(), z.unknown()).safeParse(parsed); + + if (!result.success) { + console.warn( + "Discarding malformed contribution draft store:", + result.error + ); + + clearAllStoredDrafts(); + return {}; + } + + return result.data; } catch (error) { - console.warn(`Failed to read stored draft for ${type}:`, error); - clearStoredDraft(type); + console.warn("Failed to read contribution drafts:", error); + + clearAllStoredDrafts(); + return {}; + } +} + +/** + * Writes the complete draft store to localStorage. + */ +function writeStoredDrafts(drafts: StoredDrafts): void { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts)); + } catch (error) { + console.warn("Failed to save contribution drafts:", error); + } +} + +/** + * Generates a unique ID for a new local draft. + */ +export function createLocalDraftId(): string { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + + throw new Error("crypto.randomUUID() is not available in this environment."); +} + +/** + * Validates one raw localStorage entry. + * + * The switch preserves the relationship between + * the contribution type and its corresponding data schema. + */ +function parseStoredDraft(value: unknown): AnyStoredDraft | null { + const envelopeResult = draftEnvelopeSchema.safeParse(value); + + if (!envelopeResult.success) { + return null; + } + + switch (envelopeResult.data.type) { + case "guide": { + const result = DRAFT_SCHEMAS.guide.safeParse(value); + + return result.success ? result.data : null; + } + + case "variant": { + const result = DRAFT_SCHEMAS.variant.safeParse(value); + + return result.success ? result.data : null; + } + + case "objective": { + const result = DRAFT_SCHEMAS.objective.safeParse(value); + + return result.success ? result.data : null; + } + } +} + +/** + * Gets a single stored guide draft. + */ +export function getStoredDraft( + localDraftId: string, + type: "guide" +): StoredGuideDraft | null; + +/** + * Gets a single stored variant draft. + */ +export function getStoredDraft( + localDraftId: string, + type: "variant" +): StoredVariantDraft | null; + +/** + * Gets a single stored objective draft. + */ +export function getStoredDraft( + localDraftId: string, + type: "objective" +): StoredObjectiveDraft | null; + +/** + * Implementation for getStoredDraft. + */ +export function getStoredDraft( + localDraftId: string, + type: ContributionType +): AnyStoredDraft | null { + if (typeof window === "undefined") { return null; } - const result = DRAFT_SCHEMAS[type].safeParse(parsed); - if (!result.success) { + const drafts = readStoredDrafts(); + const rawDraft = drafts[localDraftId]; + + if (rawDraft === undefined) { + return null; + } + + const parsedDraft = parseStoredDraft(rawDraft); + + if (!parsedDraft) { + console.warn(`Discarding malformed stored draft ${localDraftId}.`); + + delete drafts[localDraftId]; + writeStoredDrafts(drafts); + + return null; + } + + if (parsedDraft.type !== type) { console.warn( - `Discarding malformed stored draft for ${type}:`, - result.error + `Stored draft ${localDraftId} has type "${parsedDraft.type}" ` + + `but "${type}" was requested.` ); - clearStoredDraft(type); + return null; } - return result.data as PersistedContributionDraft; + return parsedDraft; } /** - * Safely writes a contribution draft to localStorage. + * Gets every valid stored draft. + * + * Malformed drafts are removed from localStorage. */ -export function setStoredDraft( - type: ContributionType, - draft: PersistedContributionDraft -): void { - if (typeof window === "undefined") return; +export function getAllStoredDrafts(): Array { + if (typeof window === "undefined") { + return []; + } - try { - window.localStorage.setItem(STORAGE_KEYS[type], JSON.stringify(draft)); - } catch (error) { - console.warn(`Failed to save draft to localStorage for ${type}:`, error); + const drafts = readStoredDrafts(); + + const validDrafts: Array = []; + + let changed = false; + + for (const [localDraftId, rawDraft] of Object.entries(drafts)) { + const parsedDraft = parseStoredDraft(rawDraft); + + if (!parsedDraft) { + console.warn(`Discarding malformed stored draft ${localDraftId}.`); + + delete drafts[localDraftId]; + changed = true; + continue; + } + + /** + * The localStorage key and localDraftId should agree. + */ + if (parsedDraft.localDraftId !== localDraftId) { + console.warn( + `Discarding stored draft with mismatched localDraftId: ${localDraftId}.` + ); + + delete drafts[localDraftId]; + changed = true; + continue; + } + + validDrafts.push(parsedDraft); } + + if (changed) { + writeStoredDrafts(drafts); + } + + return validDrafts.sort((a, b) => b.updatedAt - a.updatedAt); } /** - * Clears the stored draft for a given contribution type from localStorage. + * Gets all stored guide drafts. */ -export function clearStoredDraft(type: ContributionType): void { - if (typeof window === "undefined") return; +export function getStoredDraftsByType(type: "guide"): Array; - try { - window.localStorage.removeItem(STORAGE_KEYS[type]); - } catch (error) { - console.warn(`Failed to clear stored draft for ${type}:`, error); +/** + * Gets all stored variant drafts. + */ +export function getStoredDraftsByType( + type: "variant" +): Array; + +/** + * Gets all stored objective drafts. + */ +export function getStoredDraftsByType( + type: "objective" +): Array; + +/** + * Implementation for getStoredDraftsByType. + */ +export function getStoredDraftsByType( + type: ContributionType +): Array { + switch (type) { + case "guide": + return getAllStoredDrafts().filter( + (draft): draft is StoredGuideDraft => draft.type === "guide" + ); + + case "variant": + return getAllStoredDrafts().filter( + (draft): draft is StoredVariantDraft => draft.type === "variant" + ); + + case "objective": + return getAllStoredDrafts().filter( + (draft): draft is StoredObjectiveDraft => draft.type === "objective" + ); } } /** - * Checks if a stored draft exists for the given contribution type. + * Saves or updates one stored guide draft. */ -export function hasStoredDraft(type: ContributionType): boolean { - if (typeof window === "undefined") return false; +export function setStoredDraft(draft: StoredGuideDraft): void; - try { - return window.localStorage.getItem(STORAGE_KEYS[type]) !== null; - } catch { +/** + * Saves or updates one stored variant draft. + */ +export function setStoredDraft(draft: StoredVariantDraft): void; + +/** + * Saves or updates one stored objective draft. + */ +export function setStoredDraft(draft: StoredObjectiveDraft): void; + +export function setStoredDraft(draft: AnyStoredDraft): void { + if (typeof window === "undefined") { + return; + } + + const parsedDraft = parseStoredDraft(draft); + + if (!parsedDraft) { + console.warn(`Refusing to store malformed ${draft.type} draft.`); + + return; + } + + const drafts = readStoredDrafts(); + + drafts[draft.localDraftId] = parsedDraft; + + writeStoredDrafts(drafts); +} + +/** + * Deletes one local draft. + */ +export function clearStoredDraft(localDraftId: string): void { + if (typeof window === "undefined") { + return; + } + + const drafts = readStoredDrafts(); + + if (drafts[localDraftId] === undefined) { + return; + } + + delete drafts[localDraftId]; + + writeStoredDrafts(drafts); +} + +/** + * Checks whether a particular local draft exists. + * Checks the presence of the ID only. + */ +export function hasStoredDraft(localDraftId: string): boolean { + if (typeof window === "undefined") { return false; } + + const drafts = readStoredDrafts(); + + return drafts[localDraftId] !== undefined; } /** - * Clears all stored drafts for all contribution types from localStorage. + * Deletes every locally stored contribution draft. */ export function clearAllStoredDrafts(): void { - const types = Object.keys(STORAGE_KEYS) as Array; - for (const type of types) { - clearStoredDraft(type); + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch (error) { + console.warn("Failed to clear contribution drafts:", error); + } +} + +/** + * Deletes all drafts belonging to a particular + * contribution type. + */ +export function clearStoredDraftsByType(type: ContributionType): void { + if (typeof window === "undefined") { + return; + } + + const drafts = readStoredDrafts(); + + let changed = false; + + for (const [localDraftId, rawDraft] of Object.entries(drafts)) { + const parsedDraft = parseStoredDraft(rawDraft); + + if (!parsedDraft) { + delete drafts[localDraftId]; + changed = true; + continue; + } + + if (parsedDraft.type === type) { + delete drafts[localDraftId]; + changed = true; + } + } + + if (changed) { + writeStoredDrafts(drafts); } } @@ -130,51 +474,146 @@ export interface ContributionSaveControls { } /** - * Hook to automatically and debouncingly save contribution form data to localStorage. - * Passing a null type disables saving, flushing anything already queued. - * Flushes pending changes immediately on component unmount. + * Automatically and debouncingly saves one particular + * contribution draft to localStorage. + * + * localDraftId identifies WHICH draft is being saved. + */ +export function useDebouncedContributionSave( + localDraftId: string | null, + type: "guide" | null, + data: StoredGuideDraft["data"], + revisionId: string | null, + step?: string, + delay?: number +): ContributionSaveControls; + +export function useDebouncedContributionSave( + localDraftId: string | null, + type: "variant" | null, + data: StoredVariantDraft["data"], + revisionId: string | null, + step?: string, + delay?: number +): ContributionSaveControls; + +export function useDebouncedContributionSave( + localDraftId: string | null, + type: "objective" | null, + data: StoredObjectiveDraft["data"], + revisionId: string | null, + step?: string, + delay?: number +): ContributionSaveControls; + +/** + * Implementation for useDebouncedContributionSave. */ -export function useDebouncedContributionSave( +export function useDebouncedContributionSave( + localDraftId: string | null, type: ContributionType | null, - data: T, + data: + | StoredGuideDraft["data"] + | StoredVariantDraft["data"] + | StoredObjectiveDraft["data"], revisionId: string | null, step?: string, delay: number = 400 ): ContributionSaveControls { const pendingRef = useRef<{ + localDraftId: string; type: ContributionType; - data: T; + data: + | StoredGuideDraft["data"] + | StoredVariantDraft["data"] + | StoredObjectiveDraft["data"]; revisionId: string | null; step?: string; } | null>(null); - if (type) pendingRef.current = { type, data, revisionId, step }; const timerRef = useRef | null>(null); + const isPendingRef = useRef(false); + /** + * Always keep the latest contribution data available. + */ + if (localDraftId && type) { + pendingRef.current = { + localDraftId, + type, + data, + revisionId, + step, + }; + } + const clearTimer = () => { - if (timerRef.current) { + if (timerRef.current !== null) { clearTimeout(timerRef.current); timerRef.current = null; } }; + /** + * Immediately save the latest pending data. + */ const flush = () => { clearTimer(); - if (!isPendingRef.current) return; + + if (!isPendingRef.current) { + return; + } + isPendingRef.current = false; const pending = pendingRef.current; - if (!pending) return; - - setStoredDraft(pending.type, { - data: pending.data, - revisionId: pending.revisionId, - step: pending.step, - updatedAt: Date.now(), - }); + + if (!pending) { + return; + } + + switch (pending.type) { + case "guide": + setStoredDraft({ + localDraftId: pending.localDraftId, + type: "guide", + data: pending.data as StoredGuideDraft["data"], + revisionId: pending.revisionId, + step: pending.step, + updatedAt: Date.now(), + }); + break; + + case "variant": + setStoredDraft({ + localDraftId: pending.localDraftId, + type: "variant", + data: pending.data as StoredVariantDraft["data"], + revisionId: pending.revisionId, + step: pending.step, + updatedAt: Date.now(), + }); + break; + + case "objective": + setStoredDraft({ + localDraftId: pending.localDraftId, + type: "objective", + data: pending.data as StoredObjectiveDraft["data"], + revisionId: pending.revisionId, + step: pending.step, + updatedAt: Date.now(), + }); + break; + } }; + /** + * Cancel the pending autosave. + * + * This does NOT delete an existing localStorage draft. + */ const cancel = () => { clearTimer(); isPendingRef.current = false; @@ -182,26 +621,41 @@ export function useDebouncedContributionSave( const flushRef = useRef(flush); flushRef.current = flush; + const cancelRef = useRef(cancel); cancelRef.current = cancel; + /** + * Start/restart debounce timer whenever the contribution changes. + */ useEffect(() => { - if (!type) { + if (!localDraftId || !type) { flushRef.current(); return; } isPendingRef.current = true; + clearTimer(); + timerRef.current = setTimeout(() => { timerRef.current = null; flushRef.current(); }, delay); - }, [type, data, revisionId, step, delay]); + return clearTimer; + }, [localDraftId, type, data, revisionId, step, delay]); + + /** + * Flush anything still waiting when the component unmounts. + */ useEffect(() => { - return () => flushRef.current(); + return () => { + flushRef.current(); + }; }, []); - return { cancel: () => cancelRef.current() }; + return { + cancel: () => cancelRef.current(), + }; } From 00f86e3163bc9f61b9ef4c95ca4e5712fedc4138 Mon Sep 17 00:00:00 2001 From: andyrodrigues30 Date: Thu, 3 Sep 2026 03:46:56 +0100 Subject: [PATCH 2/5] feat: create multi guide contribution workspace in contribution flow Signed-off-by: andyrodrigues30 --- app/src/components/GuideMobileMenu.tsx | 2 +- .../contribute/ContributionFlow.tsx | 928 ++++++++++++------ .../contribute/StepperActionHeader.tsx | 16 +- .../components/contribute/steps/Content.tsx | 62 +- .../contribute/steps/GuideDetails.tsx | 527 ---------- .../contribute/steps/PreviewGuide.tsx | 42 - .../contribute/steps/SelectType.tsx | 2 +- .../contribute/steps/VariantDetails.tsx | 281 ------ .../contribute/steps/guide/EditGuideInfo.tsx | 113 +++ .../contribute/steps/guide/GuideDetails.tsx | 581 +++++++++++ .../contribute/steps/guide/GuideInfo.tsx | 139 +++ .../contribute/steps/guide/PreviewGuide.tsx | 134 +++ .../{ => objective}/ObjectiveDetails.tsx | 0 .../{ => objective}/OrderObjectiveGuides.tsx | 0 .../{ => objective}/OrderTargetGuides.tsx | 0 .../{ => objective}/PreviewObjective.tsx | 6 +- .../steps/variant/PreviewVariant.tsx | 95 ++ .../steps/variant/VariantDetails.tsx | 250 +++++ .../contribute/steps/variant/VariantInfo.tsx | 93 ++ .../components/modals/SelectDraftModal.tsx | 160 +++ app/src/components/sidebar/EditorSidebar.tsx | 227 +++++ app/src/lib/api/identity.ts | 10 + app/src/lib/contributionFlow.ts | 8 +- app/src/lib/contributionStorage.ts | 172 +--- app/src/routes/contribute.tsx | 6 +- .../routes/guides/$slug/$variantSlug/edit.tsx | 292 ++++-- .../guides/$slug/$variantSlug/index.tsx | 2 +- app/src/routes/guides/$slug/index.tsx | 4 +- app/src/routes/objectives/$slug/index.tsx | 2 +- app/src/routes/settings.account.tsx | 10 +- app/src/types/contributions.ts | 6 +- 31 files changed, 2714 insertions(+), 1456 deletions(-) delete mode 100644 app/src/components/contribute/steps/GuideDetails.tsx delete mode 100644 app/src/components/contribute/steps/PreviewGuide.tsx delete mode 100644 app/src/components/contribute/steps/VariantDetails.tsx create mode 100644 app/src/components/contribute/steps/guide/EditGuideInfo.tsx create mode 100644 app/src/components/contribute/steps/guide/GuideDetails.tsx create mode 100644 app/src/components/contribute/steps/guide/GuideInfo.tsx create mode 100644 app/src/components/contribute/steps/guide/PreviewGuide.tsx rename app/src/components/contribute/steps/{ => objective}/ObjectiveDetails.tsx (100%) rename app/src/components/contribute/steps/{ => objective}/OrderObjectiveGuides.tsx (100%) rename app/src/components/contribute/steps/{ => objective}/OrderTargetGuides.tsx (100%) rename app/src/components/contribute/steps/{ => objective}/PreviewObjective.tsx (98%) create mode 100644 app/src/components/contribute/steps/variant/PreviewVariant.tsx create mode 100644 app/src/components/contribute/steps/variant/VariantDetails.tsx create mode 100644 app/src/components/contribute/steps/variant/VariantInfo.tsx create mode 100644 app/src/components/modals/SelectDraftModal.tsx create mode 100644 app/src/components/sidebar/EditorSidebar.tsx diff --git a/app/src/components/GuideMobileMenu.tsx b/app/src/components/GuideMobileMenu.tsx index 2ca4c51d..612eab54 100644 --- a/app/src/components/GuideMobileMenu.tsx +++ b/app/src/components/GuideMobileMenu.tsx @@ -44,7 +44,7 @@ export function GuideMobileMenu({ return ( <> - + )} @@ -112,7 +124,7 @@ export const StepperActionHeader = ({ - + {(onSaveDraft || !hideBackBtn || onPublish) && (
diff --git a/app/src/components/contribute/steps/Content.tsx b/app/src/components/contribute/steps/Content.tsx index 607c66d2..42d67e0b 100644 --- a/app/src/components/contribute/steps/Content.tsx +++ b/app/src/components/contribute/steps/Content.tsx @@ -1,61 +1,39 @@ import { Suspense, lazy, useEffect, useState } from "react"; -import type { ContributionType } from "@/types/contributions"; -import { StepperActionHeader } from "@/components/contribute/StepperActionHeader"; const Editor = lazy(() => import("../editor/Editor")); type PropTypes = { - Stepper: any; - type: ContributionType | null; body: string; onBodyChange: (body: string) => void; onUploadImage?: (file: File) => Promise; - onSaveDraft: () => void; - submitting?: boolean; - title?: string; }; -export const Content = ({ - Stepper, - type, - body, - onBodyChange, - onUploadImage, - onSaveDraft, - submitting, - title = "Content", -}: PropTypes) => { +export const Content = ({ body, onBodyChange, onUploadImage }: PropTypes) => { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); - return ( - - + if (mounted) { + return ( + }> + + + ); + } else { + return ; + } +}; - {mounted && ( - - Loading Editor... -
- } - > - - - )} - +const EditorFallback = () => { + return ( +
+ Loading Editor... +
); }; diff --git a/app/src/components/contribute/steps/GuideDetails.tsx b/app/src/components/contribute/steps/GuideDetails.tsx deleted file mode 100644 index b540cc18..00000000 --- a/app/src/components/contribute/steps/GuideDetails.tsx +++ /dev/null @@ -1,527 +0,0 @@ -import { X } from "lucide-react"; -import { useState } from "react"; -import { normalizeTodoTitle, todoPrereqSchema } from "@bluelearn/schemas"; -import type { Dispatch, SetStateAction } from "react"; -import type { - ContributionType, - GuideContribution, -} from "@/types/contributions"; - -import { StepperActionHeader } from "@/components/contribute/StepperActionHeader"; -import { - Field, - FieldDescription, - FieldError, - FieldGroup, - FieldLabel, -} from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Combobox } from "@/components/ui/combobox"; - -type SubjectOption = { id: string; name: string }; -type GuideOption = { - slug: string | null; - title: string | null; - summary: string | null; -}; - -type PropTypes = { - Stepper: any; - type: ContributionType | null; - guideContData: GuideContribution; - setGuideContData: Dispatch>; - subjects: Array; - guides: Array; - onSaveDraft: () => void; - submitting?: boolean; - showBaseFields?: boolean; - hideBackBtn?: boolean; - title?: string; - changeSummary?: string; - onChangeSummaryChange?: (value: string) => void; -}; - -export const GuideDetails = ({ - Stepper, - type, - guideContData, - setGuideContData, - subjects, - guides, - onSaveDraft, - submitting, - // Prerequisites and todos live on the guide base, so they're only - // authorable while the base is still a draft. - showBaseFields = true, - hideBackBtn, - title = "Guide Details", - changeSummary, - onChangeSummaryChange, -}: PropTypes) => { - const [todoPrereq, setTodoPrereq] = useState<{ - title: string; - summary: string; - }>({ - title: "", - summary: "", - }); - const [todoPrereqError, setTodoPrereqError] = useState<{ - field: "title" | "summary"; - message: string; - } | null>(null); - const [newSubject, setNewSubject] = useState<{ - name: string; - summary: string; - }>({ - name: "", - summary: "", - }); - - return ( - - - - - {showBaseFields && ( - <> -
- - Type - - - Choose whether this guide explains a concept or teaches a - process for accomplishing a goal. - -
- - - - - - - )} - -
- - Title - - - A clear, concise name for your guide. - -
- - - setGuideContData((prev) => ({ - ...prev, - title: e.target.value, - })) - } - /> -
- - -
- - Summary - - - Briefly describe what the reader will learn from this guide. - -
- -