diff --git a/apps/api/src/routes/presets.ts b/apps/api/src/routes/presets.ts index d0503096..ca99a6bf 100644 --- a/apps/api/src/routes/presets.ts +++ b/apps/api/src/routes/presets.ts @@ -3,30 +3,10 @@ import path from "node:path" import { Hono } from "hono" import { HTTPException } from "hono/http-exception" import yaml from "js-yaml" -import { PresetName, StyleguideName } from "@adt/types" +import { StyleguideName } from "@adt/types" export function createPresetRoutes(configPath: string): Hono { const app = new Hono() - const presetsDir = path.join(path.dirname(configPath), "config", "presets") - - // GET /presets/:name — Return preset config overrides - app.get("/presets/:name", (c) => { - const result = PresetName.safeParse(c.req.param("name")) - - if (!result.success) { - throw new HTTPException(404, { message: `Unknown preset: ${c.req.param("name")}` }) - } - - const name = result.data - const presetPath = path.join(presetsDir, `${name}.yaml`) - if (!fs.existsSync(presetPath)) { - throw new HTTPException(404, { message: `Preset not found: ${name}` }) - } - - const content = fs.readFileSync(presetPath, "utf-8") - const parsed = yaml.load(content) as Record - return c.json({ config: parsed }) - }) // GET /styleguides — List available styleguide names app.get("/styleguides", (c) => { @@ -88,6 +68,28 @@ table{border-collapse:collapse;width:100%;margin:0.75rem 0;}th,td{border:1px sol return c.json({ name, html }) }) + // POST /styleguides/upload — Upload a styleguide .md file + app.post("/styleguides/upload", async (c) => { + const body = await c.req.parseBody() + const file = body["file"] + if (!(file instanceof File)) { + throw new HTTPException(400, { message: "Missing file" }) + } + if (!file.name.endsWith(".md")) { + throw new HTTPException(400, { message: "Only .md files are accepted" }) + } + const name = file.name.replace(/\.md$/, "") + const result = StyleguideName.safeParse(name) + if (!result.success) { + throw new HTTPException(400, { message: "Invalid styleguide name" }) + } + const styleguidesDir = path.join(path.dirname(configPath), "assets", "styleguides") + fs.mkdirSync(styleguidesDir, { recursive: true }) + const content = await file.text() + fs.writeFileSync(path.join(styleguidesDir, `${result.data}.md`), content, "utf-8") + return c.json({ name: result.data }) + }) + // GET /config — Return the global base config app.get("/config", (c) => { if (!fs.existsSync(configPath)) { diff --git a/apps/studio/eslint-suppressions.json b/apps/studio/eslint-suppressions.json index 9453c016..024a10cc 100644 --- a/apps/studio/eslint-suppressions.json +++ b/apps/studio/eslint-suppressions.json @@ -9,11 +9,6 @@ "count": 5 } }, - "src/components/config/AdvancedLayoutPanel.tsx": { - "lingui/no-unlocalized-strings": { - "count": 33 - } - }, "src/components/debug/ConfigTab.tsx": { "lingui/no-unlocalized-strings": { "count": 16 @@ -84,11 +79,6 @@ "count": 2 } }, - "src/routes/books.new.tsx": { - "lingui/no-unlocalized-strings": { - "count": 49 - } - }, "src/vite-env.d.ts": { "lingui/no-unlocalized-strings": { "count": 1 diff --git a/apps/studio/eslint.config.js b/apps/studio/eslint.config.js index 363d5ca3..f5bb091d 100644 --- a/apps/studio/eslint.config.js +++ b/apps/studio/eslint.config.js @@ -16,7 +16,8 @@ export default [ { ignoreNames: [ // --- HTML / JSX structural attributes --- - "className", + // Any prop/identifier containing "className" (e.g. className, bodyClassName, headerClassName) + { regex: { pattern: "className", flags: "i" } }, "style", "id", "type", @@ -69,6 +70,11 @@ export default [ // --- Form / input props (non-visible values) --- "defaultValue", "value", + // Form field name constants (e.g. const RADIO_NAME = "renderStrategy") + "RADIO_NAME", + "radioName", + // Image processing preview pane focus key (ImageProcessingPreviewFocus — not user-visible) + "previewFocus", // --- CSS class & color props (never user-visible) --- "rootMargin", @@ -80,6 +86,11 @@ export default [ "borderDark", "iconColor", "colorClass", + "bgColor", + "imageSrc", + "pdfUrl", + "adtUrl", + "comingSoon", { regex: { pattern: "Class$" } }, // --- Data labels (technical CSS descriptions, pipeline labels — not user-facing prose) --- @@ -94,6 +105,7 @@ export default [ "direction", "fromStage", "toStage", + "category", // --- CSS utility class variable names (StageSidebar layout constants) --- "gap", @@ -177,6 +189,9 @@ export default [ // --- TanStack Router route definitions --- "createFileRoute", + // --- TanStack Form (field paths are identifiers, not UI copy) --- + "*.setFieldValue", + // --- CSS class composition utilities --- "cn", "cva", @@ -186,6 +201,8 @@ export default [ "*.toFixed", ], ignore: [ + // project brand name (intentional non-translatable literal) + "^ADT Studio$", // npm package names and module paths (e.g. "@tanstack/react-router") "^@?[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)+", // TypeScript import() / type strings using path aliases (e.g. "@/api/client") @@ -211,6 +228,8 @@ export default [ "^ADT Studio$", // Data URIs (e.g. "data:image/png;base64,...") "^data:", + // Byte-size literals (e.g. "12 KB", "1.5MB", "2 gb", "10tb") + "^[0-9]+(?:\\.[0-9]+)?\\s?(?:B|KB|MB|GB|TB|b|kb|mb|gb|tb)$", // HTML fragments used in innerHTML assignments (e.g. `
`) "^<[a-z]", // Closing HTML tags used in string operations (e.g. "") diff --git a/apps/studio/package.json b/apps/studio/package.json index 7cb451de..4d1a40bb 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -17,12 +17,19 @@ "@lingui/conf": "^5.9.3", "@lingui/core": "^5.9.3", "@lingui/react": "^5.9.3", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-form": "^1.28.5", "@tanstack/react-query": "^5.0.0", "@tanstack/react-router": "^1.0.0", "@tanstack/react-virtual": "^3.13.23", @@ -31,7 +38,9 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "dompurify": "^3.2.7", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.469.0", + "pdfjs-dist": "^5.5.207", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^3.0.0", diff --git a/apps/studio/public/previews/two-column-story.png b/apps/studio/public/previews/two-column-story.png new file mode 100644 index 00000000..f9d2c17f Binary files /dev/null and b/apps/studio/public/previews/two-column-story.png differ diff --git a/apps/studio/src/api/client.ts b/apps/studio/src/api/client.ts index 248b4682..06f890a8 100644 --- a/apps/studio/src/api/client.ts +++ b/apps/studio/src/api/client.ts @@ -940,15 +940,21 @@ export const api = { getTemplates: () => request<{ templates: string[] }>(`/templates`), - getPreset: (name: string) => - request<{ config: Record }>(`/presets/${name}`), - getStyleguides: () => request<{ styleguides: string[] }>(`/styleguides`), getStyleguidePreview: (name: string) => request<{ name: string; html: string }>(`/styleguides/${name}/preview`), + uploadStyleguide: (file: File) => { + const form = new FormData() + form.append("file", file) + return request<{ name: string }>(`/styleguides/upload`, { + method: "POST", + body: form, + }) + }, + generateStyleguide: (label: string, pageIds: string[], apiKey: string, signal?: AbortSignal) => request<{ name: string; content: string; reasoning: string }>( `/books/${label}/generate-styleguide`, diff --git a/apps/studio/src/components/LanguagePicker.tsx b/apps/studio/src/components/LanguagePicker.tsx index b415ec5d..5e1048bd 100644 --- a/apps/studio/src/components/LanguagePicker.tsx +++ b/apps/studio/src/components/LanguagePicker.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react" import { Check, Languages, X } from "lucide-react" +import { cn } from "@/lib/utils" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Badge } from "@/components/ui/badge" @@ -25,12 +26,14 @@ export function LanguagePicker({ multiple, label, hint, + size = "sm", }: { selected: string | Set onSelect: (code: string) => void multiple?: boolean label: string hint?: string + size?: "sm" | "default" }) { const { t } = useLingui() const [search, setSearch] = useState("") @@ -250,9 +253,9 @@ export function LanguagePicker({
{(label || hint) && (
- {label && } + {label && } {hint && ( -

{hint}

+

{hint}

)}
)} @@ -280,7 +283,7 @@ export function LanguagePicker({ )}
- + {/* Locked language chip shown inside the input area */} {open && lockedLang && ( @@ -297,7 +300,11 @@ export function LanguagePicker({ onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} placeholder={placeholderText()} - className={`h-8 text-xs ${lockedLang && open ? "pl-[calc(var(--chip-offset,5rem)+0.75rem)]" : "pl-8"} ${!multiple && selectedCode ? "pr-16" : ""}`} + className={cn( + size === "default" ? "h-10 text-sm" : "h-8 text-xs", + lockedLang && open ? "pl-[calc(var(--chip-offset,5rem)+0.75rem)]" : size === "default" ? "pl-10" : "pl-8", + !multiple && selectedCode && "pr-16", + )} style={ lockedLang && open ? { paddingLeft: `calc(${lockedLang.name.length * 0.55}rem + 2.5rem)` } @@ -332,13 +339,15 @@ export function LanguagePicker({ key={item.code} type="button" onClick={() => handleItemClick(item)} - className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none transition-colors ${ + className={cn( + "flex w-full items-center gap-2 rounded-sm outline-none transition-colors", + size === "default" ? "px-2.5 py-2 text-sm" : "px-2 py-1.5 text-xs", active ? "bg-primary/10 text-primary font-medium" : i === highlighted ? "bg-accent text-accent-foreground" - : "hover:bg-accent hover:text-accent-foreground" - }`} + : "hover:bg-accent hover:text-accent-foreground", + )} > {active && } diff --git a/apps/studio/src/components/StudioTopBar.tsx b/apps/studio/src/components/StudioTopBar.tsx new file mode 100644 index 00000000..943daff7 --- /dev/null +++ b/apps/studio/src/components/StudioTopBar.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react" +import { Link } from "@tanstack/react-router" +import { Home, Settings } from "lucide-react" +import { useLingui } from "@lingui/react/macro" +import { Button } from "@/components/ui/button" +import { LocaleSwitcher } from "@/components/LocaleSwitcher" +import { useSettingsDialog } from "@/routes/__root" + +export type StudioTopBarProps = { + /** When true, the brand row links to `/` with hover styles (e.g. add-book flow). */ + brandLinksHome?: boolean + /** Optional title after `/` (e.g. translated “Add Book”). */ + trailingTitle?: ReactNode +} + +export function StudioTopBar({ brandLinksHome = false, trailingTitle }: StudioTopBarProps) { + const { t } = useLingui() + const { openSettings } = useSettingsDialog() + + const brandInner = ( + <> + + ADT Studio + + ) + + const brandRow = brandLinksHome ? ( + + {brandInner} + + ) : ( +
{brandInner}
+ ) + + return ( +
+
+ {brandRow} + {trailingTitle != null && ( + <> + / + {trailingTitle} + + )} +
+
+ + +
+
+ ) +} diff --git a/apps/studio/src/components/config/AdvancedLayoutPanel.tsx b/apps/studio/src/components/config/AdvancedLayoutPanel.tsx deleted file mode 100644 index 77f21c36..00000000 --- a/apps/studio/src/components/config/AdvancedLayoutPanel.tsx +++ /dev/null @@ -1,605 +0,0 @@ -import { useState, useEffect } from "react" -import { Plus, Trash2, ChevronDown } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Badge } from "@/components/ui/badge" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { RENDER_TYPES } from "@/lib/config-constants" -import { - listSelectableRenderStrategies, - normalizeDefaultRenderStrategy, -} from "@/lib/render-strategy" - -export interface RenderStrategyState { - render_type: string - config: { - prompt?: string - model?: string - max_retries?: string - timeout?: string - temperature?: string - answer_prompt?: string - template?: string - } -} - -export interface AdvancedLayoutPanelProps { - defaultRenderStrategy: string - onDefaultRenderStrategyChange: (value: string) => void - sectioningMode: string - onSectioningModeChange: (value: string) => void - renderStrategies: Record - onRenderStrategiesChange: (strategies: Record) => void - textTypes: Record - onTextTypesChange: (types: Record) => void - textGroupTypes: Record - onTextGroupTypesChange: (types: Record) => void - sectionTypes: Record - onSectionTypesChange: (types: Record) => void - prunedTextTypes: Set - onTogglePrunedText: (type: string) => void - prunedSectionTypes: Set - onTogglePrunedSection: (type: string) => void - /** Rendered right after the render strategy dropdown (e.g. styleguide selector) */ - afterStrategySlot?: React.ReactNode -} - -function CollapsibleSection({ - title, - children, -}: { - title: string - children: React.ReactNode -}) { - const [open, setOpen] = useState(false) - return ( -
- - {open &&
{children}
} -
- ) -} - -function KeyValueRow({ - entryKey, - description, - onKeyChange, - onDescriptionChange, - onRemove, -}: { - entryKey: string - description: string - onKeyChange: (newKey: string) => void - onDescriptionChange: (desc: string) => void - onRemove?: () => void -}) { - const [localKey, setLocalKey] = useState(entryKey) - - useEffect(() => { - setLocalKey(entryKey) - }, [entryKey]) - - return ( -
- setLocalKey(e.target.value)} - onBlur={() => { - const trimmed = localKey.trim() - if (trimmed && trimmed !== entryKey) { - onKeyChange(trimmed) - } - setLocalKey(entryKey) - }} - placeholder="type_name" - className="h-7 text-xs w-40 font-mono" - /> - onDescriptionChange(e.target.value)} - placeholder="Description" - className="h-7 text-xs flex-1" - /> - {onRemove && ( - - )} -
- ) -} - -function KeyValueEditor({ - entries, - onChange, - canRemove = false, - addLabel = "Add", -}: { - entries: Record - onChange: (entries: Record) => void - canRemove?: boolean - addLabel?: string -}) { - const pairs = Object.entries(entries) - - const handleKeyChange = (oldKey: string, newKey: string) => { - if (newKey in entries) return - const result: Record = {} - for (const [k, v] of pairs) { - result[k === oldKey ? newKey : k] = v - } - onChange(result) - } - - const handleDescChange = (key: string, desc: string) => { - onChange({ ...entries, [key]: desc }) - } - - const addEntry = () => { - let name = "new_type" - let i = 1 - while (name in entries) name = `new_type_${i++}` - onChange({ ...entries, [name]: "" }) - } - - const removeEntry = (key: string) => { - const next = { ...entries } - delete next[key] - onChange(next) - } - - return ( -
- {pairs.map(([key, desc]) => ( - handleKeyChange(key, newKey)} - onDescriptionChange={(newDesc) => handleDescChange(key, newDesc)} - onRemove={canRemove ? () => removeEntry(key) : undefined} - /> - ))} - -
- ) -} - -function RenderStrategyEditor({ - name, - strategy, - onNameChange, - onChange, - onRemove, -}: { - name: string - strategy: RenderStrategyState - onNameChange: (newName: string) => void - onChange: (updated: RenderStrategyState) => void - onRemove: () => void -}) { - const [expanded, setExpanded] = useState(false) - const [localName, setLocalName] = useState(name) - - useEffect(() => { - setLocalName(name) - }, [name]) - - const updateConfig = (field: string, value: string) => { - onChange({ - ...strategy, - config: { ...strategy.config, [field]: value || undefined }, - }) - } - - const showPromptFields = strategy.render_type === "llm" || strategy.render_type === "activity" - const showTemplateField = strategy.render_type === "template" - const showAnswerPrompt = strategy.render_type === "activity" - - return ( -
-
- - setLocalName(e.target.value)} - onBlur={() => { - const trimmed = localName.trim() - if (trimmed && trimmed !== name) { - onNameChange(trimmed) - } - setLocalName(name) - }} - placeholder="strategy name" - className="h-7 text-xs font-medium flex-1" - /> - - -
- - {expanded && ( -
- {showPromptFields && ( - <> -
-
- - updateConfig("prompt", e.target.value)} - placeholder="e.g., web_generation_html" - className="h-7 text-xs" - /> -
-
- - updateConfig("model", e.target.value)} - placeholder="e.g., openai:gpt-5.4" - className="h-7 text-xs" - /> -
-
-
-
- - updateConfig("max_retries", e.target.value)} - placeholder="25" - className="h-7 text-xs" - /> -
-
- - updateConfig("timeout", e.target.value)} - placeholder="180" - className="h-7 text-xs" - /> -
-
- - updateConfig("temperature", e.target.value)} - placeholder="0.3" - className="h-7 text-xs" - /> -
-
- {showAnswerPrompt && ( -
- - updateConfig("answer_prompt", e.target.value)} - placeholder="e.g., activity_multiple_choice_answers" - className="h-7 text-xs" - /> -
- )} - - )} - {showTemplateField && ( -
- - updateConfig("template", e.target.value)} - placeholder="e.g., two_column_render" - className="h-7 text-xs" - /> -
- )} -
- )} -
- ) -} - -export function AdvancedLayoutPanel({ - defaultRenderStrategy, - onDefaultRenderStrategyChange, - sectioningMode, - onSectioningModeChange, - renderStrategies, - onRenderStrategiesChange, - textTypes, - onTextTypesChange, - textGroupTypes, - onTextGroupTypesChange, - sectionTypes, - onSectionTypesChange, - prunedTextTypes, - onTogglePrunedText, - prunedSectionTypes, - onTogglePrunedSection, - afterStrategySlot, -}: AdvancedLayoutPanelProps) { - const dropdownOptions = listSelectableRenderStrategies(renderStrategies) - - const addRenderStrategy = () => { - const base = "new_strategy" - let name = base - let i = 1 - while (name in renderStrategies) { - name = `${base}_${i++}` - } - onRenderStrategiesChange({ - ...renderStrategies, - [name]: { render_type: "llm", config: {} }, - }) - } - - const removeRenderStrategy = (name: string) => { - const next = { ...renderStrategies } - delete next[name] - onRenderStrategiesChange(next) - if (defaultRenderStrategy === name) { - onDefaultRenderStrategyChange(normalizeDefaultRenderStrategy("", next)) - } - } - - const renameRenderStrategy = (oldName: string, newName: string) => { - if (!newName || newName === oldName) return - if (newName in renderStrategies) return - const entries = Object.entries(renderStrategies).map(([k, v]) => - k === oldName ? [newName, v] : [k, v] - ) - onRenderStrategiesChange(Object.fromEntries(entries)) - if (defaultRenderStrategy === oldName) { - onDefaultRenderStrategyChange(newName) - } - } - - const updateRenderStrategy = (name: string, updated: RenderStrategyState) => { - onRenderStrategiesChange({ ...renderStrategies, [name]: updated }) - } - - const allTextTypeKeys = Object.keys(textTypes) - const allSectionTypeKeys = Object.keys(sectionTypes) - - return ( -
- {/* 1. Render Strategy — dropdown */} -
-

- Render Strategy -

- -

- Fallback strategy for sections without a specific mapping -

-
- - {afterStrategySlot} - - {/* Sectioning Mode */} -
-

- Sectioning Mode -

- -

- {sectioningMode === "page" - ? "Each page is treated as a single section" - : sectioningMode === "dynamic" - ? "Keeps pages whole unless mixed activity types require splitting" - : "Content is grouped into logical sections per page"} -

-
- - {/* 2. Available Render Strategies — collapsed */} - -
- {Object.entries(renderStrategies).map(([name, strategy]) => ( - renameRenderStrategy(name, newName)} - onChange={(updated) => updateRenderStrategy(name, updated)} - onRemove={() => removeRenderStrategy(name)} - /> - ))} -
- -
- - {/* 3. Text Types — collapsed */} - - - - - {/* 4. Text Groups — collapsed */} - - - - - {/* 5. Section Types — collapsed */} - - - - - {/* 6. Text Filters — collapsed */} - -

- Active (filled) types are excluded from rendering -

-
- {allTextTypeKeys.map((t) => ( - onTogglePrunedText(t)} - > - {t.replace(/_/g, " ")} - - ))} - {allTextTypeKeys.length === 0 && ( -

- No text types defined -

- )} -
-
- - {/* 7. Section Filters — collapsed */} - -

