diff --git a/CLAUDE.md b/CLAUDE.md index d7fe60b..50e3229 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,64 +181,118 @@ The markdown references optimized figures: `./figures/optimized/filename.png` ### Generating Figures -Figures are generated using **Fal AI's Recraft v3 (nano-banana-pro)** model via Convex actions, then stored in Convex storage. +Figures are generated using **Fal AI's nano-banana-pro** model via Convex actions, with LLM-enhanced prompts for consistent styling. -#### Generation Process +#### Figure Spec Syntax -1. **Use Fal Actions**: Generate images using the `fal-ai/recraft-v3` model (nano-banana-pro) -2. **Store in Convex**: Upload generated images to Convex storage for persistence -3. **Download for Ebook**: Export images from Convex storage to `ebook/figures/` +Instead of manually generating and linking images, use the `figure` code fence syntax in the markdown. This embeds generation prompts directly in the document: -#### Design Guidelines (IMPORTANT) +````markdown +```figure +id: my-diagram +prompt: "A pyramid with 5 layers showing the startup foundation" +alt: My Diagram Title +style: diagram +aspect_ratio: 4:3 +resolution: 2K +``` +*Caption text in italics* +```` + +**Fields:** +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | Unique identifier, used as filename | +| `prompt` | Yes | Generation prompt for Fal AI | +| `alt` | Yes | Alt text for accessibility | +| `style` | No | Style preset (see below). Default: `diagram` | +| `aspect_ratio` | No | Image ratio. Default: `4:3` | +| `resolution` | No | `1K`, `2K`, or `4K`. Default: `2K` | +| `src` | Auto | Added automatically after generation (locks the figure) | -All figures must follow these guidelines for consistency: +#### Style Presets -1. **Flat, Front-Facing Perspective** - - Diagrams and charts must be presented **directly facing the screen** - - **NO 3D angles or isometric views** for informational graphics - - Text and labels should be horizontal and readable +The generation script uses an LLM to enhance prompts with style-specific requirements for visual consistency: -2. **Brand Alignment** - - Use the amber/orange color scheme (#f59e0b, #d97706, #ea580c) - - Consistent illustration style across all figures - - Professional, clean aesthetic matching the ebook design +| Style | Description | +|-------|-------------| +| `diagram` | Technical diagrams, framework visualizations (like the Pivot Pyramid) | +| `flowchart` | Process flows, decision trees | +| `matrix` | Grids, comparison matrices | +| `canvas` | Worksheets, planning templates | +| `conceptual` | Abstract concept illustrations | + +All styles apply the ebook's visual identity: +- **3D isometric perspective** with depth and polish +- **Color palette**: Dark teal/navy (#1e3a5f), amber/gold (#f59e0b), muted teal-greens +- **Glossy surfaces** with gradients and lighting effects +- **Clean light gray backgrounds** +- **Professional business illustration style** + +#### Generation Workflow + +1. **Add figure spec** to markdown (without `src` field) +2. **Run generation script**: + ```bash + npm run ebook:generate-figures + ``` +3. **Script automatically**: + - Finds all figures without `src` paths + - Enhances prompts using LLM + style presets + - Generates images via Fal AI (in parallel) + - Downloads to `ebook/figures/optimized/` + - Copies to `public/ebook/figures/` + - Updates markdown with `src` path -3. **Visual Consistency** - - Same illustration style for all figures (flat design with subtle shadows) - - Consistent use of the pyramid motif where relevant - - Unified color palette: amber/orange for primary, teal for accents, stone grays for neutrals +#### Regenerating Figures -#### Example Prompt Structure +```bash +# Regenerate only figures without src paths +npm run ebook:generate-figures -``` -Create a professional business diagram showing [concept]. - -Style requirements: -- Flat, 2D design viewed directly from the front (not at an angle) -- Clean, minimal aesthetic with amber/orange (#f59e0b) as primary color -- Professional business illustration style -- Clear labels and text that are horizontal and readable -- Light gray or white background -- Subtle shadows for depth, but diagram should face the viewer directly +# Force regenerate ALL figures (even those with src) +npm run ebook:generate-figures -- --force ``` -#### Adding New Figures +To regenerate a single figure: +1. Delete the `src:` line from its figure spec +2. Run `npm run ebook:generate-figures` -1. Generate figure using Fal AI with the design guidelines above -2. Store in Convex storage -3. Download and save to `ebook/figures/` -4. Create optimized version in `ebook/figures/optimized/`: - ```bash - cd ebook/figures - magick "new-figure.png" -resize 1200x1200\> -quality 85 "optimized/new-figure.png" +#### How Locking Works + +- **Without `src`**: Figure is pending generation +- **With `src`**: Figure is "locked" - won't be regenerated unless `--force` is used +- The `prompt` is always preserved for documentation, even after generation + +#### Key Files + +| File | Purpose | +|------|---------| +| `scripts/generate-ebook-figures.mjs` | CLI script for figure generation | +| `src/lib/ebook-figure-parser.ts` | Parses figure specs from markdown | +| `src/lib/ebook-parser.ts` | Transforms figure specs to standard images | +| `convex/lib/fal/actions/generateEbookFigure.ts` | Convex action with LLM enhancement | + +#### Adding New Figures (Simplified) + +1. Add figure spec to markdown: + ````markdown + ```figure + id: new-concept-diagram + prompt: "Visual representation of the concept" + alt: New Concept Diagram + style: diagram ``` -5. Reference in markdown: - ```markdown - ![Figure Title](./figures/optimized/figure-name.png) + *Figure caption* + ```` - *Caption text in italics* +2. Generate: + ```bash + npm run ebook:generate-figures ``` +3. Done! The `src` path is automatically added and the figure is ready. + ### Markdown Structure The ebook markdown follows this structure: @@ -372,6 +426,9 @@ Set `NEXT_PUBLIC_CONVEX_URL` in Vercel environment variables. | `src/app/ebook/page.tsx` | HTML ebook landing page | | `src/app/ebook/[slug]/page.tsx` | Dynamic chapter pages | | `src/lib/ebook-parser.ts` | Ebook markdown parser | +| `src/lib/ebook-figure-parser.ts` | Figure spec parser | +| `scripts/generate-ebook-figures.mjs` | Figure generation CLI script | +| `convex/lib/fal/actions/generateEbookFigure.ts` | Fal AI figure generation action | | `convex/canvases.ts` | Canvas CRUD + AI generation | | `convex/canvasStream.ts` | AI streaming for chat | | `src/lib/pivot-pyramid-data.ts` | Layer definitions and prompts | diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 6392362..7fe5a5a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -12,6 +12,10 @@ import type * as adminAuth from "../adminAuth.js"; import type * as canvasMessages from "../canvasMessages.js"; import type * as canvasStream from "../canvasStream.js"; import type * as canvases from "../canvases.js"; +import type * as ebook_actions from "../ebook/actions.js"; +import type * as ebook_auth from "../ebook/auth.js"; +import type * as ebook_mutations from "../ebook/mutations.js"; +import type * as ebook_queries from "../ebook/queries.js"; import type * as ebookAccess from "../ebookAccess.js"; import type * as ebookAccessCodes from "../ebookAccessCodes.js"; import type * as ebookSubscribers from "../ebookSubscribers.js"; @@ -60,6 +64,10 @@ declare const fullApi: ApiFromModules<{ canvasMessages: typeof canvasMessages; canvasStream: typeof canvasStream; canvases: typeof canvases; + "ebook/actions": typeof ebook_actions; + "ebook/auth": typeof ebook_auth; + "ebook/mutations": typeof ebook_mutations; + "ebook/queries": typeof ebook_queries; ebookAccess: typeof ebookAccess; ebookAccessCodes: typeof ebookAccessCodes; ebookSubscribers: typeof ebookSubscribers; diff --git a/convex/ebook/actions.ts b/convex/ebook/actions.ts new file mode 100644 index 0000000..8bb5cc9 --- /dev/null +++ b/convex/ebook/actions.ts @@ -0,0 +1,128 @@ +"use node"; + +import { action } from "../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../_generated/api"; +import { Id } from "../_generated/dataModel"; + +/** + * Ebook Actions + * + * Actions that need Node.js runtime (fetch, file operations, etc.) + * These are separated from mutations because only actions can use "use node" + */ + +// Action to upload a figure from URL (used by migration script) +export const uploadFigureFromUrl = action({ + args: { + draftId: v.id("ebookDrafts"), + figureId: v.string(), + imageUrl: v.string(), + alt: v.string(), + caption: v.optional(v.string()), + }, + returns: v.object({ + success: v.boolean(), + figureDocId: v.optional(v.id("ebookFigures")), + storageId: v.optional(v.id("_storage")), + error: v.optional(v.string()), + }), + handler: async (ctx, args): Promise<{ + success: boolean; + figureDocId?: Id<"ebookFigures">; + storageId?: Id<"_storage">; + error?: string; + }> => { + try { + // Fetch the image + const response = await fetch(args.imageUrl); + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.statusText}`); + } + + const blob = await response.blob(); + + // Store in Convex storage + const storageId = await ctx.storage.store(blob); + + // Create the figure record + const figureDocId: Id<"ebookFigures"> = await ctx.runMutation( + internal.ebook.mutations.storeFigureFromUpload, + { + draftId: args.draftId, + figureId: args.figureId, + storageId, + alt: args.alt, + caption: args.caption, + } + ); + + return { + success: true, + figureDocId, + storageId, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}); + +// Action to upload figure from file bytes (for migration script reading local files) +export const uploadFigureFromBytes = action({ + args: { + draftId: v.id("ebookDrafts"), + figureId: v.string(), + base64Data: v.string(), + mimeType: v.string(), + alt: v.string(), + caption: v.optional(v.string()), + }, + returns: v.object({ + success: v.boolean(), + figureDocId: v.optional(v.id("ebookFigures")), + storageId: v.optional(v.id("_storage")), + error: v.optional(v.string()), + }), + handler: async (ctx, args): Promise<{ + success: boolean; + figureDocId?: Id<"ebookFigures">; + storageId?: Id<"_storage">; + error?: string; + }> => { + try { + // Convert base64 to blob + const buffer = Buffer.from(args.base64Data, "base64"); + const blob = new Blob([buffer], { type: args.mimeType }); + + // Store in Convex storage + const storageId = await ctx.storage.store(blob); + + // Create the figure record + const figureDocId: Id<"ebookFigures"> = await ctx.runMutation( + internal.ebook.mutations.storeFigureFromUpload, + { + draftId: args.draftId, + figureId: args.figureId, + storageId, + alt: args.alt, + caption: args.caption, + } + ); + + return { + success: true, + figureDocId, + storageId, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}); diff --git a/convex/ebook/auth.ts b/convex/ebook/auth.ts new file mode 100644 index 0000000..670dafe --- /dev/null +++ b/convex/ebook/auth.ts @@ -0,0 +1,100 @@ +import { QueryCtx, MutationCtx } from "../_generated/server"; +import { Id } from "../_generated/dataModel"; + +/** + * Ebook Permission Helpers + * + * Handles authorization for ebook editing: + * - Admins can edit any ebook + * - Owners can edit their own ebooks (drafts they created) + */ + +/** + * Get user from the current authenticated identity + */ +async function getUserFromIdentity(ctx: QueryCtx | MutationCtx) { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) return null; + + const existingUser = await ctx.db + .query("users") + .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject)) + .first(); + + return existingUser; +} + +/** + * Check if user can edit a specific draft + * Returns { canEdit: boolean, user, draft } without throwing + */ +export async function checkEbookEditAccess( + ctx: QueryCtx | MutationCtx, + draftId: Id<"ebookDrafts"> +) { + const user = await getUserFromIdentity(ctx); + if (!user) { + return { canEdit: false, user: null, draft: null, reason: "Not authenticated" }; + } + + const draft = await ctx.db.get(draftId); + if (!draft) { + return { canEdit: false, user, draft: null, reason: "Draft not found" }; + } + + // Admins can edit any draft + if (user.isAdmin) { + return { canEdit: true, user, draft, reason: null }; + } + + // Owners can edit their own drafts + if (draft.createdById === user._id) { + return { canEdit: true, user, draft, reason: null }; + } + + return { canEdit: false, user, draft, reason: "You don't have permission to edit this ebook" }; +} + +/** + * Require edit access - throws if not authorized + * Use this in mutations that modify ebook content + */ +export async function requireEbookEditAccess( + ctx: QueryCtx | MutationCtx, + draftId: Id<"ebookDrafts"> +) { + const result = await checkEbookEditAccess(ctx, draftId); + + if (!result.canEdit) { + throw new Error(result.reason || "Access denied"); + } + + return { user: result.user!, draft: result.draft! }; +} + +/** + * Get the current user if authenticated + */ +export async function getOptionalUser(ctx: QueryCtx | MutationCtx) { + return await getUserFromIdentity(ctx); +} + +/** + * Check if user can edit any ebook (admin or has owned drafts) + * Useful for showing/hiding edit buttons in UI + */ +export async function canUserEditAnyEbook(ctx: QueryCtx | MutationCtx) { + const user = await getUserFromIdentity(ctx); + if (!user) return false; + + // Admins can always edit + if (user.isAdmin) return true; + + // Check if user owns any drafts + const ownedDrafts = await ctx.db + .query("ebookDrafts") + .withIndex("by_creator", (q) => q.eq("createdById", user._id)) + .first(); + + return ownedDrafts !== null; +} diff --git a/convex/ebook/mutations.ts b/convex/ebook/mutations.ts new file mode 100644 index 0000000..19752fa --- /dev/null +++ b/convex/ebook/mutations.ts @@ -0,0 +1,578 @@ +import { mutation, internalMutation } from "../_generated/server"; +import { v } from "convex/values"; +import { Id } from "../_generated/dataModel"; + +/** + * Ebook Content Mutations + * + * Mutations for managing ebook content including drafts, parts, chapters, blocks, and figures. + * These are designed to be used by AI agents for editing. + */ + +// =========================================== +// DRAFT MUTATIONS +// =========================================== + +export const createDraft = mutation({ + args: { + name: v.string(), + description: v.optional(v.string()), + isPublished: v.optional(v.boolean()), + }, + returns: v.id("ebookDrafts"), + handler: async (ctx, args) => { + const now = Date.now(); + + // If publishing, unpublish all other drafts first + if (args.isPublished) { + const publishedDrafts = await ctx.db + .query("ebookDrafts") + .withIndex("by_published", (q) => q.eq("isPublished", true)) + .collect(); + + for (const draft of publishedDrafts) { + await ctx.db.patch(draft._id, { isPublished: false, updatedAt: now }); + } + } + + return await ctx.db.insert("ebookDrafts", { + name: args.name, + description: args.description, + isPublished: args.isPublished ?? false, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const publishDraft = mutation({ + args: { + draftId: v.id("ebookDrafts"), + }, + handler: async (ctx, args) => { + const now = Date.now(); + + // Unpublish all other drafts + const publishedDrafts = await ctx.db + .query("ebookDrafts") + .withIndex("by_published", (q) => q.eq("isPublished", true)) + .collect(); + + for (const draft of publishedDrafts) { + await ctx.db.patch(draft._id, { isPublished: false, updatedAt: now }); + } + + // Publish the target draft + await ctx.db.patch(args.draftId, { isPublished: true, updatedAt: now }); + }, +}); + +// =========================================== +// PART MUTATIONS +// =========================================== + +export const createPart = mutation({ + args: { + draftId: v.id("ebookDrafts"), + title: v.string(), + order: v.number(), + }, + returns: v.id("ebookParts"), + handler: async (ctx, args) => { + const now = Date.now(); + return await ctx.db.insert("ebookParts", { + draftId: args.draftId, + title: args.title, + order: args.order, + createdAt: now, + updatedAt: now, + }); + }, +}); + +// =========================================== +// CHAPTER MUTATIONS +// =========================================== + +export const createChapter = mutation({ + args: { + draftId: v.id("ebookDrafts"), + partId: v.optional(v.id("ebookParts")), + slug: v.string(), + title: v.string(), + type: v.union(v.literal("intro"), v.literal("chapter"), v.literal("appendix")), + chapterNumber: v.optional(v.number()), + order: v.number(), + }, + returns: v.id("ebookChapters"), + handler: async (ctx, args) => { + const now = Date.now(); + return await ctx.db.insert("ebookChapters", { + draftId: args.draftId, + partId: args.partId, + slug: args.slug, + title: args.title, + type: args.type, + chapterNumber: args.chapterNumber, + order: args.order, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const updateChapter = mutation({ + args: { + chapterId: v.id("ebookChapters"), + title: v.optional(v.string()), + slug: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const updates: Record = { updatedAt: Date.now() }; + if (args.title !== undefined) updates.title = args.title; + if (args.slug !== undefined) updates.slug = args.slug; + await ctx.db.patch(args.chapterId, updates); + }, +}); + +// =========================================== +// BLOCK MUTATIONS (for AI editing) +// =========================================== + +export const insertBlock = mutation({ + args: { + chapterId: v.id("ebookChapters"), + type: v.union( + v.literal("paragraph"), + v.literal("heading2"), + v.literal("heading3"), + v.literal("heading4"), + v.literal("blockquote"), + v.literal("list"), + v.literal("table"), + v.literal("figure"), + v.literal("code") + ), + content: v.string(), + afterBlockId: v.optional(v.id("ebookBlocks")), + figureId: v.optional(v.id("ebookFigures")), + listType: v.optional(v.union(v.literal("bullet"), v.literal("numbered"))), + }, + returns: v.id("ebookBlocks"), + handler: async (ctx, args) => { + const now = Date.now(); + + // Get all blocks in chapter to determine order + const blocks = await ctx.db + .query("ebookBlocks") + .withIndex("by_chapter_order", (q) => q.eq("chapterId", args.chapterId)) + .collect(); + + let newOrder: number; + + if (args.afterBlockId) { + // Find the block to insert after + const afterBlock = blocks.find((b) => b._id === args.afterBlockId); + if (!afterBlock) { + throw new Error("Block to insert after not found"); + } + + // Calculate new order (midpoint between afterBlock and next block) + const afterIndex = blocks.findIndex((b) => b._id === args.afterBlockId); + const nextBlock = blocks[afterIndex + 1]; + + if (nextBlock) { + newOrder = (afterBlock.order + nextBlock.order) / 2; + } else { + newOrder = afterBlock.order + 1; + } + } else { + // Insert at the end (append) + const lastBlock = blocks[blocks.length - 1]; + newOrder = lastBlock ? lastBlock.order + 1 : 0; + } + + return await ctx.db.insert("ebookBlocks", { + chapterId: args.chapterId, + type: args.type, + content: args.content, + figureId: args.figureId, + listType: args.listType, + order: newOrder, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const updateBlock = mutation({ + args: { + blockId: v.id("ebookBlocks"), + content: v.optional(v.string()), + type: v.optional( + v.union( + v.literal("paragraph"), + v.literal("heading2"), + v.literal("heading3"), + v.literal("heading4"), + v.literal("blockquote"), + v.literal("list"), + v.literal("table"), + v.literal("figure"), + v.literal("code") + ) + ), + }, + handler: async (ctx, args) => { + const updates: Record = { updatedAt: Date.now() }; + if (args.content !== undefined) updates.content = args.content; + if (args.type !== undefined) updates.type = args.type; + await ctx.db.patch(args.blockId, updates); + }, +}); + +export const deleteBlock = mutation({ + args: { + blockId: v.id("ebookBlocks"), + }, + handler: async (ctx, args) => { + await ctx.db.delete(args.blockId); + }, +}); + +export const moveBlock = mutation({ + args: { + blockId: v.id("ebookBlocks"), + afterBlockId: v.optional(v.id("ebookBlocks")), + }, + handler: async (ctx, args) => { + const block = await ctx.db.get(args.blockId); + if (!block) throw new Error("Block not found"); + + const blocks = await ctx.db + .query("ebookBlocks") + .withIndex("by_chapter_order", (q) => q.eq("chapterId", block.chapterId)) + .collect(); + + let newOrder: number; + + if (args.afterBlockId) { + const afterBlock = blocks.find((b) => b._id === args.afterBlockId); + if (!afterBlock) throw new Error("Target block not found"); + + const afterIndex = blocks.findIndex((b) => b._id === args.afterBlockId); + const nextBlock = blocks[afterIndex + 1]; + + if (nextBlock && nextBlock._id !== args.blockId) { + newOrder = (afterBlock.order + nextBlock.order) / 2; + } else { + newOrder = afterBlock.order + 1; + } + } else { + const firstBlock = blocks[0]; + newOrder = firstBlock ? firstBlock.order - 1 : 0; + } + + await ctx.db.patch(args.blockId, { order: newOrder, updatedAt: Date.now() }); + }, +}); + +// Batch edit for AI agents - execute multiple operations atomically +export const batchEditBlocks = mutation({ + args: { + operations: v.array( + v.object({ + op: v.union(v.literal("insert"), v.literal("update"), v.literal("delete")), + blockId: v.optional(v.id("ebookBlocks")), + chapterId: v.optional(v.id("ebookChapters")), + type: v.optional( + v.union( + v.literal("paragraph"), + v.literal("heading2"), + v.literal("heading3"), + v.literal("heading4"), + v.literal("blockquote"), + v.literal("list"), + v.literal("table"), + v.literal("figure"), + v.literal("code") + ) + ), + content: v.optional(v.string()), + afterBlockId: v.optional(v.id("ebookBlocks")), + figureId: v.optional(v.id("ebookFigures")), + listType: v.optional(v.union(v.literal("bullet"), v.literal("numbered"))), + }) + ), + }, + returns: v.array(v.union(v.id("ebookBlocks"), v.null())), + handler: async (ctx, args) => { + const results: (Id<"ebookBlocks"> | null)[] = []; + const now = Date.now(); + + for (const op of args.operations) { + if (op.op === "insert") { + if (!op.chapterId || !op.type || op.content === undefined) { + results.push(null); + continue; + } + + const blocks = await ctx.db + .query("ebookBlocks") + .withIndex("by_chapter_order", (q) => q.eq("chapterId", op.chapterId!)) + .collect(); + + let newOrder: number; + if (op.afterBlockId) { + const afterBlock = blocks.find((b) => b._id === op.afterBlockId); + if (afterBlock) { + const afterIndex = blocks.findIndex((b) => b._id === op.afterBlockId); + const nextBlock = blocks[afterIndex + 1]; + newOrder = nextBlock + ? (afterBlock.order + nextBlock.order) / 2 + : afterBlock.order + 1; + } else { + newOrder = blocks.length; + } + } else { + const firstBlock = blocks[0]; + newOrder = firstBlock ? firstBlock.order - 1 : 0; + } + + const id = await ctx.db.insert("ebookBlocks", { + chapterId: op.chapterId, + type: op.type, + content: op.content, + figureId: op.figureId, + listType: op.listType, + order: newOrder, + createdAt: now, + updatedAt: now, + }); + results.push(id); + } else if (op.op === "update") { + if (!op.blockId) { + results.push(null); + continue; + } + const updates: Record = { updatedAt: now }; + if (op.content !== undefined) updates.content = op.content; + if (op.type !== undefined) updates.type = op.type; + await ctx.db.patch(op.blockId, updates); + results.push(op.blockId); + } else if (op.op === "delete") { + if (!op.blockId) { + results.push(null); + continue; + } + await ctx.db.delete(op.blockId); + results.push(op.blockId); + } + } + + return results; + }, +}); + +// =========================================== +// FIGURE MUTATIONS +// =========================================== + +export const createFigure = mutation({ + args: { + draftId: v.id("ebookDrafts"), + figureId: v.string(), + storageId: v.id("_storage"), + alt: v.string(), + caption: v.optional(v.string()), + prompt: v.optional(v.string()), + enhancedPrompt: v.optional(v.string()), + style: v.optional(v.string()), + width: v.optional(v.number()), + height: v.optional(v.number()), + }, + returns: v.id("ebookFigures"), + handler: async (ctx, args) => { + const now = Date.now(); + return await ctx.db.insert("ebookFigures", { + draftId: args.draftId, + figureId: args.figureId, + storageId: args.storageId, + alt: args.alt, + caption: args.caption, + prompt: args.prompt, + enhancedPrompt: args.enhancedPrompt, + style: args.style, + width: args.width, + height: args.height, + createdAt: now, + updatedAt: now, + }); + }, +}); + +// Clear all chapters, parts, and blocks from a draft (for reimport) +export const clearDraftContent = mutation({ + args: { + draftId: v.id("ebookDrafts"), + }, + handler: async (ctx, args) => { + // Get all chapters in draft + const chapters = await ctx.db + .query("ebookChapters") + .withIndex("by_draft_order", (q) => q.eq("draftId", args.draftId)) + .collect(); + + // Delete all blocks for each chapter + for (const chapter of chapters) { + const blocks = await ctx.db + .query("ebookBlocks") + .withIndex("by_chapter_order", (q) => q.eq("chapterId", chapter._id)) + .collect(); + + for (const block of blocks) { + await ctx.db.delete(block._id); + } + + // Delete the chapter + await ctx.db.delete(chapter._id); + } + + // Delete all parts + const parts = await ctx.db + .query("ebookParts") + .withIndex("by_draft_order", (q) => q.eq("draftId", args.draftId)) + .collect(); + + for (const part of parts) { + await ctx.db.delete(part._id); + } + + return { + chaptersDeleted: chapters.length, + partsDeleted: parts.length, + }; + }, +}); + +// Internal mutation for storing uploaded figure (called from actions.ts) +export const storeFigureFromUpload = internalMutation({ + args: { + draftId: v.id("ebookDrafts"), + figureId: v.string(), + storageId: v.id("_storage"), + alt: v.string(), + caption: v.optional(v.string()), + }, + returns: v.id("ebookFigures"), + handler: async (ctx, args) => { + const now = Date.now(); + return await ctx.db.insert("ebookFigures", { + draftId: args.draftId, + figureId: args.figureId, + storageId: args.storageId, + alt: args.alt, + caption: args.caption, + createdAt: now, + updatedAt: now, + }); + }, +}); + +// =========================================== +// UPLOAD MUTATIONS (for figure uploads) +// =========================================== + +// Generate an upload URL for client-side file upload +export const generateUploadUrl = mutation({ + args: {}, + returns: v.string(), + handler: async (ctx) => { + return await ctx.storage.generateUploadUrl(); + }, +}); + +// Create or update a figure after uploading an image +export const createFigureFromUpload = mutation({ + args: { + draftId: v.id("ebookDrafts"), + storageId: v.id("_storage"), + alt: v.string(), + caption: v.optional(v.string()), + }, + returns: v.id("ebookFigures"), + handler: async (ctx, args) => { + const now = Date.now(); + // Generate a unique figure ID + const figureId = `figure-${now}-${Math.random().toString(36).slice(2, 8)}`; + return await ctx.db.insert("ebookFigures", { + draftId: args.draftId, + figureId, + storageId: args.storageId, + alt: args.alt, + caption: args.caption, + createdAt: now, + updatedAt: now, + }); + }, +}); + +// Update figure metadata (alt text, caption, prompt, style, etc.) +export const updateFigure = mutation({ + args: { + figureId: v.id("ebookFigures"), + alt: v.optional(v.string()), + caption: v.optional(v.string()), + prompt: v.optional(v.string()), + enhancedPrompt: v.optional(v.string()), + style: v.optional(v.string()), + width: v.optional(v.number()), + height: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const updates: Record = { updatedAt: Date.now() }; + if (args.alt !== undefined) updates.alt = args.alt; + if (args.caption !== undefined) updates.caption = args.caption; + if (args.prompt !== undefined) updates.prompt = args.prompt; + if (args.enhancedPrompt !== undefined) updates.enhancedPrompt = args.enhancedPrompt; + if (args.style !== undefined) updates.style = args.style; + if (args.width !== undefined) updates.width = args.width; + if (args.height !== undefined) updates.height = args.height; + await ctx.db.patch(args.figureId, updates); + }, +}); + +// Replace a figure's image (delete old storage, link new one) +export const replaceFigureImage = mutation({ + args: { + figureId: v.id("ebookFigures"), + newStorageId: v.id("_storage"), + }, + handler: async (ctx, args) => { + const figure = await ctx.db.get(args.figureId); + if (!figure) throw new Error("Figure not found"); + + // Delete old storage file + await ctx.storage.delete(figure.storageId); + + // Update with new storage ID + await ctx.db.patch(args.figureId, { + storageId: args.newStorageId, + updatedAt: Date.now(), + }); + }, +}); + +// Link a figure to a block +export const linkFigureToBlock = mutation({ + args: { + blockId: v.id("ebookBlocks"), + figureId: v.id("ebookFigures"), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.blockId, { + figureId: args.figureId, + updatedAt: Date.now(), + }); + }, +}); diff --git a/convex/ebook/queries.ts b/convex/ebook/queries.ts new file mode 100644 index 0000000..a26ea8f --- /dev/null +++ b/convex/ebook/queries.ts @@ -0,0 +1,331 @@ +import { query } from "../_generated/server"; +import { v } from "convex/values"; +import { checkEbookEditAccess } from "./auth"; + +/** + * Ebook Content Queries + * + * Queries for reading ebook content from Convex. + * Used by the frontend to render chapters and by AI agents to understand context. + */ + +// =========================================== +// DRAFT QUERIES +// =========================================== + +export const getPublishedDraft = query({ + args: {}, + returns: v.union( + v.object({ + _id: v.id("ebookDrafts"), + _creationTime: v.number(), + name: v.string(), + description: v.optional(v.string()), + isPublished: v.boolean(), + createdById: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + }), + v.null() + ), + handler: async (ctx) => { + const drafts = await ctx.db + .query("ebookDrafts") + .withIndex("by_published", (q) => q.eq("isPublished", true)) + .collect(); + + return drafts[0] ?? null; + }, +}); + +// Check if current user can edit a specific draft +export const canEditDraft = query({ + args: { + draftId: v.id("ebookDrafts"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const result = await checkEbookEditAccess(ctx, args.draftId); + return result.canEdit; + }, +}); + +// Check if current user can edit the published draft +export const canEditPublishedDraft = query({ + args: {}, + returns: v.boolean(), + handler: async (ctx) => { + const drafts = await ctx.db + .query("ebookDrafts") + .withIndex("by_published", (q) => q.eq("isPublished", true)) + .collect(); + + const draft = drafts[0]; + if (!draft) return false; + + const result = await checkEbookEditAccess(ctx, draft._id); + return result.canEdit; + }, +}); + +export const getDraft = query({ + args: { + draftId: v.id("ebookDrafts"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.draftId); + }, +}); + +export const listDrafts = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("ebookDrafts").collect(); + }, +}); + +// =========================================== +// PART QUERIES +// =========================================== + +export const getParts = query({ + args: { + draftId: v.id("ebookDrafts"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("ebookParts") + .withIndex("by_draft_order", (q) => q.eq("draftId", args.draftId)) + .collect(); + }, +}); + +// =========================================== +// CHAPTER QUERIES +// =========================================== + +export const getChapters = query({ + args: { + draftId: v.id("ebookDrafts"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("ebookChapters") + .withIndex("by_draft_order", (q) => q.eq("draftId", args.draftId)) + .collect(); + }, +}); + +export const getChapterBySlug = query({ + args: { + draftId: v.id("ebookDrafts"), + slug: v.string(), + }, + handler: async (ctx, args) => { + const chapters = await ctx.db + .query("ebookChapters") + .withIndex("by_draft_slug", (q) => + q.eq("draftId", args.draftId).eq("slug", args.slug) + ) + .collect(); + + return chapters[0] ?? null; + }, +}); + +export const getChaptersByPart = query({ + args: { + partId: v.id("ebookParts"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("ebookChapters") + .withIndex("by_part", (q) => q.eq("partId", args.partId)) + .collect(); + }, +}); + +// =========================================== +// BLOCK QUERIES +// =========================================== + +export const getChapterBlocks = query({ + args: { + chapterId: v.id("ebookChapters"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("ebookBlocks") + .withIndex("by_chapter_order", (q) => q.eq("chapterId", args.chapterId)) + .collect(); + }, +}); + +// Get chapter with all its blocks (for rendering) +export const getChapterWithBlocks = query({ + args: { + draftId: v.id("ebookDrafts"), + slug: v.string(), + }, + handler: async (ctx, args) => { + // Get chapter + const chapters = await ctx.db + .query("ebookChapters") + .withIndex("by_draft_slug", (q) => + q.eq("draftId", args.draftId).eq("slug", args.slug) + ) + .collect(); + + const chapter = chapters[0]; + if (!chapter) return null; + + // Get blocks + const blocks = await ctx.db + .query("ebookBlocks") + .withIndex("by_chapter_order", (q) => q.eq("chapterId", chapter._id)) + .collect(); + + // Get figures referenced by blocks + const figureIds = blocks + .filter((b) => b.figureId) + .map((b) => b.figureId!); + + const figures = await Promise.all( + figureIds.map((id) => ctx.db.get(id)) + ); + + // Get figure URLs from storage + const figuresWithUrls = await Promise.all( + figures.filter(Boolean).map(async (figure) => { + if (!figure) return null; + const url = await ctx.storage.getUrl(figure.storageId); + return { ...figure, url }; + }) + ); + + return { + chapter, + blocks, + figures: figuresWithUrls.filter(Boolean), + }; + }, +}); + +// =========================================== +// FIGURE QUERIES +// =========================================== + +export const getFigures = query({ + args: { + draftId: v.id("ebookDrafts"), + }, + handler: async (ctx, args) => { + const figures = await ctx.db + .query("ebookFigures") + .withIndex("by_draft", (q) => q.eq("draftId", args.draftId)) + .collect(); + + // Add URLs + return await Promise.all( + figures.map(async (figure) => { + const url = await ctx.storage.getUrl(figure.storageId); + return { ...figure, url }; + }) + ); + }, +}); + +export const getFigureByFigureId = query({ + args: { + draftId: v.id("ebookDrafts"), + figureId: v.string(), + }, + handler: async (ctx, args) => { + const figures = await ctx.db + .query("ebookFigures") + .withIndex("by_draft_figure_id", (q) => + q.eq("draftId", args.draftId).eq("figureId", args.figureId) + ) + .collect(); + + const figure = figures[0]; + if (!figure) return null; + + const url = await ctx.storage.getUrl(figure.storageId); + return { ...figure, url }; + }, +}); + +// =========================================== +// TABLE OF CONTENTS QUERY +// =========================================== + +export const getTableOfContents = query({ + args: { + draftId: v.id("ebookDrafts"), + }, + handler: async (ctx, args) => { + // Get all parts + const parts = await ctx.db + .query("ebookParts") + .withIndex("by_draft_order", (q) => q.eq("draftId", args.draftId)) + .collect(); + + // Get all chapters + const chapters = await ctx.db + .query("ebookChapters") + .withIndex("by_draft_order", (q) => q.eq("draftId", args.draftId)) + .collect(); + + // Group chapters by part + const groups: { + part: { _id: string; title: string; order: number } | null; + chapters: typeof chapters; + }[] = []; + + // First add intro chapters (no part) + const introChapters = chapters.filter((c) => !c.partId); + if (introChapters.length > 0) { + groups.push({ part: null, chapters: introChapters }); + } + + // Then add part-grouped chapters + for (const part of parts) { + const partChapters = chapters.filter( + (c) => c.partId === part._id + ); + if (partChapters.length > 0) { + groups.push({ + part: { _id: part._id, title: part.title, order: part.order }, + chapters: partChapters, + }); + } + } + + return groups; + }, +}); + +// =========================================== +// ADJACENT CHAPTERS (for navigation) +// =========================================== + +export const getAdjacentChapters = query({ + args: { + draftId: v.id("ebookDrafts"), + slug: v.string(), + }, + handler: async (ctx, args) => { + const chapters = await ctx.db + .query("ebookChapters") + .withIndex("by_draft_order", (q) => q.eq("draftId", args.draftId)) + .collect(); + + const currentIndex = chapters.findIndex((c) => c.slug === args.slug); + + return { + previous: currentIndex > 0 ? chapters[currentIndex - 1] : null, + next: currentIndex < chapters.length - 1 ? chapters[currentIndex + 1] : null, + }; + }, +}); diff --git a/convex/lib/fal/actions/generateEbookFigure.ts b/convex/lib/fal/actions/generateEbookFigure.ts index b7e3752..926ac7c 100644 --- a/convex/lib/fal/actions/generateEbookFigure.ts +++ b/convex/lib/fal/actions/generateEbookFigure.ts @@ -3,24 +3,113 @@ import { action } from "../../../_generated/server"; import { v } from "convex/values"; import { NanoBananaClient } from "../clients/image/nanoBananaClient"; +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { generateText } from "ai"; + +/** + * Style presets for ebook figures + * Each preset defines visual requirements for consistent branding + * + * Visual identity based on existing figures: + * - 3D isometric perspective with depth and polish + * - Color palette: dark teal/navy (#1e3a5f), amber/gold (#f59e0b), muted teal-greens, warm stone neutrals + * - Glossy, polished look with gradients and subtle lighting + * - Clean light gray/off-white backgrounds + * - Professional business illustration style + */ +const STYLE_PRESETS = { + diagram: { + description: "Technical diagram or framework visualization (like the Pivot Pyramid)", + requirements: + "3D isometric perspective with depth and polish, glossy surfaces with subtle gradients and lighting effects, color palette of dark teal/navy (#1e3a5f), amber/gold (#f59e0b), muted teal-greens, and warm stone neutrals, clean light gray or off-white background, professional business illustration style, small iconic symbols or figures for visual interest, subtle drop shadows and reflections, high-quality polished render", + }, + flowchart: { + description: "Process flow or decision tree", + requirements: + "3D isometric flowchart with depth, rounded rectangular boxes with glossy surfaces, color palette of dark teal/navy (#1e3a5f) for boxes, amber/gold (#f59e0b) for highlights and decision points, muted teal-green for positive paths, directional arrows with depth, clean light gray background, professional business style, subtle shadows and gradients, polished render quality", + }, + matrix: { + description: "Grid or comparison matrix", + requirements: + "3D isometric grid/matrix with depth and perspective, glossy cell surfaces with rounded corners, color palette of dark teal/navy (#1e3a5f), amber/gold (#f59e0b), muted teal-greens, and warm stone neutrals, alternating subtle gradients, clean light gray background, professional business illustration, small icons in cells for visual interest, polished render with subtle shadows", + }, + canvas: { + description: "Worksheet or planning canvas", + requirements: + "3D isometric worksheet template with depth, glossy section panels with rounded corners, color palette of dark teal/navy (#1e3a5f), amber/gold (#f59e0b) for headers and accents, muted teal-greens, clean light gray background, professional business style, subtle shadows and lighting effects, high-quality polished render", + }, + conceptual: { + description: "Abstract concept illustration", + requirements: + "3D isometric conceptual illustration with depth and polish, metaphorical visual representation, color palette of dark teal/navy (#1e3a5f), amber/gold (#f59e0b), muted teal-greens, and warm stone neutrals, glossy surfaces with gradients and lighting, clean light gray or off-white background, professional business illustration style, small human figures or icons for scale, subtle reflections and shadows, high-quality polished render", + }, +} as const; + +type StylePreset = keyof typeof STYLE_PRESETS; + +/** + * Enhance a user prompt with style-specific requirements using LLM + */ +async function enhancePrompt( + userPrompt: string, + style: StylePreset +): Promise { + const preset = STYLE_PRESETS[style]; + + const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY!, + }); + + const result = await generateText({ + model: openrouter.chat("google/gemini-2.5-flash"), + prompt: `You are enhancing an image generation prompt for a professional business ebook figure. + +Original prompt: "${userPrompt}" + +Style: ${style} - ${preset.description} + +Style requirements: ${preset.requirements} + +Create an enhanced prompt that: +1. Preserves the user's intent and core concept +2. Incorporates ALL the style requirements listed above +3. Is specific and detailed for image generation +4. Ensures the result will be professional, on-brand, and suitable for a business ebook +5. Keeps the image simple and clean - avoid cluttered or overly complex designs + +Return ONLY the enhanced prompt, no explanation or additional text.`, + }); + + return result.text.trim(); +} /** * Generate an ebook figure using Nano Banana Pro and store in Convex storage * Returns a clean HTTP URL for easy downloading * * Pattern from minimoji: - * 1. Generate with FAL AI - * 2. Download/decode the image - * 3. Store in Convex storage - * 4. Return Convex storage URL + * 1. Enhance prompt with style requirements (via LLM) + * 2. Generate with FAL AI + * 3. Download/decode the image + * 4. Store in Convex storage + * 5. Return Convex storage URL * * Usage: - * npx convex run lib/fal/actions/generateEbookFigure:generateFigure '{"prompt": "...", "filename": "pyramid.png"}' + * npx convex run lib/fal/actions/generateEbookFigure:generateFigure '{"prompt": "...", "filename": "pyramid.png", "style": "diagram"}' */ export const generateFigure = action({ args: { prompt: v.string(), filename: v.string(), + style: v.optional( + v.union( + v.literal("diagram"), + v.literal("flowchart"), + v.literal("matrix"), + v.literal("canvas"), + v.literal("conceptual") + ) + ), aspect_ratio: v.optional( v.union( v.literal("21:9"), @@ -45,16 +134,25 @@ export const generateFigure = action({ width: v.optional(v.union(v.number(), v.null())), height: v.optional(v.union(v.number(), v.null())), description: v.optional(v.string()), + originalPrompt: v.optional(v.string()), + enhancedPrompt: v.optional(v.string()), error: v.optional(v.string()), }), handler: async (ctx, args) => { console.log(`šŸ“ø Generating figure: ${args.filename}`); - console.log(`šŸ“ Prompt: ${args.prompt.substring(0, 100)}...`); + console.log(`šŸ“ Original prompt: ${args.prompt.substring(0, 100)}...`); try { - // Step 1: Generate image with FAL AI + // Step 1: Enhance prompt with style requirements + const style = args.style || "diagram"; + console.log(`šŸŽØ Style: ${style}`); + + const enhancedPrompt = await enhancePrompt(args.prompt, style); + console.log(`✨ Enhanced prompt: ${enhancedPrompt.substring(0, 150)}...`); + + // Step 2: Generate image with FAL AI const result = await NanoBananaClient.generateImage({ - prompt: args.prompt, + prompt: enhancedPrompt, aspect_ratio: args.aspect_ratio || "4:3", resolution: args.resolution || "2K", num_images: 1, @@ -126,6 +224,8 @@ export const generateFigure = action({ width: image.width, height: image.height, description: result.description, + originalPrompt: args.prompt, + enhancedPrompt, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -141,7 +241,7 @@ export const generateFigure = action({ /** * Generate multiple figures in a single action (sequential within action) - * Alternative to parallel external calls if needed + * Each figure can have its own style for prompt enhancement */ export const generateMultipleFigures = action({ args: { @@ -149,6 +249,7 @@ export const generateMultipleFigures = action({ v.object({ prompt: v.string(), filename: v.string(), + style: v.optional(v.string()), aspect_ratio: v.optional(v.string()), resolution: v.optional(v.string()), }) @@ -162,6 +263,8 @@ export const generateMultipleFigures = action({ width: v.optional(v.union(v.number(), v.null())), height: v.optional(v.union(v.number(), v.null())), description: v.optional(v.string()), + originalPrompt: v.optional(v.string()), + enhancedPrompt: v.optional(v.string()), error: v.optional(v.string()), }) ), @@ -174,8 +277,15 @@ export const generateMultipleFigures = action({ console.log(`\nšŸ“ø Processing: ${fig.filename}`); try { + // Enhance prompt with style requirements + const style = (fig.style as StylePreset) || "diagram"; + console.log(`šŸŽØ Style: ${style}`); + + const enhancedPrompt = await enhancePrompt(fig.prompt, style); + console.log(`✨ Enhanced: ${enhancedPrompt.substring(0, 100)}...`); + const result = await NanoBananaClient.generateImage({ - prompt: fig.prompt, + prompt: enhancedPrompt, aspect_ratio: (fig.aspect_ratio as "4:3" | "16:9" | "1:1") || "4:3", resolution: (fig.resolution as "1K" | "2K" | "4K") || "2K", num_images: 1, @@ -194,6 +304,8 @@ export const generateMultipleFigures = action({ width: image.width, height: image.height, description: result.description, + originalPrompt: fig.prompt, + enhancedPrompt, }); } else { results.push({ @@ -213,7 +325,7 @@ export const generateMultipleFigures = action({ } } - const successCount = results.filter(r => r.success).length; + const successCount = results.filter((r) => r.success).length; console.log(`\nāœ… Generated ${successCount}/${args.figures.length} figures`); return results; diff --git a/convex/schema.ts b/convex/schema.ts index 4eab5bb..538993b 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -13,6 +13,26 @@ const startupProfileValidator = v.object({ description: v.string(), // Freeform description of the startup }); +// Block types for ebook content (Notion-like) +const ebookBlockTypeValidator = v.union( + v.literal("paragraph"), + v.literal("heading2"), + v.literal("heading3"), + v.literal("heading4"), + v.literal("blockquote"), + v.literal("list"), + v.literal("table"), + v.literal("figure"), + v.literal("code") +); + +// Chapter types +const ebookChapterTypeValidator = v.union( + v.literal("intro"), + v.literal("chapter"), + v.literal("appendix") +); + export default defineSchema({ // Users (synced from Clerk) users: defineTable({ @@ -162,4 +182,97 @@ export default defineSchema({ .index("by_tier", ["tier"]) .index("by_sent", ["isSent"]) .index("by_email", ["email"]), + + // =========================================== + // EBOOK CONTENT MANAGEMENT + // Hierarchical structure: Draft → Parts → Chapters → Blocks + // =========================================== + + // Ebook drafts (version control / branches) + ebookDrafts: defineTable({ + name: v.string(), // "main", "v2-rewrite", etc. + description: v.optional(v.string()), + isPublished: v.boolean(), // Only one can be published at a time + createdById: v.optional(v.id("users")), // Owner of the draft + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_published", ["isPublished"]) + .index("by_creator", ["createdById"]), + + // Parts (Part I, Part II, etc.) + ebookParts: defineTable({ + draftId: v.id("ebookDrafts"), + title: v.string(), // "Part I: The Framework" + order: v.number(), // 0, 1, 2, 3, 4 + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_draft", ["draftId"]) + .index("by_draft_order", ["draftId", "order"]), + + // Chapters + ebookChapters: defineTable({ + draftId: v.id("ebookDrafts"), + partId: v.optional(v.id("ebookParts")), // null for intro sections (foreword, etc.) + slug: v.string(), // "chapter-1", "foreword", "appendix-a" + title: v.string(), // "The Pivot Pyramid Foundation" + type: ebookChapterTypeValidator, + chapterNumber: v.optional(v.number()), // 1-14 for chapters + order: v.number(), // Global order within draft + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_draft", ["draftId"]) + .index("by_draft_slug", ["draftId", "slug"]) + .index("by_part", ["partId"]) + .index("by_draft_order", ["draftId", "order"]), + + // Content blocks (Notion-like paragraphs/headings/etc.) + ebookBlocks: defineTable({ + chapterId: v.id("ebookChapters"), + type: ebookBlockTypeValidator, + content: v.string(), // Markdown content + + // For figures only + figureId: v.optional(v.id("ebookFigures")), + + // For list blocks + listType: v.optional(v.union(v.literal("bullet"), v.literal("numbered"))), + + order: v.number(), // Position within chapter + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_chapter", ["chapterId"]) + .index("by_chapter_order", ["chapterId", "order"]), + + // Figures (separate table for reusability and metadata) + ebookFigures: defineTable({ + draftId: v.id("ebookDrafts"), + + // Identity + figureId: v.string(), // "pivot-pyramid-foundation" (filename without extension) + + // Storage + storageId: v.id("_storage"), // Convex storage reference + + // Display + alt: v.string(), // Alt text for accessibility + caption: v.optional(v.string()), // Figure caption + + // Generation metadata (from figure spec) + prompt: v.optional(v.string()), // Original generation prompt + enhancedPrompt: v.optional(v.string()), // LLM-enhanced prompt + style: v.optional(v.string()), // diagram, flowchart, matrix, etc. + + // Dimensions + width: v.optional(v.number()), + height: v.optional(v.number()), + + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_draft", ["draftId"]) + .index("by_draft_figure_id", ["draftId", "figureId"]), }); diff --git a/ebook/figures/optimized/test-pyramid-diagram.png b/ebook/figures/optimized/test-pyramid-diagram.png new file mode 100644 index 0000000..7194e2b Binary files /dev/null and b/ebook/figures/optimized/test-pyramid-diagram.png differ diff --git a/ebook/pivot-pyramid-ebook.md b/ebook/pivot-pyramid-ebook.md index 3e1ffdf..22d515c 100644 --- a/ebook/pivot-pyramid-ebook.md +++ b/ebook/pivot-pyramid-ebook.md @@ -5558,3 +5558,23 @@ And to you, the reader: Thank you for investing your time in this framework. I h *All rights reserved.* +--- + + + +## Test Figure Generation + +This section tests the new figure spec syntax for automatic image generation. + +```figure +id: test-pyramid-diagram +prompt: "A pyramid with 5 horizontal layers labeled from bottom to top: Customers, Problem, Solution, Technology, Growth" +alt: Test Pivot Pyramid Diagram +style: diagram +aspect_ratio: 4:3 +resolution: 2K +src: ./figures/optimized/test-pyramid-diagram.png +``` +*A test figure showing the Pivot Pyramid layers.* + + diff --git a/next.config.js b/next.config.js index 2309650..8f66954 100644 --- a/next.config.js +++ b/next.config.js @@ -12,6 +12,10 @@ const nextConfig = { protocol: "https", hostname: "img.clerk.com", }, + { + protocol: "https", + hostname: "*.convex.cloud", + }, ], }, diff --git a/package-lock.json b/package-lock.json index 4e8b067..6fbe79d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "dependencies": { "@clerk/nextjs": "^6.36.5", "@convex-dev/persistent-text-streaming": "^0.3.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fal-ai/client": "^1.8.0", "@openrouter/ai-sdk-provider": "^1.5.4", "@tailwindcss/postcss": "^4.1.18", @@ -211,6 +214,60 @@ "react-dom": "~18.3.1 || ^19.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", diff --git a/package.json b/package.json index 46cf596..1dcef60 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,17 @@ "build": "next build", "start": "next start", "lint": "next lint", - "copy-outreach-to-prod": "node scripts/copy-outreach-to-prod.mjs" + "copy-outreach-to-prod": "node scripts/copy-outreach-to-prod.mjs", + "ebook:generate-figures": "node scripts/generate-ebook-figures.mjs", + "ebook:migrate-figures": "node scripts/migrate-figures-to-convex.mjs", + "ebook:import-content": "node scripts/import-ebook-to-convex.mjs" }, "dependencies": { "@clerk/nextjs": "^6.36.5", "@convex-dev/persistent-text-streaming": "^0.3.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fal-ai/client": "^1.8.0", "@openrouter/ai-sdk-provider": "^1.5.4", "@tailwindcss/postcss": "^4.1.18", diff --git a/public/ebook/figures/test-pyramid-diagram.png b/public/ebook/figures/test-pyramid-diagram.png new file mode 100644 index 0000000..7194e2b Binary files /dev/null and b/public/ebook/figures/test-pyramid-diagram.png differ diff --git a/scripts/generate-ebook-figures.mjs b/scripts/generate-ebook-figures.mjs new file mode 100755 index 0000000..28119fb --- /dev/null +++ b/scripts/generate-ebook-figures.mjs @@ -0,0 +1,321 @@ +#!/usr/bin/env node + +/** + * Generate Ebook Figures + * + * This script scans the ebook markdown for figure specs (```figure code fences) + * without src paths, generates images via Fal AI with LLM-enhanced prompts, + * downloads them, and updates the markdown with the src path. + * + * Figure Spec Syntax: + * ```figure + * id: my-diagram + * prompt: "Description of the figure" + * alt: Alt text for accessibility + * style: diagram|flowchart|matrix|canvas|conceptual + * aspect_ratio: 4:3 + * resolution: 2K + * ``` + * *Caption text* + * + * Usage: + * npm run ebook:generate-figures # Generate pending figures + * npm run ebook:generate-figures -- --force # Regenerate ALL figures + * + * How it works: + * 1. Parses markdown for ```figure specs without src paths + * 2. For each pending figure: + * - Calls Convex action which enhances prompt with style requirements via LLM + * - Generates image using Fal AI (nano-banana-pro) + * - Stores in Convex storage + * 3. Downloads images to ebook/figures/optimized/ + * 4. Copies to public/ebook/figures/ for web serving + * 5. Updates markdown with src: path (locks the figure) + * + * Prerequisites: + * - Convex dev server running (npx convex dev) + * - OPENROUTER_API_KEY set in Convex environment (for LLM prompt enhancement) + * - FAL_KEY set in Convex environment (for image generation) + */ + +import { execSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PROJECT_ROOT = path.join(__dirname, ".."); + +// Paths +const EBOOK_PATH = path.join(PROJECT_ROOT, "ebook", "pivot-pyramid-ebook.md"); +const FIGURES_DIR = path.join(PROJECT_ROOT, "ebook", "figures", "optimized"); +const PUBLIC_FIGURES_DIR = path.join(PROJECT_ROOT, "public", "ebook", "figures"); + +function log(message) { + console.log(`[generate-figures] ${message}`); +} + +function error(message) { + console.error(`[generate-figures] ERROR: ${message}`); +} + +/** + * Parse figure specs from markdown content + */ +function extractFigureSpecs(markdown) { + const figures = []; + const figureRegex = /```figure\n([\s\S]*?)```/g; + let match; + + while ((match = figureRegex.exec(markdown)) !== null) { + const raw = match[0]; + const specContent = match[1]; + const startIndex = match.index; + const endIndex = match.index + raw.length; + + const spec = {}; + const lines = specContent.trim().split("\n"); + + for (const line of lines) { + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) continue; + + const key = line.slice(0, colonIndex).trim(); + let value = line.slice(colonIndex + 1).trim(); + + // Remove quotes from value if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + spec[key] = value; + } + + // Look for caption (italic text immediately after the code fence) + const afterFence = markdown.slice(endIndex); + const captionMatch = afterFence.match(/^\s*\n\*([^*]+)\*/); + const caption = captionMatch ? captionMatch[1].trim() : undefined; + + figures.push({ + spec, + raw, + startIndex, + endIndex, + caption, + }); + } + + return figures; +} + +/** + * Call Convex action to generate a figure + */ +async function generateFigure(spec) { + const args = { + prompt: spec.prompt, + filename: `${spec.id}.png`, + style: spec.style || "diagram", + aspect_ratio: spec.aspect_ratio || "4:3", + resolution: spec.resolution || "2K", + }; + + log(`Generating: ${spec.id}`); + log(` Prompt: ${spec.prompt.substring(0, 80)}...`); + log(` Style: ${args.style}`); + + try { + const result = execSync( + `npx convex run lib/fal/actions/generateEbookFigure:generateFigure '${JSON.stringify(args)}'`, + { + encoding: "utf-8", + cwd: PROJECT_ROOT, + stdio: ["pipe", "pipe", "pipe"], + } + ); + + // Parse JSON result + const parsed = JSON.parse(result.trim()); + + if (!parsed.success) { + throw new Error(parsed.error || "Unknown error"); + } + + return parsed; + } catch (err) { + error(`Failed to generate ${spec.id}: ${err.message}`); + throw err; + } +} + +/** + * Download image from URL to local file + */ +async function downloadImage(url, outputPath) { + log(`Downloading to: ${outputPath}`); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download: ${response.statusText}`); + } + + const buffer = await response.arrayBuffer(); + fs.writeFileSync(outputPath, Buffer.from(buffer)); + + const sizeKB = (buffer.byteLength / 1024).toFixed(1); + log(` Downloaded: ${sizeKB} KB`); +} + +/** + * Update markdown with new src path for a figure + */ +function updateMarkdownSrc(markdown, figureId, srcPath) { + const figures = extractFigureSpecs(markdown); + const figure = figures.find((f) => f.spec.id === figureId); + + if (!figure) { + throw new Error(`Figure not found: ${figureId}`); + } + + const specContent = markdown.slice(figure.startIndex, figure.endIndex); + + let updatedSpec; + if (figure.spec.src) { + // Update existing src line + updatedSpec = specContent.replace(/^src:.*$/m, `src: ${srcPath}`); + } else { + // Add new src line before the closing ``` + updatedSpec = specContent.replace(/```$/, `src: ${srcPath}\n\`\`\``); + } + + return ( + markdown.slice(0, figure.startIndex) + + updatedSpec + + markdown.slice(figure.endIndex) + ); +} + +/** + * Main function + */ +async function main() { + const args = process.argv.slice(2); + const forceAll = args.includes("--force"); + + log("Starting ebook figure generation..."); + log(`Ebook path: ${EBOOK_PATH}`); + + // Ensure directories exist + if (!fs.existsSync(FIGURES_DIR)) { + fs.mkdirSync(FIGURES_DIR, { recursive: true }); + log(`Created directory: ${FIGURES_DIR}`); + } + if (!fs.existsSync(PUBLIC_FIGURES_DIR)) { + fs.mkdirSync(PUBLIC_FIGURES_DIR, { recursive: true }); + log(`Created directory: ${PUBLIC_FIGURES_DIR}`); + } + + // Read markdown + let markdown = fs.readFileSync(EBOOK_PATH, "utf-8"); + + // Extract figure specs + const allFigures = extractFigureSpecs(markdown); + log(`Found ${allFigures.length} figure spec(s) in markdown`); + + // Filter to pending figures (or all if --force) + const pendingFigures = forceAll + ? allFigures + : allFigures.filter((f) => !f.spec.src); + + if (pendingFigures.length === 0) { + log("No figures to generate. All figures have src paths."); + return; + } + + log(`Generating ${pendingFigures.length} figure(s) in parallel...`); + if (forceAll) { + log("(--force flag: regenerating all figures)"); + } + + // Generate all figures in parallel + const generationPromises = pendingFigures.map(async (figure) => { + const { spec } = figure; + + if (!spec.id || !spec.prompt) { + return { spec, success: false, error: "Missing id or prompt" }; + } + + try { + log(`Starting: ${spec.id}`); + const result = await generateFigure(spec); + + if (!result.storageUrl) { + return { spec, success: false, error: "No storage URL returned" }; + } + + return { spec, success: true, result }; + } catch (err) { + return { spec, success: false, error: err.message }; + } + }); + + const generationResults = await Promise.all(generationPromises); + + // Process results: download images and update markdown sequentially + let successCount = 0; + let errorCount = 0; + + for (const { spec, success, result, error: errMsg } of generationResults) { + if (!success) { + error(`Failed to generate ${spec.id}: ${errMsg}`); + errorCount++; + continue; + } + + try { + // Download to ebook/figures/optimized/ + const optimizedPath = path.join(FIGURES_DIR, `${spec.id}.png`); + await downloadImage(result.storageUrl, optimizedPath); + + // Copy to public/ebook/figures/ + const publicPath = path.join(PUBLIC_FIGURES_DIR, `${spec.id}.png`); + fs.copyFileSync(optimizedPath, publicPath); + log(` Copied to: ${publicPath}`); + + // Update markdown with src path + const srcPath = `./figures/optimized/${spec.id}.png`; + markdown = updateMarkdownSrc(markdown, spec.id, srcPath); + + log(` Enhanced prompt: ${result.enhancedPrompt?.substring(0, 100)}...`); + log(`Successfully generated: ${spec.id}`); + successCount++; + } catch (err) { + error(`Failed to download/save ${spec.id}: ${err.message}`); + errorCount++; + } + } + + // Write updated markdown + fs.writeFileSync(EBOOK_PATH, markdown); + log(`Updated markdown saved to: ${EBOOK_PATH}`); + + // Summary + log(""); + log("=== Summary ==="); + log(`Total figures: ${allFigures.length}`); + log(`Generated: ${successCount}`); + log(`Errors: ${errorCount}`); + + if (errorCount > 0) { + process.exit(1); + } +} + +main().catch((err) => { + error(err.message); + process.exit(1); +}); diff --git a/scripts/import-ebook-to-convex.mjs b/scripts/import-ebook-to-convex.mjs new file mode 100644 index 0000000..17a04e5 --- /dev/null +++ b/scripts/import-ebook-to-convex.mjs @@ -0,0 +1,487 @@ +#!/usr/bin/env node + +/** + * Import Ebook Content to Convex + * + * This script: + * 1. Uses an existing draft (or creates one if needed) + * 2. Parses the ebook markdown into parts, chapters, and blocks + * 3. Creates all the necessary records in Convex + * + * IMPORTANT: Run migrate-figures-to-convex.mjs first to upload figures! + * + * Usage: + * npm run ebook:import-content + * # or with custom draft name: + * npm run ebook:import-content -- --draft "v2" + */ + +import { ConvexHttpClient } from "convex/browser"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load environment variables +const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL; + +if (!CONVEX_URL) { + console.error("Error: NEXT_PUBLIC_CONVEX_URL environment variable is required"); + process.exit(1); +} + +const client = new ConvexHttpClient(CONVEX_URL); + +// Chapter configuration (same as ebook-parser.ts) +const CHAPTER_CONFIG = [ + { pattern: /^# The Pivot Pyramid$/m, slug: "title", type: "intro" }, + { pattern: /^# Why I Wrote This Book$/m, slug: "foreword", type: "intro" }, + { pattern: /^# Table of Contents$/m, slug: "toc", type: "intro" }, + { pattern: /^## Chapter 1:/m, slug: "chapter-1", type: "chapter" }, + { pattern: /^## Chapter 2:/m, slug: "chapter-2", type: "chapter" }, + { pattern: /^## Chapter 3:/m, slug: "chapter-3", type: "chapter" }, + { pattern: /^## Chapter 4:/m, slug: "chapter-4", type: "chapter" }, + { pattern: /^## Chapter 5:/m, slug: "chapter-5", type: "chapter" }, + { pattern: /^## Chapter 6:/m, slug: "chapter-6", type: "chapter" }, + { pattern: /^## Chapter 7:/m, slug: "chapter-7", type: "chapter" }, + { pattern: /^## Chapter 8:/m, slug: "chapter-8", type: "chapter" }, + { pattern: /^## Chapter 9:/m, slug: "chapter-9", type: "chapter" }, + { pattern: /^## Chapter 10:/m, slug: "chapter-10", type: "chapter" }, + { pattern: /^## Chapter 11:/m, slug: "chapter-11", type: "chapter" }, + { pattern: /^## Chapter 12:/m, slug: "chapter-12", type: "chapter" }, + { pattern: /^## Chapter 13:/m, slug: "chapter-13", type: "chapter" }, + { pattern: /^## Chapter 14:/m, slug: "chapter-14", type: "chapter" }, + { pattern: /^## Appendix A:/m, slug: "appendix-a", type: "appendix" }, + { pattern: /^## Appendix B:/m, slug: "appendix-b", type: "appendix" }, + { pattern: /^## Appendix C:/m, slug: "appendix-c", type: "appendix" }, +]; + +// Part definitions +const PARTS = [ + { title: "Part I: The Framework", chapters: ["chapter-1", "chapter-2", "chapter-3"] }, + { title: "Part II: Diagnosis", chapters: ["chapter-4", "chapter-5", "chapter-6"] }, + { title: "Part III: Execution", chapters: ["chapter-7", "chapter-8", "chapter-9", "chapter-10"] }, + { title: "Part IV: Advanced Topics", chapters: ["chapter-11", "chapter-12", "chapter-13"] }, + { title: "Part V: Tools and Resources", chapters: ["chapter-14", "appendix-a", "appendix-b", "appendix-c"] }, +]; + +function getPartForChapter(slug) { + for (const part of PARTS) { + if (part.chapters.includes(slug)) { + return part.title; + } + } + return null; +} + +function extractTitle(content) { + const match = content.match(/^#+ (.+)$/m); + if (match) { + return match[1].replace(/^(Chapter \d+|Appendix [A-C]): /, ""); + } + return "Untitled"; +} + +function getChapterNumber(slug) { + const match = slug.match(/chapter-(\d+)/); + return match ? parseInt(match[1], 10) : null; +} + +function stripPartHeaders(content) { + return content.replace(/^# Part [IVX]+:.*$/gm, "").trim(); +} + +/** + * Parse markdown content into blocks + */ +function parseContentIntoBlocks(content) { + const blocks = []; + const lines = content.split("\n"); + let currentBlock = null; + let inCodeBlock = false; + let inTable = false; + let inList = false; + let listType = null; + + function pushCurrentBlock() { + if (currentBlock && currentBlock.content.trim()) { + blocks.push({ + type: currentBlock.type, + content: currentBlock.content.trim(), + listType: currentBlock.listType, + }); + } + currentBlock = null; + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmedLine = line.trim(); + + // Handle code blocks + if (trimmedLine.startsWith("```")) { + if (inCodeBlock) { + // End of code block + currentBlock.content += "\n" + line; + pushCurrentBlock(); + inCodeBlock = false; + } else { + // Start of code block + pushCurrentBlock(); + inCodeBlock = true; + currentBlock = { type: "code", content: line }; + } + continue; + } + + if (inCodeBlock) { + currentBlock.content += "\n" + line; + continue; + } + + // Handle figure blocks (```figure ... ```) + if (trimmedLine.startsWith("```figure")) { + pushCurrentBlock(); + let figureContent = line; + i++; + while (i < lines.length && !lines[i].trim().startsWith("```")) { + figureContent += "\n" + lines[i]; + i++; + } + if (i < lines.length) { + figureContent += "\n" + lines[i]; // closing ``` + } + blocks.push({ type: "figure", content: figureContent }); + continue; + } + + // Handle tables + if (trimmedLine.startsWith("|") || (inTable && trimmedLine.includes("|"))) { + if (!inTable) { + pushCurrentBlock(); + inTable = true; + currentBlock = { type: "table", content: line }; + } else { + currentBlock.content += "\n" + line; + } + continue; + } else if (inTable) { + pushCurrentBlock(); + inTable = false; + } + + // Handle empty lines + if (trimmedLine === "") { + if (inList) { + pushCurrentBlock(); + inList = false; + listType = null; + } else if (currentBlock && currentBlock.type === "paragraph") { + pushCurrentBlock(); + } + continue; + } + + // Handle headings + if (trimmedLine.startsWith("####")) { + pushCurrentBlock(); + blocks.push({ type: "heading4", content: trimmedLine.replace(/^####\s*/, "") }); + continue; + } + if (trimmedLine.startsWith("###")) { + pushCurrentBlock(); + blocks.push({ type: "heading3", content: trimmedLine.replace(/^###\s*/, "") }); + continue; + } + if (trimmedLine.startsWith("##")) { + pushCurrentBlock(); + blocks.push({ type: "heading2", content: trimmedLine.replace(/^##\s*/, "") }); + continue; + } + + // Handle blockquotes + if (trimmedLine.startsWith(">")) { + if (!currentBlock || currentBlock.type !== "blockquote") { + pushCurrentBlock(); + currentBlock = { type: "blockquote", content: trimmedLine.replace(/^>\s*/, "") }; + } else { + currentBlock.content += "\n" + trimmedLine.replace(/^>\s*/, ""); + } + continue; + } + + // Handle lists + const bulletMatch = trimmedLine.match(/^[-*]\s+(.*)$/); + const numberedMatch = trimmedLine.match(/^\d+\.\s+(.*)$/); + + if (bulletMatch) { + if (!inList || listType !== "bullet") { + pushCurrentBlock(); + inList = true; + listType = "bullet"; + currentBlock = { type: "list", listType: "bullet", content: line }; + } else { + currentBlock.content += "\n" + line; + } + continue; + } + + if (numberedMatch) { + if (!inList || listType !== "numbered") { + pushCurrentBlock(); + inList = true; + listType = "numbered"; + currentBlock = { type: "list", listType: "numbered", content: line }; + } else { + currentBlock.content += "\n" + line; + } + continue; + } + + // Handle images (convert to figure blocks) + const imageMatch = trimmedLine.match(/^!\[([^\]]*)\]\(([^)]+)\)$/); + if (imageMatch) { + pushCurrentBlock(); + const [, alt, src] = imageMatch; + // Extract figureId from path + const figureIdMatch = src.match(/([^/]+)\.png$/); + const figureId = figureIdMatch ? figureIdMatch[1] : null; + blocks.push({ + type: "figure", + content: trimmedLine, + figureId, + alt, + src, + }); + continue; + } + + // Handle continuation of list items (indented lines) + if (inList && line.match(/^\s+/) && trimmedLine) { + currentBlock.content += "\n" + line; + continue; + } + + // Regular paragraph + if (inList) { + pushCurrentBlock(); + inList = false; + listType = null; + } + + if (!currentBlock || currentBlock.type !== "paragraph") { + pushCurrentBlock(); + currentBlock = { type: "paragraph", content: trimmedLine }; + } else { + currentBlock.content += " " + trimmedLine; + } + } + + // Push final block + pushCurrentBlock(); + + return blocks; +} + +/** + * Parse chapters from ebook markdown + */ +function parseChapters(content) { + const chapters = []; + const positions = []; + + for (const config of CHAPTER_CONFIG) { + const match = content.match(config.pattern); + if (match && match.index !== undefined) { + positions.push({ + slug: config.slug, + type: config.type, + start: match.index, + }); + } + } + + positions.sort((a, b) => a.start - b.start); + + for (let i = 0; i < positions.length; i++) { + const current = positions[i]; + const nextStart = i < positions.length - 1 ? positions[i + 1].start : content.length; + + // Skip title page and TOC + if (current.slug === "title" || current.slug === "toc") continue; + + let chapterContent = content.slice(current.start, nextStart).trim(); + chapterContent = stripPartHeaders(chapterContent); + const title = extractTitle(chapterContent); + + chapters.push({ + slug: current.slug, + title, + content: chapterContent, + part: getPartForChapter(current.slug), + chapterNumber: getChapterNumber(current.slug), + type: current.type, + }); + } + + return chapters; +} + +async function getOrCreateDraft(draftName) { + const drafts = await client.query("ebook/queries:listDrafts", {}); + const existingDraft = drafts.find((d) => d.name === draftName); + + if (existingDraft) { + console.log(`Found existing draft: "${draftName}" (${existingDraft._id})`); + return existingDraft._id; + } + + console.log(`Creating new draft: "${draftName}"`); + const draftId = await client.mutation("ebook/mutations:createDraft", { + name: draftName, + description: "Main ebook content imported from markdown", + isPublished: true, + }); + console.log(`Created draft: ${draftId}`); + return draftId; +} + +async function createParts(draftId) { + console.log("\nCreating parts..."); + const partIds = {}; + + for (let i = 0; i < PARTS.length; i++) { + const part = PARTS[i]; + console.log(` Creating ${part.title}...`); + const partId = await client.mutation("ebook/mutations:createPart", { + draftId, + title: part.title, + order: i, + }); + partIds[part.title] = partId; + console.log(` āœ“ Created ${part.title}`); + } + + return partIds; +} + +async function createChapterWithBlocks(draftId, chapter, partId, order, figureMap) { + console.log(` Creating chapter: ${chapter.slug} (${chapter.title})...`); + + // Create chapter + const chapterId = await client.mutation("ebook/mutations:createChapter", { + draftId, + partId: partId || undefined, + slug: chapter.slug, + title: chapter.title, + type: chapter.type, + chapterNumber: chapter.chapterNumber || undefined, + order, + }); + + // Parse content into blocks + const parsedBlocks = parseContentIntoBlocks(chapter.content); + console.log(` Parsed ${parsedBlocks.length} blocks`); + + // Create blocks + let blockOrder = 0; + for (const block of parsedBlocks) { + let figureId = undefined; + + // If it's a figure block, try to find the figure in Convex + if (block.type === "figure" && block.figureId) { + const figure = figureMap.get(block.figureId); + if (figure) { + figureId = figure._id; + } + } + + await client.mutation("ebook/mutations:insertBlock", { + chapterId, + type: block.type, + content: block.content, + figureId, + listType: block.listType || undefined, + afterBlockId: undefined, // Insert at end + }); + + blockOrder++; + } + + console.log(` āœ“ Created ${parsedBlocks.length} blocks`); + return chapterId; +} + +async function main() { + // Parse args + const args = process.argv.slice(2); + let draftName = "main"; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--draft" && args[i + 1]) { + draftName = args[i + 1]; + i++; + } + } + + console.log("\n=== Ebook Content Import ===\n"); + console.log(`Convex URL: ${CONVEX_URL}`); + console.log(`Draft name: ${draftName}`); + + // Read ebook content + const ebookPath = path.join(__dirname, "..", "ebook", "pivot-pyramid-ebook.md"); + const content = fs.readFileSync(ebookPath, "utf-8"); + console.log(`\nRead ebook: ${ebookPath}`); + + // Get or create draft + const draftId = await getOrCreateDraft(draftName); + + // Check if draft already has chapters + const existingChapters = await client.query("ebook/queries:getChapters", { draftId }); + if (existingChapters.length > 0) { + const forceFlag = args.includes("--force"); + if (!forceFlag) { + console.log(`\nāš ļø Draft "${draftName}" already has ${existingChapters.length} chapters.`); + console.log(" Skipping import to avoid duplicates."); + console.log(" To reimport, use --force flag to clear and reimport."); + process.exit(0); + } + + console.log(`\nāš ļø Draft has ${existingChapters.length} chapters. Clearing with --force...`); + const result = await client.mutation("ebook/mutations:clearDraftContent", { draftId }); + console.log(` āœ“ Cleared ${result.chaptersDeleted} chapters and ${result.partsDeleted} parts`); + } + + // Get existing figures for linking + const figures = await client.query("ebook/queries:getFigures", { draftId }); + const figureMap = new Map(figures.map((f) => [f.figureId, f])); + console.log(`\nFound ${figures.length} figures in draft`); + + // Create parts + const partIds = await createParts(draftId); + + // Parse chapters + const chapters = parseChapters(content); + console.log(`\nParsed ${chapters.length} chapters`); + + // Create chapters and blocks + console.log("\nImporting chapters..."); + let chapterOrder = 0; + + for (const chapter of chapters) { + const partId = chapter.part ? partIds[chapter.part] : null; + await createChapterWithBlocks(draftId, chapter, partId, chapterOrder, figureMap); + chapterOrder++; + } + + console.log("\n=== Import Complete ===\n"); + console.log(`Chapters imported: ${chapters.length}`); + console.log(`Parts created: ${PARTS.length}`); +} + +main().catch((error) => { + console.error("Import failed:", error); + process.exit(1); +}); diff --git a/scripts/migrate-figures-to-convex.mjs b/scripts/migrate-figures-to-convex.mjs new file mode 100644 index 0000000..148546a --- /dev/null +++ b/scripts/migrate-figures-to-convex.mjs @@ -0,0 +1,262 @@ +#!/usr/bin/env node + +/** + * Migrate Ebook Figures to Convex Storage + * + * This script: + * 1. Creates a "main" draft if it doesn't exist + * 2. Reads all PNG files from ebook/figures/optimized/ + * 3. Uploads each to Convex storage + * 4. Creates ebookFigures records with metadata + * + * Usage: + * npm run ebook:migrate-figures + * # or with custom draft name: + * npm run ebook:migrate-figures -- --draft "v2" + */ + +import { ConvexHttpClient } from "convex/browser"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load environment variables +const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL; + +if (!CONVEX_URL) { + console.error("Error: NEXT_PUBLIC_CONVEX_URL environment variable is required"); + console.error("Make sure you have a .env.local file with NEXT_PUBLIC_CONVEX_URL set"); + process.exit(1); +} + +const client = new ConvexHttpClient(CONVEX_URL); + +// Figure metadata - alt text based on filename +const FIGURE_METADATA = { + "anti-pivot-warning": { + alt: "Anti-Pivot Warning Signs", + caption: "Warning signs that indicate a pivot may not be the right choice", + }, + "assessment-canvas-full": { + alt: "Pivot Pyramid Assessment Canvas", + caption: "Complete assessment canvas for evaluating your startup's pyramid layers", + }, + "cascade-customer-pivot": { + alt: "Customer Pivot Cascade Effect", + caption: "How a customer pivot affects all layers of the pyramid", + }, + "cascade-growth-pivot": { + alt: "Growth Pivot Cascade Effect", + caption: "How a growth pivot affects the pyramid layers", + }, + "cascade-problem-pivot": { + alt: "Problem Pivot Cascade Effect", + caption: "How a problem pivot cascades through the layers", + }, + "cascade-solution-pivot": { + alt: "Solution Pivot Cascade Effect", + caption: "How a solution pivot impacts other layers", + }, + "cascade-technology-pivot": { + alt: "Technology Pivot Cascade Effect", + caption: "How a technology pivot affects the pyramid", + }, + "cost-impact-matrix": { + alt: "Pivot Cost-Impact Matrix", + caption: "Matrix showing the cost and impact of different pivot types", + }, + "customer-pivot-playbook": { + alt: "Customer Pivot Playbook", + caption: "Step-by-step guide for executing a customer pivot", + }, + "diagnostic-flowchart": { + alt: "Pivot Diagnostic Flowchart", + caption: "Flowchart to help diagnose which pivot type you need", + }, + "growth-channels-matrix": { + alt: "Growth Channels Matrix", + caption: "Matrix of growth channels and their characteristics", + }, + "layer-documentation-card": { + alt: "Layer Documentation Card", + caption: "Template for documenting each layer of your pyramid", + }, + "multi-track-trap": { + alt: "Multi-Track Trap Warning", + caption: "The dangers of pursuing multiple pivots simultaneously", + }, + "over-under-pivot": { + alt: "Over-Pivot vs Under-Pivot", + caption: "Understanding the spectrum between over-pivoting and under-pivoting", + }, + "pivot-cost-curve": { + alt: "Pivot Cost Curve", + caption: "How pivot costs change as you move down the pyramid", + }, + "pivot-planning-canvas": { + alt: "Pivot Planning Canvas", + caption: "Canvas for planning and executing your pivot strategy", + }, + "pivot-pyramid-foundation": { + alt: "The Pivot Pyramid Foundation", + caption: "The five layers of the Pivot Pyramid framework", + }, + "pyramid-audit-template": { + alt: "Pyramid Audit Template", + caption: "Template for auditing your current pyramid state", + }, + "solution-pivot-playbook": { + alt: "Solution Pivot Playbook", + caption: "Step-by-step guide for executing a solution pivot", + }, + "toolkit-overview": { + alt: "Pivot Pyramid Toolkit Overview", + caption: "Overview of all tools in the Pivot Pyramid toolkit", + }, + // Test figure (skip this one) + "test-pyramid-diagram": { + skip: true, + }, +}; + +async function getOrCreateDraft(draftName) { + // Check for existing drafts + const drafts = await client.query("ebook/queries:listDrafts", {}); + + // Find existing draft with this name + const existingDraft = drafts.find((d) => d.name === draftName); + if (existingDraft) { + console.log(`Found existing draft: "${draftName}" (${existingDraft._id})`); + return existingDraft._id; + } + + // Create new draft + console.log(`Creating new draft: "${draftName}"`); + const draftId = await client.mutation("ebook/mutations:createDraft", { + name: draftName, + description: "Main ebook content imported from markdown", + isPublished: true, + }); + console.log(`Created draft: ${draftId}`); + return draftId; +} + +async function uploadFigure(draftId, figureId, filePath, metadata) { + console.log(` Uploading ${figureId}...`); + + // Read file as base64 + const fileBuffer = fs.readFileSync(filePath); + const base64Data = fileBuffer.toString("base64"); + + // Upload to Convex + const result = await client.action("ebook/actions:uploadFigureFromBytes", { + draftId, + figureId, + base64Data, + mimeType: "image/png", + alt: metadata.alt, + caption: metadata.caption, + }); + + if (result.success) { + console.log(` āœ“ Uploaded ${figureId} (storageId: ${result.storageId})`); + return result; + } else { + console.error(` āœ— Failed to upload ${figureId}: ${result.error}`); + return result; + } +} + +async function main() { + // Parse args + const args = process.argv.slice(2); + let draftName = "main"; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--draft" && args[i + 1]) { + draftName = args[i + 1]; + i++; + } + } + + console.log("\n=== Ebook Figure Migration ===\n"); + console.log(`Convex URL: ${CONVEX_URL}`); + console.log(`Draft name: ${draftName}`); + + // Get or create draft + const draftId = await getOrCreateDraft(draftName); + + // Check existing figures + const existingFigures = await client.query("ebook/queries:getFigures", { + draftId, + }); + const existingFigureIds = new Set(existingFigures.map((f) => f.figureId)); + console.log(`\nExisting figures in draft: ${existingFigures.length}`); + + // Find figure files + const figuresDir = path.join(__dirname, "..", "ebook", "figures", "optimized"); + + if (!fs.existsSync(figuresDir)) { + console.error(`Figures directory not found: ${figuresDir}`); + process.exit(1); + } + + const files = fs.readdirSync(figuresDir).filter((f) => f.endsWith(".png")); + console.log(`Found ${files.length} PNG files in ${figuresDir}\n`); + + // Upload each figure + let uploaded = 0; + let skipped = 0; + let errors = 0; + + for (const file of files) { + const figureId = file.replace(".png", ""); + const metadata = FIGURE_METADATA[figureId]; + + // Skip test figures + if (metadata?.skip) { + console.log(`Skipping test figure: ${figureId}`); + skipped++; + continue; + } + + // Skip if already exists + if (existingFigureIds.has(figureId)) { + console.log(`Skipping existing figure: ${figureId}`); + skipped++; + continue; + } + + // Get metadata (or use defaults) + const meta = metadata || { + alt: figureId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), + caption: undefined, + }; + + const filePath = path.join(figuresDir, file); + const result = await uploadFigure(draftId, figureId, filePath, meta); + + if (result.success) { + uploaded++; + } else { + errors++; + } + } + + console.log("\n=== Migration Complete ===\n"); + console.log(`Uploaded: ${uploaded}`); + console.log(`Skipped: ${skipped}`); + console.log(`Errors: ${errors}`); + + if (errors > 0) { + process.exit(1); + } +} + +main().catch((error) => { + console.error("Migration failed:", error); + process.exit(1); +}); diff --git a/src/app/ebook/[slug]/edit/page.tsx b/src/app/ebook/[slug]/edit/page.tsx new file mode 100644 index 0000000..af1a98e --- /dev/null +++ b/src/app/ebook/[slug]/edit/page.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { useQuery, useConvexAuth } from 'convex/react'; +import { useAuth } from '@clerk/nextjs'; +import { api } from '../../../../../convex/_generated/api'; +import { ChapterEditor } from '@/components/ebook/editor/ChapterEditor'; +import { Loader2, Lock, ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { motion } from 'framer-motion'; + +export default function EditChapterPage() { + const params = useParams(); + const router = useRouter(); + const slug = params.slug as string; + + // Auth states + const { isSignedIn, isLoaded: clerkLoaded } = useAuth(); + const { isAuthenticated: convexAuthenticated } = useConvexAuth(); + + // Get the published draft + const publishedDraft = useQuery(api.ebook.queries.getPublishedDraft, {}); + + // Check if user can edit (only query when we have a draft) + const canEdit = useQuery( + api.ebook.queries.canEditPublishedDraft, + publishedDraft ? {} : 'skip' + ); + + // Get chapter with blocks for editing + const chapterData = useQuery( + api.ebook.queries.getChapterWithBlocks, + publishedDraft ? { draftId: publishedDraft._id, slug } : 'skip' + ); + + // Loading state - waiting for auth and data + const isLoading = + !clerkLoaded || + publishedDraft === undefined || + canEdit === undefined || + chapterData === undefined; + + // Not signed in + if (clerkLoaded && !isSignedIn) { + return ( +
+ +
+ +
+