- Active (filled) sections are excluded from rendering -

-
- {allSectionTypeKeys.map((t) => ( - onTogglePrunedSection(t)} - > - {t.replace(/_/g, " ")} - - ))} - {allSectionTypeKeys.length === 0 && ( -

- No section types defined -

- )} -
-
-
- ) -} diff --git a/apps/studio/src/components/ui/alert-dialog.tsx b/apps/studio/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..8722561c --- /dev/null +++ b/apps/studio/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/apps/studio/src/components/ui/button.tsx b/apps/studio/src/components/ui/button.tsx index 36496a28..4479646d 100644 --- a/apps/studio/src/components/ui/button.tsx +++ b/apps/studio/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { diff --git a/apps/studio/src/components/ui/carousel.tsx b/apps/studio/src/components/ui/carousel.tsx new file mode 100644 index 00000000..94c8a849 --- /dev/null +++ b/apps/studio/src/components/ui/carousel.tsx @@ -0,0 +1,272 @@ +import * as React from "react" +import { msg } from "@lingui/core/macro" +import { useLingui } from "@lingui/react/macro" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +const CAROUSEL_ROLE_DESCRIPTION = msg`carousel` +const CAROUSEL_SLIDE_ROLE_DESCRIPTION = msg`slide` +const PREVIOUS_SLIDE_SR_LABEL = msg`Previous slide` +const NEXT_SLIDE_SR_LABEL = msg`Next slide` + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const { i18n } = useLingui() + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + // eslint-disable-next-line lingui/no-unlocalized-strings -- embla event name + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { i18n } = useLingui() + const { orientation } = useCarousel() + + return ( +
+ ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { i18n } = useLingui() + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { i18n } = useLingui() + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/apps/studio/src/components/ui/hover-card.tsx b/apps/studio/src/components/ui/hover-card.tsx new file mode 100644 index 00000000..ec41b0d5 --- /dev/null +++ b/apps/studio/src/components/ui/hover-card.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" +import { cn } from "@/lib/utils" + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +function HoverCardContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/apps/studio/src/components/ui/popover.tsx b/apps/studio/src/components/ui/popover.tsx new file mode 100644 index 00000000..821a32f3 --- /dev/null +++ b/apps/studio/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/apps/studio/src/components/ui/radio-group.tsx b/apps/studio/src/components/ui/radio-group.tsx new file mode 100644 index 00000000..43b43b48 --- /dev/null +++ b/apps/studio/src/components/ui/radio-group.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/apps/studio/src/components/ui/segmented-control.tsx b/apps/studio/src/components/ui/segmented-control.tsx new file mode 100644 index 00000000..c12f601a --- /dev/null +++ b/apps/studio/src/components/ui/segmented-control.tsx @@ -0,0 +1,67 @@ +import { cn } from "@/lib/utils" + +export interface SegmentedControlOption { + value: T + label: string +} + +interface SegmentedControlProps { + options: SegmentedControlOption[] + value: T | "" + onValueChange: (value: T) => void + className?: string + color?: string +} + +export function SegmentedControl({ + options, + value, + onValueChange, + className, + color, +}: SegmentedControlProps) { + const activeIndex = options.findIndex((o) => o.value === value) + const showIndicator = activeIndex >= 0 && options.length > 0 + + return ( +
+ {showIndicator ? ( +
+ ) : null} + {options.map((option) => ( + + ))} +
+ ) +} diff --git a/apps/studio/src/components/ui/slider.tsx b/apps/studio/src/components/ui/slider.tsx new file mode 100644 index 00000000..9d6ac626 --- /dev/null +++ b/apps/studio/src/components/ui/slider.tsx @@ -0,0 +1,38 @@ +import type { CSSProperties } from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +type SliderProps = React.ComponentPropsWithoutRef & { + color?: string +} + +export function Slider({ color, ...props }: SliderProps) { + const isRange = Array.isArray(props.value) && props.value.length === 2 + const accentColor = color ?? "#2b7fff" + + /* eslint-disable-next-line lingui/no-unlocalized-strings -- CSS transition value, not UI copy */ + const thumbStyle: CSSProperties = { borderColor: accentColor, transition: "border-color 0.4s ease" } + + return ( + + + + + + {isRange && ( + + )} + + ) +} diff --git a/apps/studio/src/components/ui/tooltip.tsx b/apps/studio/src/components/ui/tooltip.tsx new file mode 100644 index 00000000..20421fd7 --- /dev/null +++ b/apps/studio/src/components/ui/tooltip.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/apps/studio/src/components/wizard/BookCreationWizard.tsx b/apps/studio/src/components/wizard/BookCreationWizard.tsx new file mode 100644 index 00000000..831f3a14 --- /dev/null +++ b/apps/studio/src/components/wizard/BookCreationWizard.tsx @@ -0,0 +1,382 @@ + +import { useState, useEffect, useRef, type CSSProperties } from "react" +import { Trans, useLingui } from "@lingui/react/macro" +import { Eye, ArrowLeft, ArrowRight, Zap, Loader2 } from "lucide-react" +import { useStore } from "@tanstack/react-form" +import { useNavigate } from "@tanstack/react-router" +import { Button } from "@/components/ui/button" +import { api } from "@/api/client" +import { useApiKey } from "@/hooks/use-api-key" +import { useBooks, useCreateBook } from "@/hooks/use-books" +import { useWizard } from "./index" +import { useWizardForm } from "./wizardForm" +import { STEPS } from "./steps" +import { buildConfigOverrides } from "./bookCreationConfig" +import { getPresetAccent, type PresetAccent } from "./constants" +import { Step0Preset } from "./step0preset" +import { StudioTopBar } from "@/components/StudioTopBar" +import { PdfCoverPreview } from "./shared/PdfCoverPreview" +import { LayoutPreview, getPreviewWidth } from "./step2LayoutOptions/LayoutPreview" +import { ImageProcessingPreviewPane } from "./step3ContentProcessing/ImageProcessingPreviewPane" +import { LanguagesPreviewPane } from "./step4Languages/LanguagesPreviewPane" +import { StyleguidePreviewPane } from "./step5Styleguide/StyleguidePreviewPane" +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog" +import { cn } from "@/lib/utils" + +function WizardHeader({ step, accent }: { step: number; accent: PresetAccent }) { + const { i18n, t } = useLingui() + const def = STEPS[step - 1] + return ( +
+
+ {def.hasRequiredFields ? ( + + {t`Required Fields`} + + ) : ( + + )} + + {t`Step ${step} of ${STEPS.length}`} + +
+
+

+ {i18n._(def.title)} +

+

{i18n._(def.description)}

+
+
+ ) +} +function WizardFooter({ + isLastStep, + canContinue, + canCreate, + isCreating, + onBack, + onNext, + onCreate, + accent, + hint, + onScrollToInvalid, +}: { + isLastStep: boolean + canContinue: boolean + canCreate: boolean + isCreating: boolean + onBack: () => void + onNext: () => void + onCreate: () => void + accent: PresetAccent + hint?: string + onScrollToInvalid?: () => void +}) { + const { t } = useLingui() + const isValid = isLastStep ? canCreate : canContinue + const [attempted, setAttempted] = useState(false) + + useEffect(() => { + if (isValid) setAttempted(false) + }, [isValid]) + + function handleNext() { + if (isValid) { + isLastStep ? onCreate() : onNext() + } else { + setAttempted(true) + onScrollToInvalid?.() + } + } + + return ( +
+
+
+

{hint}

+
+
+
+ + + +
+
+ ) +} + +function previewShellVars(width: number): CSSProperties { + return { + "--preview-w": `${width}px`, + "--preview-ar": `${width} / 812`, + } as CSSProperties +} + +function PreviewContainer({ + children, + width = 650, + variant = "desktop", +}: { + children: React.ReactNode + width?: number + variant?: "desktop" | "dialog" +}) { + if (variant === "dialog") { + return ( +
+ {children} +
+ ) + } + + return ( +
+ {children} +
+ ) +} + +export function BookCreationWizard() { + const { t, i18n } = useLingui() + const navigate = useNavigate() + const { currentStep, setCurrentStep, stepDirection, previewFocus } = useWizard() + const form = useWizardForm() + const createMutation = useCreateBook() + const { data: books, isPending: booksLoading } = useBooks() + const { apiKey, hasApiKey, azureKey, azureRegion, geminiKey } = useApiKey() + const [previewOpen, setPreviewOpen] = useState(false) + const [submitError, setSubmitError] = useState(null) + const [isCreating, setIsCreating] = useState(false) + const creatingRef = useRef(false) + + const values = useStore(form.store, (s) => s.values) + const { file, renderStrategy, editingLanguage, outputLanguages, styleguide } = values + const accent = getPresetAccent(values.selectedPreset) + const stepIndex = currentStep - 1 + const existingBookLabels = booksLoading ? undefined : books?.map((b: { label: string }) => b.label) + const stepValidationContext = { existingBookLabels } + const canContinue = + currentStep >= 1 + ? STEPS[stepIndex].isValid(values, stepValidationContext) + : false + const canCreate = STEPS.every((s) => s.isValid(values, stepValidationContext)) + const stepDef = STEPS[stepIndex] + const hintDescriptor = stepDef?.hint?.(values, stepValidationContext) ?? null + const hint = hintDescriptor ? i18n._(hintDescriptor) : undefined + + function handleScrollToInvalid() { + const fieldId = stepDef?.scrollToFirstInvalid?.(values, stepValidationContext) + if (!fieldId) return + const el = document.getElementById(fieldId) + if (!el) return + el.scrollIntoView({ behavior: "smooth", block: "center" }) + // eslint-disable-next-line lingui/no-unlocalized-strings + const focusable = el.matches("input,button,select,textarea,[tabindex]") + ? el + : el.querySelector("input,button,select,textarea,[tabindex]") + if (focusable) setTimeout(() => focusable.focus({ preventScroll: true }), 300) + } + + if (currentStep === 0) { + return ( +
+ Add Book} /> +
+ +
+
+ ) + } + + const StepComponent = STEPS[stepIndex].component + + function handleBack() { + setCurrentStep(currentStep <= 1 ? 0 : currentStep - 1) + } + + function handleNext() { + if (currentStep < STEPS.length) setCurrentStep(currentStep + 1) + } + + async function handleCreate() { + if (creatingRef.current) return + creatingRef.current = true + setSubmitError(null) + setIsCreating(true) + try { + const book = await createMutation.mutateAsync({ + label: values.label.trim(), + pdf: values.file!, + config: buildConfigOverrides(values), + }) + + if (hasApiKey && apiKey) { + try { + await api.runStages( + book.label, + apiKey, + { fromStage: "extract", toStage: "storyboard" }, + { azure: { key: azureKey, region: azureRegion }, geminiApiKey: geminiKey }, + ) + } catch (pipelineError) { + console.error("[wizard] pipeline kickoff failed:", pipelineError) + } + } + + navigate({ to: "/books/$label/$step", params: { label: book.label, step: "book" } }) + } catch (error) { + creatingRef.current = false + setIsCreating(false) + setSubmitError(error instanceof Error ? error.message : t`Failed to create book.`) + } + } + const previewWidth = currentStep === 2 ? getPreviewWidth(renderStrategy) : 650 + + function renderPreviewContent({mobileMode}: {mobileMode: boolean} = {mobileMode: false}) { + if (currentStep === 1) return + if (currentStep === 2) return + if (currentStep === 3) + return + if (currentStep === 4) + return + if (currentStep === 5) + return + return {t`Book preview`} + } + + const previewDesktop = ( + + {renderPreviewContent()} + + ) + + const previewDialog = ( + + {renderPreviewContent({mobileMode: true})} + + ) + + return ( +
+ Add Book} /> +
+ + +
+ {previewDesktop} +
+
+ + + {t`Book Preview`} + + {t`This is a preview of the options you have selected for your book, each option affects the preview in a different way.`} + +
+ {previewDialog} +
+
+
+ +
+ ) +} diff --git a/apps/studio/src/components/wizard/WizardFormProvider.tsx b/apps/studio/src/components/wizard/WizardFormProvider.tsx new file mode 100644 index 00000000..13250996 --- /dev/null +++ b/apps/studio/src/components/wizard/WizardFormProvider.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react" +import { useAppForm, defaultWizardValues } from "./wizardForm" + +export function WizardFormProvider({ children }: { children: ReactNode }) { + const form = useAppForm({ defaultValues: defaultWizardValues }) + return {children} +} diff --git a/apps/studio/src/components/wizard/bookCreationConfig.ts b/apps/studio/src/components/wizard/bookCreationConfig.ts new file mode 100644 index 00000000..b011bfc8 --- /dev/null +++ b/apps/studio/src/components/wizard/bookCreationConfig.ts @@ -0,0 +1,57 @@ +import type { WizardFormValues } from "./wizardForm" +import { PRESETS } from "./constants" + +function parsePositiveInt(raw: string): number | undefined { + const n = Number(raw.trim()) + return raw.trim() && Number.isInteger(n) && n >= 1 ? n : undefined +} + +export function buildConfigOverrides(values: WizardFormValues): Record { + const parsedStartPage = parsePositiveInt(values.startPage) + const parsedEndPage = parsePositiveInt(values.endPage) + const validPageRange = + parsedStartPage === undefined || parsedEndPage === undefined || parsedStartPage <= parsedEndPage + + const preset = PRESETS.find((p) => p.id === values.selectedPreset) + const baseConfig = preset?.baseConfig ?? {} + const baseImageFilters = (baseConfig.image_filters ?? {}) as Record + + const renderStrategies = (baseConfig.render_strategies ?? {}) as Record + const activityTypeNames = Object.keys(renderStrategies).filter( + (name) => renderStrategies[name].render_type === "activity" + ) + + const config: Record = { + ...baseConfig, + default_render_strategy: values.renderStrategy, + page_sectioning: { mode: values.sectioningMode }, + spread_mode: values.pageGrouping === "spread", + vector_text_grouping: values.figureExtraction, + apply_body_background: true, + ...(!values.activitiesGenerator && activityTypeNames.length > 0 && { + disabled_section_types: activityTypeNames, + }), + image_filters: { + ...baseImageFilters, + min_side: values.imageFilterMinSide, + max_side: values.imageFilterMaxSide, + cropping: values.imageCropping, + segmentation: values.imageSegmentation, + }, + } + + if (values.selectedPreset && values.selectedPreset !== "custom") { + config.layout_type = values.selectedPreset + } + if (values.styleguide.trim()) config.styleguide = values.styleguide.trim() + if (values.editingLanguage.trim()) config.editing_language = values.editingLanguage.trim() + if (values.outputLanguages.length > 0) config.output_languages = values.outputLanguages + if (validPageRange && parsedStartPage !== undefined) config.start_page = parsedStartPage + if (validPageRange && parsedEndPage !== undefined) config.end_page = parsedEndPage + if (values.imageSegmentation && values.segmentationMinSide.trim()) { + const n = Number(values.segmentationMinSide.trim()) + if (Number.isInteger(n) && n >= 0) config.image_segmentation = { min_side: n } + } + + return config +} diff --git a/apps/studio/src/components/wizard/constants.ts b/apps/studio/src/components/wizard/constants.ts new file mode 100644 index 00000000..3cf439a6 --- /dev/null +++ b/apps/studio/src/components/wizard/constants.ts @@ -0,0 +1,574 @@ +import { + Sparkles, + Layers, + BookOpen, + AlignLeft, + SlidersHorizontal, +} from "lucide-react"; +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; +import type { ElementType } from "react"; +import { TwoColumnStoryStrategyIcon } from "@/components/wizard/icons/TwoColumnStoryStrategyIcon"; +import { TextbookWireframePreview } from "@/components/wizard/icons/TextbookWireframePreview"; +import { StorybookWireframePreview } from "@/components/wizard/icons/StorybookWireframePreview"; +import { ReferenceWireframePreview } from "@/components/wizard/icons/ReferenceWireframePreview"; +import type { WizardFormValues } from "./wizardForm"; + +// ─── Option shape ──────────────────────────────────────────────────────────── + +export interface WizardOption { + id: TId; + Icon?: ElementType; + title: MessageDescriptor; + description?: MessageDescriptor; +} + +// ─── Render Strategy categories ────────────────────────────────────────────── + +export type StrategyCategory = "template" | "ai"; + +export interface StrategyCategoryMeta { + label: MessageDescriptor; + description: MessageDescriptor; +} + +export const STRATEGY_CATEGORIES: Record< + StrategyCategory, + StrategyCategoryMeta +> = { + template: { + label: msg`Template-based`, + description: msg`Fast, consistent results with no AI cost`, + }, + ai: { + label: msg`AI-powered`, + description: msg`Adaptive layouts generated per page (slower, uses API credits)`, + }, +}; + +export interface RenderStrategyOption extends WizardOption { + category: StrategyCategory; +} + +// ─── Render Strategies (Step 2) ────────────────────────────────────────────── + +export const RENDER_STRATEGIES = [ + { + id: "llm", + Icon: Sparkles, + title: msg`Dynamic`, + description: msg`Automatically adapts the layout based on each page's content using AI.`, + category: "ai", + }, + { + id: "llm-overlay", + Icon: Layers, + title: msg`Dynamic Overlay`, + description: msg`AI-powered layout that preserves the original page as a background with text overlay.`, + category: "ai", + }, + { + id: "single_column", + Icon: AlignLeft, + title: msg`Single Column`, + description: msg`Full-width single column layout. Ideal for reference material, documentation, and dense technical content.`, + category: "template", + }, + { + id: "two_column", + Icon: BookOpen, + title: msg`Two Columns`, + description: msg`The ideal choice for novels, focused on a clean and continuous reading experience.`, + category: "template", + }, + { + id: "two_column_story", + Icon: TwoColumnStoryStrategyIcon, + title: msg`Two Columns Story`, + description: msg`Perfect for children's books, pairing large images with minimal text.`, + category: "template", + }, +] as const satisfies readonly RenderStrategyOption[]; + +export type RenderStrategyId = (typeof RENDER_STRATEGIES)[number]["id"]; + +// ─── Preset defaults — typed against the wizard form ──────────────────────── + +export type SectioningModeId = "page" | "dynamic" | "section"; + +export type WizardPageGrouping = "" | "spread" | "single"; + +export type WizardSectioningMode = "" | SectioningModeId; + +export type PresetRecommendations = Partial; + +// ─── Preset types ──────────────────────────────────────────────────────────── + +export type PresetId = "textbook" | "storybook" | "reference" | "custom"; + +export interface ExampleBook { + title: MessageDescriptor; + pdfUrl?: string; + adtUrl?: string; + comingSoon?: boolean; +} + +export interface PresetConfig { + id: PresetId; + imageSrc: string | null; + Icon: React.ElementType; + iconColor: string; + bgColor: string; + title: MessageDescriptor; + description: MessageDescriptor; + renderStrategies?: readonly RenderStrategyId[]; + recommendedStrategies?: readonly RenderStrategyId[]; + recommendedFor: MessageDescriptor[]; + exampleBooks: ExampleBook[]; + recommendations: PresetRecommendations; + formDefaults?: Partial; + baseConfig?: Record; +} + +// ─── Demo URLs (shared across all presets until per-preset assets are ready) ─ + +const DEMO_PDF_URL = + "https://ontheline.trincoll.edu/images/bookdown/sample-local-pdf.pdf"; +const DEMO_ADT_URL = + "https://elasticsounds.github.io/adt-brazil-demo/index.html"; + +// ─── Presets ───────────────────────────────────────────────────────────────── + +export const PRESETS: PresetConfig[] = [ + { + id: "textbook", + imageSrc: null, + Icon: TextbookWireframePreview, + iconColor: "text-blue-500", + bgColor: "bg-blue-500/5", + title: msg`Textbooks & Activities`, + description: msg`Structured chapters, exercises. Best for educational content with complex layouts.`, + renderStrategies: ["llm", "llm-overlay", "two_column", "two_column_story"], + recommendedStrategies: ["llm"], + recommendedFor: [ + msg`School textbooks and workbooks`, + msg`University academic publications`, + msg`Scientific papers and journals`, + msg`Technical manuals with diagrams`, + ], + exampleBooks: [ + { + title: msg`Práticas de Alfabetização e de Matemática`, + pdfUrl: DEMO_PDF_URL, + adtUrl: DEMO_ADT_URL, + }, + { + title: msg`Ciências da Natureza - Ensino Fundamental`, + comingSoon: true, + }, + { title: msg`História e Sociedade - Vol. 1`, comingSoon: true }, + { title: msg`Língua Portuguesa - 3° Ano`, comingSoon: true }, + ], + recommendations: { + renderStrategy: "llm", + pageGrouping: "single", + sectioningMode: "dynamic", + activitiesGenerator: true, + imageCropping: false, + imageSegmentation: true, + figureExtraction: true, + }, + formDefaults: { + imageFilterMinSide: 50, + imageFilterMaxSide: 3500, + }, + baseConfig: { + render_strategies: { + llm: { + render_type: "llm", + config: { + prompt: "web_generation_html", + model: "openai:gpt-5.2", + max_retries: 5, + timeout: 180, + temperature: 0.3, + visual_refinement: { enabled: true, max_iterations: 20 }, + }, + }, + "llm-overlay": { + render_type: "llm", + config: { + prompt: "web_generation_html_overlay", + model: "openai:gpt-5.2", + max_retries: 25, + timeout: 180, + temperature: 0.3, + visual_refinement: { enabled: true, max_iterations: 5 }, + }, + }, + two_column: { + render_type: "template", + config: { template: "two_column_render" }, + }, + activity_multiple_choice: { + render_type: "activity", + config: { + prompt: "activity_multiple_choice", + answer_prompt: "activity_multiple_choice_answers", + model: "openai:gpt-5.2", + max_retries: 5, + timeout: 180, + temperature: 0.3, + visual_refinement: { enabled: true, max_iterations: 5 }, + }, + }, + activity_true_false: { + render_type: "activity", + config: { + prompt: "activity_true_false", + answer_prompt: "activity_true_false_answers", + model: "openai:gpt-5.2", + max_retries: 5, + timeout: 180, + temperature: 0.3, + visual_refinement: { enabled: true, max_iterations: 5 }, + }, + }, + activity_fill_in_the_blank: { + render_type: "activity", + config: { + prompt: "activity_fill_in_the_blank", + answer_prompt: "activity_fill_in_the_blank_answers", + model: "openai:gpt-5.2", + max_retries: 5, + timeout: 180, + temperature: 0.3, + visual_refinement: { enabled: true, max_iterations: 5 }, + }, + }, + activity_fill_in_a_table: { + render_type: "activity", + config: { + prompt: "activity_fill_in_a_table", + answer_prompt: "activity_fill_in_a_table_answers", + model: "openai:gpt-5.2", + max_retries: 5, + timeout: 180, + temperature: 0.3, + visual_refinement: { enabled: true, max_iterations: 5 }, + }, + }, + activity_matching: { + render_type: "activity", + config: { + prompt: "activity_matching", + answer_prompt: "activity_matching_answers", + model: "openai:gpt-5.2", + max_retries: 5, + timeout: 180, + temperature: 0.3, + visual_refinement: { enabled: true, max_iterations: 5 }, + }, + }, + activity_sorting: { + render_type: "activity", + config: { + prompt: "activity_sorting", + answer_prompt: "activity_sorting_answers", + model: "openai:gpt-5.2", + max_retries: 5, + timeout: 180, + temperature: 0.3, + visual_refinement: { enabled: true, max_iterations: 5 }, + }, + }, + activity_open_ended_answer: { + render_type: "activity", + config: { + prompt: "activity_open_ended_answer", + model: "openai:gpt-5.2", + max_retries: 5, + timeout: 180, + temperature: 0.3, + visual_refinement: { enabled: true, max_iterations: 5 }, + }, + }, + }, + section_render_strategies: { + activity_multiple_choice: "activity_multiple_choice", + activity_true_false: "activity_true_false", + activity_fill_in_the_blank: "activity_fill_in_the_blank", + activity_fill_in_a_table: "activity_fill_in_a_table", + activity_matching: "activity_matching", + activity_sorting: "activity_sorting", + activity_open_ended_answer: "activity_open_ended_answer", + }, + pruned_text_types: ["header_text", "footer_text", "page_number"], + pruned_section_types: ["back_cover", "credits", "inside_cover"], + image_filters: { min_stddev: 2 }, + }, + }, + { + id: "storybook", + imageSrc: null, + Icon: StorybookWireframePreview, + iconColor: "text-amber-500", + bgColor: "bg-amber-500/5", + title: msg`Storybook`, + description: msg`Large images, narrative flow. Best for illustrated books with high-fidelity TTS voices.`, + renderStrategies: ["llm", "llm-overlay", "two_column", "two_column_story"], + recommendedStrategies: ["llm-overlay", "two_column_story"], + recommendedFor: [ + msg`Illustrated children's books`, + msg`Young adult fiction`, + msg`Chapter books with images`, + msg`Picture books and early readers`, + ], + exampleBooks: [ + { + title: msg`Sample Illustrated Story`, + pdfUrl: DEMO_PDF_URL, + adtUrl: DEMO_ADT_URL, + }, + { title: msg`Adventure Tales - Vol. 1`, comingSoon: true }, + { title: msg`The Lost Forest`, comingSoon: true }, + ], + recommendations: { + renderStrategy: "two_column_story", + pageGrouping: "spread", + sectioningMode: "page", + imageCropping: false, + imageSegmentation: false, + }, + formDefaults: { + imageFilterMinSide: 150, + imageFilterMaxSide: 3500, + }, + baseConfig: { + render_strategies: { + two_column_story: { + render_type: "template", + config: { template: "two_column_story" }, + }, + two_column: { + render_type: "template", + config: { template: "two_column_render" }, + }, + llm: { + render_type: "llm", + config: { + prompt: "web_generation_html", + model: "openai:gpt-5.2", + max_retries: 5, + timeout: 180, + }, + }, + "llm-overlay": { + render_type: "llm", + config: { + prompt: "web_generation_html_overlay", + model: "openai:gpt-5.2", + max_retries: 25, + timeout: 180, + }, + }, + }, + section_render_strategies: {}, + pruned_text_types: ["header_text", "footer_text", "page_number"], + pruned_section_types: [ + "back_cover", + "credits", + "inside_cover", + "activity_multiple_choice", + "activity_true_false", + "activity_fill_in_the_blank", + "activity_fill_in_a_table", + "activity_matching", + "activity_sorting", + "activity_open_ended_answer", + ], + image_filters: { min_stddev: 2 }, + quiz_generation: { pages_per_quiz: 3 }, + }, + }, + { + id: "reference", + imageSrc: null, + Icon: ReferenceWireframePreview, + iconColor: "text-emerald-500", + bgColor: "bg-emerald-500/5", + title: msg`Reference`, + description: msg`Dense text, tables, glossaries. Best for technical material and documentation.`, + renderStrategies: ["llm", "llm-overlay", "single_column", "two_column"], + recommendedStrategies: ["single_column"], + recommendedFor: [ + msg`Technical documentation`, + msg`Legal and compliance manuals`, + msg`Medical references`, + msg`Engineering handbooks`, + ], + exampleBooks: [ + { + title: msg`Sample Reference Manual`, + pdfUrl: DEMO_PDF_URL, + adtUrl: DEMO_ADT_URL, + }, + { title: msg`Engineering Handbook Vol. 2`, comingSoon: true }, + { title: msg`Legal Compliance Guide`, comingSoon: true }, + ], + recommendations: { + renderStrategy: "single_column", + pageGrouping: "single", + sectioningMode: "page", + imageCropping: false, + imageSegmentation: false, + figureExtraction: true, + }, + formDefaults: { + imageFilterMinSide: 100, + imageFilterMaxSide: 5000, + }, + baseConfig: { + render_strategies: { + single_column: { + render_type: "template", + config: { template: "one_column_render" }, + }, + two_column: { + render_type: "template", + config: { template: "two_column_render" }, + }, + llm: { + render_type: "llm", + config: { + prompt: "web_generation_html", + model: "openai:gpt-5.2", + max_retries: 5, + timeout: 180, + visual_refinement: { enabled: true, max_iterations: 20 }, + }, + }, + "llm-overlay": { + render_type: "llm", + config: { + prompt: "web_generation_html_overlay", + model: "openai:gpt-5.2", + max_retries: 25, + timeout: 180, + visual_refinement: { enabled: true, max_iterations: 5 }, + }, + }, + }, + section_render_strategies: {}, + pruned_text_types: ["header_text", "footer_text", "page_number"], + pruned_section_types: [ + "back_cover", + "credits", + "inside_cover", + "activity_multiple_choice", + "activity_true_false", + "activity_fill_in_the_blank", + "activity_fill_in_a_table", + "activity_matching", + "activity_sorting", + "activity_open_ended_answer", + ], + image_filters: { min_stddev: 2 }, + }, + }, + { + id: "custom", + imageSrc: null, + Icon: SlidersHorizontal, + iconColor: "text-violet-500", + bgColor: "bg-violet-500/5", + title: msg`Custom`, + description: msg`Full control over render strategies, pruning, and filters.`, + recommendedFor: [ + msg`Any content type`, + msg`Specialized workflows`, + msg`Experimental configurations`, + msg`Multi-format publications`, + ], + exampleBooks: [ + { + title: msg`Custom Layout Demo`, + pdfUrl: DEMO_PDF_URL, + adtUrl: DEMO_ADT_URL, + }, + { title: msg`Mixed Content Project`, comingSoon: true }, + ], + recommendations: {}, + }, +]; + +// ─── Preset accent colors ──────────────────────────────────────────────────── + +export interface PresetAccent { + bg: string + hover: string + text: string +} + +const PRESET_ACCENT_MAP: Record = { + textbook: { bg: "#3b82f6", hover: "#2563eb", text: "#3b82f6" }, + storybook: { bg: "#f59e0b", hover: "#d97706", text: "#f59e0b" }, + reference: { bg: "#10b981", hover: "#059669", text: "#10b981" }, + custom: { bg: "#8b5cf6", hover: "#7c3aed", text: "#8b5cf6" }, +} + +const DEFAULT_PRESET_ACCENT: PresetAccent = { bg: "#2b7fff", hover: "#1a6fef", text: "#2b7fff" } + +export function getPresetAccent(presetId: string | null | undefined): PresetAccent { + return (presetId ? PRESET_ACCENT_MAP[presetId] : undefined) ?? DEFAULT_PRESET_ACCENT +} + +export const PRESET_RECOMMENDATIONS: Record = + Object.fromEntries(PRESETS.map((p) => [p.id, p.recommendations])) as Record< + PresetId, + PresetRecommendations + >; + +const FIELD_LABELS: Partial> = + { + renderStrategy: msg`Render Strategy`, + pageGrouping: msg`Page Grouping`, + sectioningMode: msg`Sectioning`, + imageCropping: msg`Smart Cropping`, + imageSegmentation: msg`Image Segmentation`, + figureExtraction: msg`Figure Extraction`, + }; + +const VALUE_LABELS: Record = { + single_column: msg`Single Column`, + two_column: msg`Two Columns`, + two_column_story: msg`Two Columns Story`, + llm: msg`Dynamic`, + "llm-overlay": msg`Dynamic Overlay`, + single: msg`Single Page`, + spread: msg`Spread`, + page: msg`Per Page`, + dynamic: msg`Dynamic`, + section: msg`By Section`, +}; + +function formatDefaultValue( + key: keyof WizardFormValues, + value: unknown, +): MessageDescriptor | string { + if (typeof value === "boolean") return value ? msg`On` : msg`Off`; + if (typeof value === "number") return `${value}px`; + const str = String(value); + return VALUE_LABELS[str] ?? str; +} + +export function getPresetRecommendationEntries( + recommendations: PresetRecommendations, +): { label: MessageDescriptor; value: MessageDescriptor | string }[] { + return Object.entries(recommendations) + .filter(([key]) => key in FIELD_LABELS) + .map(([key, value]) => ({ + label: FIELD_LABELS[key as keyof WizardFormValues]!, + value: formatDefaultValue(key as keyof WizardFormValues, value), + })); +} diff --git a/apps/studio/src/components/wizard/icons/ReferenceWireframePreview.tsx b/apps/studio/src/components/wizard/icons/ReferenceWireframePreview.tsx new file mode 100644 index 00000000..43d2175a --- /dev/null +++ b/apps/studio/src/components/wizard/icons/ReferenceWireframePreview.tsx @@ -0,0 +1,80 @@ +import { cn } from "@/lib/utils" +import type { SVGProps } from "react" + +/** Wireframe preview for the Reference preset — dense text, section headings, and a table. */ +export function ReferenceWireframePreview({ + className, + ...props +}: SVGProps) { + return ( + + {/* Page background */} + + + {/* Page number top-right */} + + + {/* Section 1 heading */} + + + + {/* Dense text block 1 */} + + + + + + {/* Table */} + + {/* Header row */} + + {/* Column dividers */} + + + {/* Row dividers */} + + + + + {/* Header labels */} + + + + {/* Row 1 */} + + + + {/* Row 2 */} + + + + {/* Row 3 */} + + + + {/* Row 4 */} + + + + + {/* Section 2 heading */} + + + + {/* Dense text block 2 */} + + + + + + + + ) +} diff --git a/apps/studio/src/components/wizard/icons/StorybookWireframePreview.tsx b/apps/studio/src/components/wizard/icons/StorybookWireframePreview.tsx new file mode 100644 index 00000000..9c6da6ff --- /dev/null +++ b/apps/studio/src/components/wizard/icons/StorybookWireframePreview.tsx @@ -0,0 +1,76 @@ +import { cn } from "@/lib/utils" +import type { SVGProps } from "react" + +/** Wireframe preview for the Storybook preset — open book viewed from above, illustration left + text right. */ +export function StorybookWireframePreview({ + className, + ...props +}: SVGProps) { + return ( + + + + + + + + + + + + + {/* Book drop shadow */} + + + {/* Left page */} + + + {/* Right page */} + + + {/* Spine */} + + + + {/* Spine depth shadows */} + + + + {/* ── Left page — illustration ────────────────────────────── */} + + {/* Sky */} + + + {/* Sun */} + + + {/* Clouds */} + + + + + {/* Ground */} + + + {/* Tree */} + + + + + {/* ── Right page — large story text, centered ─────────────── */} + + + + + + + + ) +} diff --git a/apps/studio/src/components/wizard/icons/TextbookWireframePreview.tsx b/apps/studio/src/components/wizard/icons/TextbookWireframePreview.tsx new file mode 100644 index 00000000..e942c64f --- /dev/null +++ b/apps/studio/src/components/wizard/icons/TextbookWireframePreview.tsx @@ -0,0 +1,57 @@ +import { cn } from "@/lib/utils" +import type { SVGProps } from "react" + +/** Wireframe preview for the Textbooks & Activities preset. */ +export function TextbookWireframePreview({ + className, + ...props +}: SVGProps) { + return ( + + {/* Page background */} + + + {/* Chapter header */} + + + + + + {/* Left content column - text lines */} + + + + + + {/* Diagram / image box */} + + + + + + {/* Activity section */} + + + + {/* Row 1 - unchecked */} + + + + {/* Row 2 - unchecked */} + + + + {/* Row 3 - checked */} + + + + + ) +} diff --git a/apps/studio/src/components/wizard/icons/TwoColumnStoryStrategyIcon.tsx b/apps/studio/src/components/wizard/icons/TwoColumnStoryStrategyIcon.tsx new file mode 100644 index 00000000..930e3965 --- /dev/null +++ b/apps/studio/src/components/wizard/icons/TwoColumnStoryStrategyIcon.tsx @@ -0,0 +1,43 @@ +import type { SVGProps } from "react" +import { cn } from "@/lib/utils" + +/** Custom kids-book open book (Figma) for “Two Columns Story” render strategy. */ +export function TwoColumnStoryStrategyIcon({ + className, + ...props +}: SVGProps) { + return ( + + + + + + + ) +} diff --git a/apps/studio/src/components/wizard/index.tsx b/apps/studio/src/components/wizard/index.tsx new file mode 100644 index 00000000..963d7370 --- /dev/null +++ b/apps/studio/src/components/wizard/index.tsx @@ -0,0 +1,101 @@ +import { + createContext, + useContext, + useState, + useCallback, + useEffect, + useRef, + type ReactNode, +} from "react" +import type { ImageProcessingPreviewFocus } from "./step3ContentProcessing/imageProcessingPreviewTypes" +import type { PresetId } from "./constants" + +interface WizardContextValue { + currentStep: number + setCurrentStep: (step: number) => void + stepDirection: "forward" | "back" + previewFocus: ImageProcessingPreviewFocus + setPreviewFocus: (focus: ImageProcessingPreviewFocus) => void + committedStep0Preset: PresetId | null + setCommittedStep0Preset: (id: PresetId) => void +} + +const WizardContext = createContext(null) + +export function WizardProvider({ children }: { children: ReactNode }) { + const [currentStep, setCurrentStepRaw] = useState(0) + const [stepDirection, setStepDirection] = useState<"forward" | "back">("forward") + + useEffect(() => { + if (currentStep === 0) return + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault() + } + window.addEventListener("beforeunload", handler) + return () => window.removeEventListener("beforeunload", handler) + }, [currentStep]) + + const [previewFocus, setPreviewFocusRaw] = + useState("idle") + + const [committedStep0Preset, setCommittedStep0Preset] = useState(null) + + const setCurrentStep = useCallback((step: number) => { + setStepDirection(step > currentStep ? "forward" : "back") + setCurrentStepRaw(step) + if (step !== 3) setPreviewFocusRaw("idle") + }, [currentStep]) + + const setPreviewFocus = useCallback( + (focus: ImageProcessingPreviewFocus) => setPreviewFocusRaw(focus), + [], + ) + + return ( + + {children} + + ) +} + +export function useWizard(): WizardContextValue { + const ctx = useContext(WizardContext) + if (!ctx) throw new Error("useWizard must be used inside ") + return ctx +} + +const PREVIEW_HOVER_DELAY = 300 + +export function useDelayedPreviewFocus(focus: ImageProcessingPreviewFocus) { + const { setPreviewFocus } = useWizard() + const timerRef = useRef>(null) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + const onMouseEnter = useCallback(() => { + timerRef.current = setTimeout(() => setPreviewFocus(focus), PREVIEW_HOVER_DELAY) + }, [focus, setPreviewFocus]) + + const onMouseLeave = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + return { onMouseEnter, onMouseLeave } +} diff --git a/apps/studio/src/components/wizard/shared/InfoCarousel.tsx b/apps/studio/src/components/wizard/shared/InfoCarousel.tsx new file mode 100644 index 00000000..27f6f43a --- /dev/null +++ b/apps/studio/src/components/wizard/shared/InfoCarousel.tsx @@ -0,0 +1,126 @@ +import { useState, type ElementType } from "react" +import { useLingui } from "@lingui/react/macro" +import { ChevronLeft, ChevronRight, CircleHelp } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { cn } from "@/lib/utils" + +export interface CarouselSlide { + title: string + description: string + Diagram: ElementType +} + +function Carousel({ slides }: { slides: readonly CarouselSlide[] }) { + const { t } = useLingui() + const [index, setIndex] = useState(0) + + return ( +
+
+
+ {slides.map((slide) => ( +
+ + + + {slide.title} + + + {slide.description} + + + + + + + + +
+ ))} +
+
+ +
+ + +
+ {slides.map((_, i) => ( +
+ + +
+
+ ) +} + +export function InfoCarousel({ label, slides }: { label: string; slides: readonly CarouselSlide[] }) { + return ( + + + + + + + + + ) +} diff --git a/apps/studio/src/components/wizard/shared/PdfCoverPreview.tsx b/apps/studio/src/components/wizard/shared/PdfCoverPreview.tsx new file mode 100644 index 00000000..a16ecf8c --- /dev/null +++ b/apps/studio/src/components/wizard/shared/PdfCoverPreview.tsx @@ -0,0 +1,99 @@ +import { Loader2, BookOpen } from "lucide-react" +import { msg } from "@lingui/core/macro" +import { useLingui } from "@lingui/react" +import { Trans } from "@lingui/react/macro" +import { cn } from "@/lib/utils" +import { + getPreviewPageLabel, + usePdfPreviewPages, +} from "@/components/wizard/shared/usePdfPreviewPages" +import { PreviewShell } from "@/components/wizard/shared/PreviewShell" + +interface PdfCoverPreviewProps { + file?: File | null + width: number + height: number +} + +const BOOK_PREVIEW_LABEL = msg`Book preview` +const pdfPagePreviewMsg = msg`PDF page {pageLabel} preview` + +function PdfCoverPlaceholder({ label }: { label: string }) { + return ( + +
+
+ +
+
+

+ Book preview +

+

+ Upload a PDF to preview its pages here. +

+
+
+
+ ) +} + +function PdfCoverCanvas({ file, width, height, label }: { file: File; width: number; height: number; label: string }) { + const { i18n } = useLingui() + const { pages, pageLabels, isLoading } = usePdfPreviewPages({ + file, + mode: "all", + width, + height, + }) + const ready = !isLoading && pages.length > 0 + + return ( + + {!ready && ( +
+ + + Loading preview + +
+ )} +
+ {pages.map((dataUrl, index) => { + const pageLabel = getPreviewPageLabel(pageLabels, index) + return ( +
+ {i18n._( +
+ {pageLabel} +
+
+ ) + })} +
+
+ ) +} + +export function PdfCoverPreview({ file, width, height }: PdfCoverPreviewProps) { + const { i18n } = useLingui() + const label = i18n._(BOOK_PREVIEW_LABEL) + if (!file) return + return +} diff --git a/apps/studio/src/components/wizard/shared/PreviewShell.tsx b/apps/studio/src/components/wizard/shared/PreviewShell.tsx new file mode 100644 index 00000000..4e35f18f --- /dev/null +++ b/apps/studio/src/components/wizard/shared/PreviewShell.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react" +import { cn } from "@/lib/utils" + +export function PreviewShell({ + label, + className, + bodyClassName, + children, +}: { + label: string + className?: string + bodyClassName?: string + children: ReactNode +}) { + return ( +
+
+

+ {label} +

+
+
+ {children} +
+
+ ) +} diff --git a/apps/studio/src/components/wizard/shared/RangeSlider.tsx b/apps/studio/src/components/wizard/shared/RangeSlider.tsx new file mode 100644 index 00000000..5085a530 --- /dev/null +++ b/apps/studio/src/components/wizard/shared/RangeSlider.tsx @@ -0,0 +1,280 @@ +import { useId, useState } from "react"; +import { CircleHelp, Minus, Plus } from "lucide-react"; +import { Slider } from "@/components/ui/slider"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface RangeSliderProps { + label: string; + tooltip?: string; + min: number; + max: number; + value: [number, number]; + onChange: (value: [number, number]) => void; + disabled?: boolean; + startLabel?: string; + endLabel?: string; + color?: string; +} + +export interface SingleValueSliderProps { + label: string; + tooltip?: string; + min: number; + max: number; + value: number; + onChange: (value: number) => void; + disabled?: boolean; + minValueLabel?: string; + valueUnit?: string; + color?: string; +} + +function clampToRange(n: number, lo: number, hi: number) { + return Math.min(hi, Math.max(lo, n)); +} + +function MinMaxInput({ + label, + value, + min, + max, + onChange, + disabled, +}: { + label: string; + value: number; + min: number; + max: number; + onChange: (v: number) => void; + disabled?: boolean; +}) { + const inputId = useId(); + const [draft, setDraft] = useState(null); + + function clamp(v: number) { + return Math.min(max, Math.max(min, v)); + } + + function commit(raw: string) { + setDraft(null); + const parsed = parseInt(raw, 10); + if (!isNaN(parsed)) onChange(clamp(parsed)); + } + + return ( +
+ +
+ + setDraft(e.target.value)} + onBlur={(e) => commit(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + className={cn( + "h-8 w-full rounded-none border-0 bg-white px-2 py-0 text-center text-[11.2px] shadow-none", + "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0", + "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + )} + /> + +
+
+ ); +} + +export function SingleValueSlider({ + label, + tooltip, + min, + max, + value, + onChange, + disabled, + minValueLabel, + valueUnit = "px", + color, +}: SingleValueSliderProps) { + const groupLabelId = useId(); + const boundedMin = Number.isFinite(min) ? min : 0; + const boundedMax = + Number.isFinite(max) && max >= boundedMin ? max : boundedMin; + const v = clampToRange(value, boundedMin, boundedMax); + + return ( +
+
+
+ + {tooltip && ( + + + + + {tooltip} + + )} +
+ + {v <= boundedMin ? minValueLabel : `${v} ${valueUnit}`} + +
+ + { + const n = next[0]; + if (n !== undefined) onChange(n); + }} + disabled={disabled} + color={color} + /> +
+ ); +} + +export function RangeSlider({ + label, + tooltip, + min, + max, + value, + onChange, + disabled, + startLabel, + endLabel, + color, +}: RangeSliderProps) { + const [start, end] = value; + const groupLabelId = useId(); + + const boundedMin = Number.isFinite(min) ? min : 0; + const boundedMax = + Number.isFinite(max) && max >= boundedMin ? max : boundedMin; + const emptyRangePreview = disabled && boundedMax <= boundedMin; + const sliderMax = emptyRangePreview ? boundedMin + 1 : boundedMax; + + const lo = clampToRange(Math.min(start, end), boundedMin, sliderMax); + const hi = clampToRange(Math.max(start, end), boundedMin, sliderMax); + const sliderValue: [number, number] = emptyRangePreview + ? [boundedMin, sliderMax] + : [Math.min(lo, hi), Math.max(lo, hi)]; + + return ( +
+
+ + {tooltip && ( + + + + + {tooltip} + + )} +
+ + onChange([s, e])} + disabled={disabled} + color={color} + /> + +
+ onChange([v, end])} + disabled={disabled} + /> + onChange([start, v])} + disabled={disabled} + /> +
+
+ ); +} diff --git a/apps/studio/src/components/wizard/shared/WizardSwitch.tsx b/apps/studio/src/components/wizard/shared/WizardSwitch.tsx new file mode 100644 index 00000000..f07c46f6 --- /dev/null +++ b/apps/studio/src/components/wizard/shared/WizardSwitch.tsx @@ -0,0 +1,39 @@ +import * as React from "react" +import type { CSSProperties } from "react" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +export type WizardSwitchProps = React.ComponentPropsWithoutRef & { + decorative?: boolean + color?: string +} + +export const WizardSwitch = React.forwardRef< + React.ElementRef, + WizardSwitchProps +>(({ className, disabled, decorative, onCheckedChange, color, style, ...props }, ref) => ( + {} : onCheckedChange} + style={color ? ({ "--switch-color": color, ...style } as CSSProperties) : style} + className={cn( + "h-6 w-11 shrink-0 transition-colors duration-300", + decorative ? "pointer-events-none cursor-default" : "cursor-pointer", + color + ? "data-[state=checked]:bg-[var(--switch-color)]" + : "data-[state=checked]:bg-primary", + "data-[state=unchecked]:bg-input", + !decorative && [ + "hover:ring-2 hover:ring-ring/45 hover:ring-offset-2 hover:ring-offset-background", + "active:scale-[0.98] active:transition-none", + ], + "disabled:cursor-not-allowed disabled:hover:ring-0", + className, + )} + {...props} + /> +)) +WizardSwitch.displayName = "WizardSwitch" diff --git a/apps/studio/src/components/wizard/shared/pdfjsLoader.ts b/apps/studio/src/components/wizard/shared/pdfjsLoader.ts new file mode 100644 index 00000000..006db459 --- /dev/null +++ b/apps/studio/src/components/wizard/shared/pdfjsLoader.ts @@ -0,0 +1,15 @@ +let pdfjsModule: Promise | null = null + +/** Single dynamic import + worker setup so pdf work stays off the critical path until needed. */ +export function getPdfJs() { + if (!pdfjsModule) { + pdfjsModule = import("pdfjs-dist").then((pdfjs) => { + pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.mjs", + import.meta.url, + ).href + return pdfjs + }) + } + return pdfjsModule +} diff --git a/apps/studio/src/components/wizard/shared/usePdfPreviewPages.ts b/apps/studio/src/components/wizard/shared/usePdfPreviewPages.ts new file mode 100644 index 00000000..4a7e823a --- /dev/null +++ b/apps/studio/src/components/wizard/shared/usePdfPreviewPages.ts @@ -0,0 +1,161 @@ +import { useEffect, useState } from "react" +import { getPdfJs } from "@/components/wizard/shared/pdfjsLoader" + +const JPEG_QUALITY = 0.92 +const MAX_CACHE_ENTRIES = 12 + +type CachedPreview = { dataUrls: string[]; pageLabels: string[] | null } + +const previewCache = new Map() + +function rememberInCache(key: string, entry: CachedPreview) { + if (previewCache.size >= MAX_CACHE_ENTRIES) { + const oldest = previewCache.keys().next().value + if (oldest !== undefined) previewCache.delete(oldest) + } + previewCache.set(key, entry) +} + +type PdfPreviewMode = "first" | "all" + +interface UsePdfPreviewPagesParams { + file?: File | null + src?: string + mode: PdfPreviewMode + width?: number + height?: number +} + +function buildCacheKey({ file, src, mode, width, height }: UsePdfPreviewPagesParams): string { + if (file) { + return `v2:file:${file.name}:${file.lastModified}:${file.size}:${mode}:${width ?? "-"}:${height ?? "-"}` + } + return `v2:src:${src ?? ""}:${mode}:${width ?? "-"}:${height ?? "-"}` +} + +export function usePdfPreviewPages(params: UsePdfPreviewPagesParams) { + const { file, src, mode, width, height } = params + const [pages, setPages] = useState([]) + const [pageLabels, setPageLabels] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + const hasFile = Boolean(file) + const hasSrc = Boolean(src) + if (!hasFile && !hasSrc) { + setPages([]) + setPageLabels(null) + setIsLoading(false) + setError(null) + return + } + + const cacheKey = buildCacheKey(params) + const cached = previewCache.get(cacheKey) + if (cached) { + setPages(cached.dataUrls) + setPageLabels(cached.pageLabels) + setIsLoading(false) + setError(null) + return + } + + let cancelled = false + let objectUrl: string | null = null + + setPages([]) + setPageLabels(null) + setIsLoading(true) + setError(null) + + ;(async () => { + try { + const documentSource = file + ? { url: (objectUrl = URL.createObjectURL(file)) } + : { url: src as string } + + const pdfjs = await getPdfJs() + if (cancelled) return + + const pdf = await pdfjs.getDocument(documentSource).promise + if (cancelled) { + await pdf.destroy().catch(() => {}) + return + } + + try { + const rawLabels = await pdf.getPageLabels() + if (cancelled) return + + const normalizedLabels: string[] | null = + rawLabels && rawLabels.length > 0 + ? rawLabels.map((label, idx) => { + const s = label?.trim() ?? "" + return s !== "" ? s : String(idx + 1) + }) + : null + + const result: string[] = [] + const lastPage = mode === "first" ? 1 : pdf.numPages + const labelSlice = + normalizedLabels === null ? null : normalizedLabels.slice(0, lastPage) + + if (cancelled) return + setPageLabels(labelSlice) + + for (let pageNumber = 1; pageNumber <= lastPage; pageNumber++) { + const page = await pdf.getPage(pageNumber) + if (cancelled) return + + const base = page.getViewport({ scale: 1 }) + const scale = + mode === "first" && width && height + ? Math.min(width / base.width, height / base.height) + : mode === "all" && width + ? width / base.width + : 1200 / base.width + const viewport = page.getViewport({ scale }) + const canvas = document.createElement("canvas") + const ctx = canvas.getContext("2d") + if (!ctx) throw new Error("Canvas context unavailable") + + canvas.width = Math.floor(viewport.width) + canvas.height = Math.floor(viewport.height) + await page.render({ canvas, canvasContext: ctx, viewport }).promise + if (cancelled) return + + result.push(canvas.toDataURL("image/jpeg", JPEG_QUALITY)) + page.cleanup() + canvas.width = 0 + + setPages([...result]) + if (pageNumber === 1) setIsLoading(false) + } + + rememberInCache(cacheKey, { dataUrls: result, pageLabels: labelSlice }) + } finally { + await pdf.destroy().catch(() => {}) + } + } catch { + if (cancelled) return + setError("preview-error") + setIsLoading(false) + } + })() + + return () => { + cancelled = true + if (objectUrl) URL.revokeObjectURL(objectUrl) + } + }, [file, src, mode, width, height]) + + return { pages, pageLabels, isLoading, error } +} + +export function getPreviewPageLabel(pageLabels: string[] | null, index: number): string { + const physical = String(index + 1) + if (!pageLabels || index < 0 || index >= pageLabels.length) return physical + const label = pageLabels[index]?.trim() + return label !== undefined && label !== "" ? label : physical +} diff --git a/apps/studio/src/components/wizard/step0preset/ExamplesModal.tsx b/apps/studio/src/components/wizard/step0preset/ExamplesModal.tsx new file mode 100644 index 00000000..0bd99584 --- /dev/null +++ b/apps/studio/src/components/wizard/step0preset/ExamplesModal.tsx @@ -0,0 +1,253 @@ +import { useState } from "react" +import { BookOpen, Loader2, X } from "lucide-react" +import { Trans, useLingui } from "@lingui/react/macro" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { + Dialog, + DialogDescription, + DialogOverlay, + DialogPortal, + DialogTitle, +} from "@/components/ui/dialog" +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { type PresetConfig, type ExampleBook } from "@/components/wizard/constants" +import { usePdfPreviewPages } from "@/components/wizard/shared/usePdfPreviewPages" + +type EmbedTab = "pdf" | "adt" + +function PdfCanvasPreview({ + src, + title, +}: { + src?: string + title: string +}) { + const { t } = useLingui() + const { pages, isLoading, error } = usePdfPreviewPages({ + src, + mode: "all", + }) + + if (!src) { + return ( +
+ No preview available. +
+ ) + } + + return ( +
+ {isLoading && ( +
+
+ + Loading preview... +
+
+ )} + + {error ? ( +
+ Unable to render PDF preview. +
+ ) : ( +
+ {pages.map((pageDataUrl, index) => ( + {t`${title} + ))} +
+ )} +
+ ) +} + +function BookItem({ + book, + selected, + onSelect, +}: { + book: ExampleBook + selected: boolean + onSelect: () => void +}) { + const { i18n } = useLingui() + + if (book.comingSoon) { + return ( +
+ + {i18n._(book.title)}{" "} + + (coming soon) + + +
+ ) + } + + return ( + + ) +} + +interface ExamplesModalProps { + open: boolean + onClose: () => void + preset: PresetConfig +} + +export function ExamplesModal({ open, onClose, preset }: ExamplesModalProps) { + const { i18n, t } = useLingui() + + const availableBooks = preset.exampleBooks.filter((b) => !b.comingSoon) + const [selectedBook, setSelectedBook] = useState( + () => availableBooks[0] ?? preset.exampleBooks[0], + ) + const [activeTab, setActiveTab] = useState("pdf") + + const pdfUrl = selectedBook.pdfUrl + const embedUrl = activeTab === "pdf" ? pdfUrl : selectedBook.adtUrl + + return ( + !v && onClose()}> + + + +
+
+ + {i18n._(preset.title)} + + + {i18n._(preset.description)} + +
+ +
+

+ Recommended for +

+
    + {preset.recommendedFor.map((item) => ( +
  • + + + {i18n._(item)} + +
  • + ))} +
+
+ +
+ +
+
+

+ Example Books +

+

+ + Real books processed with this preset — before and after. + +

+
+
+ {preset.exampleBooks.map((book) => ( + { + setSelectedBook(book) + setActiveTab("pdf") + }} + /> + ))} +
+
+
+ +
+ setActiveTab(value as EmbedTab)} + className="flex flex-1 flex-col min-h-0" + > + + + Original PDF + + + ADT Book + + + +
+ {embedUrl ? ( + activeTab === "adt" ? ( +
+