+ Sign In Required +

+

+ You need to be signed in to edit this chapter. +

+
+ + Sign In + + + Back to Reading + +
+
+
+ ); + } + + // Loading - show skeleton that matches the editor layout + if (isLoading) { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Content skeleton */} +
+ + {/* Part label skeleton */} +
+ + {/* Title skeleton */} +
+ + {/* Paragraph skeletons */} +
+
+
+
+
+ +
+
+
+
+
+ + {/* Heading skeleton */} +
+ + {/* More paragraph skeletons */} +
+
+
+
+
+ +
+
+ ); + } + + // No draft found + if (!publishedDraft) { + return ( +
+ +

+ No Ebook Found +

+

+ There is no published ebook to edit. +

+ + + Back to Ebook + +
+
+ ); + } + + // No permission to edit + if (!canEdit) { + return ( +
+ +
+ +
+

+ Access Denied +

+

+ You don't have permission to edit this ebook. Only admins and + the ebook owner can make changes. +

+ + + Back to Reading + +
+
+ ); + } + + // Chapter not found + if (!chapterData) { + return ( +
+ +

+ Chapter Not Found +

+

+ The chapter "{slug}" could not be found. +

+ + + Back to Ebook + +
+
+ ); + } + + // Render the editor + return ( + + ); +} diff --git a/src/app/ebook/[slug]/page.tsx b/src/app/ebook/[slug]/page.tsx index 6535648..b1e168a 100644 --- a/src/app/ebook/[slug]/page.tsx +++ b/src/app/ebook/[slug]/page.tsx @@ -1,6 +1,10 @@ import { notFound } from 'next/navigation'; -import { getChapterBySlug, getAdjacentChapters, getAllChapterSlugs } from '@/lib/ebook-parser'; -import { MarkdownRenderer, ChapterNav } from '@/components/ebook'; +import { + getChapterBySlug, + getAdjacentChapters, + getAllChapterSlugs, +} from '@/lib/ebook-convex'; +import { MarkdownRenderer, ChapterNav, EditButton } from '@/components/ebook'; import { EbookAccessGate } from '@/components/ebook/EbookAccessGate'; import type { Metadata } from 'next'; @@ -9,13 +13,13 @@ interface ChapterPageProps { } export async function generateStaticParams() { - const slugs = getAllChapterSlugs(); + const slugs = await getAllChapterSlugs(); return slugs.map((slug) => ({ slug })); } export async function generateMetadata({ params }: ChapterPageProps): Promise { const { slug } = await params; - const chapter = getChapterBySlug(slug); + const chapter = await getChapterBySlug(slug); if (!chapter) { return { @@ -123,13 +127,13 @@ function getBreadcrumbJsonLd(slug: string, title: string) { export default async function ChapterPage({ params }: ChapterPageProps) { const { slug } = await params; - const chapter = getChapterBySlug(slug); + const chapter = await getChapterBySlug(slug); if (!chapter) { notFound(); } - const { previous, next } = getAdjacentChapters(slug); + const { previous, next } = await getAdjacentChapters(slug); const title = chapter.type === 'chapter' && chapter.chapterNumber ? `Chapter ${chapter.chapterNumber}: ${chapter.title}` @@ -165,6 +169,9 @@ export default async function ChapterPage({ params }: ChapterPageProps) { {/* Navigation */} + + {/* Edit button for authorized users */} + ); diff --git a/src/app/ebook/layout.tsx b/src/app/ebook/layout.tsx index 693323f..e042f4b 100644 --- a/src/app/ebook/layout.tsx +++ b/src/app/ebook/layout.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react'; import { Merriweather, Inter } from 'next/font/google'; -import { getGroupedTableOfContents } from '@/lib/ebook-parser'; +import { getGroupedTableOfContents } from '@/lib/ebook-convex'; import { EbookSidebar, ReadingProgress, PasswordUrlChecker } from '@/components/ebook'; import type { Metadata } from 'next'; @@ -23,12 +23,12 @@ export const metadata: Metadata = { description: 'Read The Pivot Pyramid ebook online. A comprehensive guide to startup experimentation by SelƧuk Atlı.', }; -export default function EbookLayout({ +export default async function EbookLayout({ children, }: { children: React.ReactNode; }) { - const groups = getGroupedTableOfContents(); + const groups = await getGroupedTableOfContents(); return (
diff --git a/src/app/ebook/page.tsx b/src/app/ebook/page.tsx index bed6224..0af61af 100644 --- a/src/app/ebook/page.tsx +++ b/src/app/ebook/page.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { BookOpen, ArrowRight, FileText, ImageIcon } from 'lucide-react'; -import { getGroupedTableOfContents, getTableOfContents } from '@/lib/ebook-parser'; +import { getGroupedTableOfContents, getTableOfContents } from '@/lib/ebook-convex'; import { BookCoverVideo } from '@/components/ebook/BookCoverVideo'; import { EbookCTAButtons } from '@/components/ebook/EbookCTAButtons'; import type { Metadata } from 'next'; @@ -36,8 +36,8 @@ export const metadata: Metadata = { }; // Book structured data for Google -function getBookJsonLd() { - const toc = getTableOfContents(); +async function getBookJsonLd() { + const toc = await getTableOfContents(); const chapters = toc.filter(item => item.type === 'chapter'); return { @@ -106,9 +106,9 @@ const breadcrumbJsonLd = { ], }; -export default function EbookLandingPage() { - const groups = getGroupedTableOfContents(); - const bookJsonLd = getBookJsonLd(); +export default async function EbookLandingPage() { + const groups = await getGroupedTableOfContents(); + const bookJsonLd = await getBookJsonLd(); // Get first chapter for the CTA const firstChapter = groups[0]?.items[0]; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 264fe2d..3293f4b 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -252,6 +252,9 @@ export default function LoginPage() {
+ {/* Clerk CAPTCHA widget container */} +
+ + + ); + } + + // Signed in but can't edit (or still checking permissions) + if (!convexAuthenticated || !canEdit) { + return null; + } + + // Can edit - show edit button + return ( + + + Edit + + ); +} diff --git a/src/components/ebook/MarkdownRenderer.tsx b/src/components/ebook/MarkdownRenderer.tsx index c85ea9a..4ef55f6 100644 --- a/src/components/ebook/MarkdownRenderer.tsx +++ b/src/components/ebook/MarkdownRenderer.tsx @@ -11,22 +11,23 @@ interface MarkdownRendererProps { content: string; } -// Helper to check if a paragraph node contains only an image -function isImageOnlyParagraph(node: Element | undefined): boolean { +// Helper to check if a paragraph node contains an image (even with other content) +function containsImage(node: Element | undefined): boolean { if (!node || !node.children) return false; - // Filter out text nodes that are just whitespace - const meaningfulChildren = node.children.filter(child => { - if (child.type === 'text') { - return child.value.trim() !== ''; + // Check if any child is an image (recursively check all children) + function hasImage(children: typeof node.children): boolean { + for (const child of children) { + if (child.type === 'element') { + const el = child as Element; + if (el.tagName === 'img') return true; + if (el.children && hasImage(el.children as typeof node.children)) return true; + } } - return true; - }); + return false; + } - // Check if there's exactly one child and it's an image - return meaningfulChildren.length === 1 && - meaningfulChildren[0].type === 'element' && - (meaningfulChildren[0] as Element).tagName === 'img'; + return hasImage(node.children); } export function MarkdownRenderer({ content }: MarkdownRendererProps) { @@ -67,12 +68,12 @@ export function MarkdownRenderer({ content }: MarkdownRendererProps) { {children} ), - // Style paragraphs - unwrap if only contains an image to avoid hydration error + // Style paragraphs - unwrap if contains an image to avoid hydration error + // (
with
cannot be inside

) p: ({ children, node }) => { - // If the paragraph only contains an image, return the image directly - // This avoids the hydration error of

inside

- if (isImageOnlyParagraph(node)) { - return <>{children}; + // If the paragraph contains any image, render as div to avoid hydration error + if (containsImage(node)) { + return

{children}
; } return (

diff --git a/src/components/ebook/TableOfContents.tsx b/src/components/ebook/TableOfContents.tsx index a9653f3..2a79430 100644 --- a/src/components/ebook/TableOfContents.tsx +++ b/src/components/ebook/TableOfContents.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { ChevronRight, BookOpen, Home } from 'lucide-react'; -import type { TableOfContentsItem } from '@/lib/ebook-parser'; +import type { TableOfContentsItem } from '@/lib/ebook-convex'; interface TableOfContentsProps { groups: { part: string | null; items: TableOfContentsItem[] }[]; @@ -12,9 +12,17 @@ interface TableOfContentsProps { export function TableOfContents({ groups, onItemClick }: TableOfContentsProps) { const pathname = usePathname(); - const currentSlug = pathname.split('/').pop(); + const isEditMode = pathname.endsWith('/edit'); + // Extract current slug - if in edit mode, get the slug before '/edit' + const pathParts = pathname.split('/'); + const currentSlug = isEditMode ? pathParts[pathParts.length - 2] : pathParts[pathParts.length - 1]; const isWelcomePage = pathname === '/ebook'; + // Generate href based on edit mode + const getHref = (slug: string) => { + return isEditMode ? `/ebook/${slug}/edit` : `/ebook/${slug}`; + }; + return (