Spaces:
Running
Running
| import React, { useEffect, useMemo, useRef, useState } from 'react' | |
| import { useNavigate } from 'react-router-dom' | |
| import { | |
| ArrowLeft, | |
| ArrowRight, | |
| Check, | |
| FileText, | |
| Play, | |
| StopCircle, | |
| AlertCircle, | |
| AlertTriangle, | |
| Upload, | |
| Wand2, | |
| Trash2, | |
| Copy as CopyIcon, | |
| Eye, | |
| EyeOff, | |
| Gauge, | |
| Sparkles, | |
| Sliders, | |
| Layers, | |
| Lock, | |
| Unlock, | |
| RotateCcw, | |
| ArrowLeftRight, | |
| Zap, | |
| } from 'lucide-react' | |
| import PreflightModal from '../components/PreflightModal' | |
| import BackendRejectedBanner from '../components/BackendRejectedBanner' | |
| import Banner from '../components/Banner' | |
| import Toggle from '../components/Toggle' | |
| import { useTrackedGenerate } from '../hooks/useTrackedGenerate' | |
| import { useBackendCapabilities } from '../hooks/useBackendPlatform' | |
| import { api } from '../api/client' | |
| import { | |
| useSettings, | |
| DEFAULT_CLASS_OPTIONS, | |
| DEFAULT_SUBJECT_OPTIONS, | |
| } from '../store/settings' | |
| import type { GenerateSettings, OutputFormat, SavedThumbnailTemplate } from '../api/types' | |
| import { consumeProcessEditHandoff } from '../lib/processEditHandoff' | |
| import type { ReplacementTargets } from '../lib/processEditHandoff' | |
| import { | |
| buildAutoThumbnailFile, | |
| buildAutoThumbnailTemplate, | |
| renderTemplateToDataUrl, | |
| duplicateElement, | |
| nextElementId, | |
| detectChapterMeta, | |
| incrementChapterNum, | |
| type ThumbnailElement, | |
| type ThumbnailShapeType, | |
| } from '../lib/thumbnailBuilder' | |
| // βββ Defaults ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const DEFAULT_SETTINGS: GenerateSettings = { | |
| output_format: 'images', | |
| class_name: 'Class 10', | |
| subject: 'Nepali', | |
| model_choice: 'default', | |
| zoom: 2.1, | |
| overlap: 15, | |
| viewport_width: 1920, | |
| viewport_height: 1080, | |
| max_screenshots: 50, | |
| use_cache: true, | |
| beautify_html: false, | |
| close_powerpoint_before_start: true, | |
| auto_timing_screenshot_slides: true, | |
| fixed_seconds_per_screenshot_slide: 5, | |
| resolution: '1080p', | |
| video_quality: 85, | |
| fps: 30, | |
| slide_duration_sec: 5, | |
| intro_thumbnail_enabled: false, | |
| intro_thumbnail_duration_sec: 5, | |
| outro_thumbnail_enabled: false, | |
| outro_thumbnail_duration_sec: 5, | |
| } | |
| // New storage key (v2) captures the full wizard state so "Reuse previous | |
| // run" restores everything: text, all GenerateSettings (model choice, | |
| // system prompt, screenshot settings, video settings, thumbnails, β¦). | |
| // The old v1 key only stored {class_name, subject, title, output_format} | |
| // β we still read it as a fallback so users who ran at least once under | |
| // v1 don't lose the little that was saved. | |
| const LAST_RUN_STORAGE_KEY = 'textbro:text-to-video:last-run:v2' | |
| const HTML_LAST_RUN_STORAGE_KEY = 'textbro:html-to-video:last-run:v1' | |
| const LEGACY_PROJECT_DETAILS_STORAGE_KEY = 'textbro:text-to-video:project-details:v1' | |
| // C3: keys for the always-on draft autosave (every 5s while editing). | |
| const TEXT_DRAFT_STORAGE_KEY = 'textbro:text-to-video:draft:v1' | |
| const HTML_DRAFT_STORAGE_KEY = 'textbro:html-to-video:draft:v1' | |
| const DRAFT_AUTOSAVE_MS = 5_000 | |
| type SourceMode = 'text' | 'html' | |
| type LegacyProjectDetails = Pick<GenerateSettings, 'class_name' | 'subject' | 'title' | 'output_format'> | |
| /** Full snapshot of the wizard β everything needed to restore a prior run. */ | |
| interface LastRunSnapshot { | |
| text: string | |
| settings: GenerateSettings | |
| } | |
| function isRecord(v: unknown): v is Record<string, unknown> { | |
| return typeof v === 'object' && v !== null | |
| } | |
| function readLastRunSnapshot(mode: SourceMode = 'text'): LastRunSnapshot | null { | |
| if (typeof window === 'undefined') return null | |
| try { | |
| const raw = window.localStorage.getItem( | |
| mode === 'html' ? HTML_LAST_RUN_STORAGE_KEY : LAST_RUN_STORAGE_KEY, | |
| ) | |
| if (raw) { | |
| const parsed = JSON.parse(raw) as unknown | |
| if (isRecord(parsed) && isRecord(parsed.settings)) { | |
| return { | |
| text: typeof parsed.text === 'string' ? parsed.text : '', | |
| settings: parsed.settings as GenerateSettings, | |
| } | |
| } | |
| } | |
| // Fallback β legacy key only had the 4 project-info fields. | |
| const legacyRaw = | |
| mode === 'text' ? window.localStorage.getItem(LEGACY_PROJECT_DETAILS_STORAGE_KEY) : null | |
| if (legacyRaw) { | |
| const legacy = JSON.parse(legacyRaw) as LegacyProjectDetails | |
| if (isRecord(legacy)) { | |
| return { | |
| text: '', | |
| settings: { | |
| class_name: typeof legacy.class_name === 'string' ? legacy.class_name : undefined, | |
| subject: typeof legacy.subject === 'string' ? legacy.subject : undefined, | |
| title: typeof legacy.title === 'string' ? legacy.title : undefined, | |
| output_format: legacy.output_format, | |
| }, | |
| } | |
| } | |
| } | |
| return null | |
| } catch { | |
| return null | |
| } | |
| } | |
| function saveLastRunSnapshot( | |
| text: string, | |
| settings: GenerateSettings, | |
| mode: SourceMode = 'text', | |
| ): LastRunSnapshot | null { | |
| if (typeof window === 'undefined') return null | |
| const snapshot: LastRunSnapshot = { text, settings } | |
| try { | |
| window.localStorage.setItem( | |
| mode === 'html' ? HTML_LAST_RUN_STORAGE_KEY : LAST_RUN_STORAGE_KEY, | |
| JSON.stringify(snapshot), | |
| ) | |
| // Clear the legacy key so the reuse button doesn't surface stale | |
| // project-info that we've already superseded. | |
| if (mode === 'text') window.localStorage.removeItem(LEGACY_PROJECT_DETAILS_STORAGE_KEY) | |
| } catch { | |
| /* ignore storage failures */ | |
| } | |
| return snapshot | |
| } | |
| // C3: every-5s draft autosave keys are independent from the | |
| // "Reuse previous run" snapshot β we always write the in-progress state | |
| // here so a refresh / accidental tab close doesn't lose work. | |
| function readDraftSnapshot(mode: SourceMode = 'text'): LastRunSnapshot | null { | |
| if (typeof window === 'undefined') return null | |
| try { | |
| const raw = window.localStorage.getItem( | |
| mode === 'html' ? HTML_DRAFT_STORAGE_KEY : TEXT_DRAFT_STORAGE_KEY, | |
| ) | |
| if (!raw) return null | |
| const parsed = JSON.parse(raw) as unknown | |
| if (isRecord(parsed) && isRecord(parsed.settings)) { | |
| return { | |
| text: typeof parsed.text === 'string' ? parsed.text : '', | |
| settings: parsed.settings as GenerateSettings, | |
| } | |
| } | |
| return null | |
| } catch { | |
| return null | |
| } | |
| } | |
| function saveDraftSnapshot(text: string, settings: GenerateSettings, mode: SourceMode = 'text') { | |
| if (typeof window === 'undefined') return | |
| try { | |
| window.localStorage.setItem( | |
| mode === 'html' ? HTML_DRAFT_STORAGE_KEY : TEXT_DRAFT_STORAGE_KEY, | |
| JSON.stringify({ text, settings, savedAt: Date.now() }), | |
| ) | |
| } catch { | |
| /* quota errors etc β ignore, user can still submit */ | |
| } | |
| } | |
| function clearDraftSnapshot(mode: SourceMode = 'text') { | |
| if (typeof window === 'undefined') return | |
| try { | |
| window.localStorage.removeItem( | |
| mode === 'html' ? HTML_DRAFT_STORAGE_KEY : TEXT_DRAFT_STORAGE_KEY, | |
| ) | |
| } catch { | |
| /* ignore */ | |
| } | |
| } | |
| function captureThumbnailTemplateSettings(settings: GenerateSettings): Partial<GenerateSettings> { | |
| const sideImageUrl = settings.auto_thumbnail_side_image_url | |
| return { | |
| auto_thumbnail_chapter_num: settings.auto_thumbnail_chapter_num, | |
| auto_thumbnail_year: settings.auto_thumbnail_year, | |
| auto_thumbnail_chapter_prefix: settings.auto_thumbnail_chapter_prefix, | |
| auto_thumbnail_side_image_url: sideImageUrl?.startsWith('blob:') ? undefined : sideImageUrl, | |
| auto_thumbnail_image_offset_x: settings.auto_thumbnail_image_offset_x, | |
| auto_thumbnail_image_offset_y: settings.auto_thumbnail_image_offset_y, | |
| auto_thumbnail_image_zoom: settings.auto_thumbnail_image_zoom, | |
| auto_thumbnail_outro_title: settings.auto_thumbnail_outro_title, | |
| auto_thumbnail_outro_side_image_url: settings.auto_thumbnail_outro_side_image_url?.startsWith('blob:') | |
| ? undefined | |
| : settings.auto_thumbnail_outro_side_image_url, | |
| auto_thumbnail_canvas_background: settings.auto_thumbnail_canvas_background, | |
| auto_thumbnail_overrides: settings.auto_thumbnail_overrides, | |
| auto_thumbnail_added_elements: settings.auto_thumbnail_added_elements, | |
| auto_thumbnail_hidden_elements: settings.auto_thumbnail_hidden_elements, | |
| } | |
| } | |
| type StepId = 'project' | 'content' | 'screenshot' | 'video' | 'thumbnail' | 'advanced' | |
| interface StepDef { | |
| id: StepId | |
| label: string | |
| shortLabel: string | |
| /** Output formats for which this step is irrelevant and should be hidden. */ | |
| hiddenFor?: OutputFormat[] | |
| } | |
| const STEP_DEFS: StepDef[] = [ | |
| { id: 'project', label: 'Project info', shortLabel: 'Project' }, | |
| { id: 'content', label: 'AI & text', shortLabel: 'Content' }, | |
| // Screenshots only matter once HTML has to be rendered to images. | |
| { id: 'screenshot', label: 'Screenshot settings', shortLabel: 'Screenshots', hiddenFor: ['html'] }, | |
| // Video + thumbnail only matter for PowerPoint/MP4 export. | |
| { id: 'video', label: 'Video settings', shortLabel: 'Video', hiddenFor: ['html', 'images'] }, | |
| { id: 'thumbnail', label: 'Thumbnail', shortLabel: 'Thumbnail', hiddenFor: ['html', 'images'] }, | |
| { id: 'advanced', label: 'Advanced & start', shortLabel: 'Advanced' }, | |
| ] | |
| const OUTPUT_OPTIONS: { value: OutputFormat; label: string; desc: string }[] = [ | |
| { value: 'html', label: 'HTML file', desc: 'Raw AI-generated HTML only' }, | |
| { value: 'images', label: 'Screenshots', desc: 'HTML rendered to PNG images (default)' }, | |
| { value: 'pptx', label: 'PowerPoint', desc: 'Images packed into a .pptx (Windows only)' }, | |
| { value: 'video', label: 'MP4 video', desc: 'Rendered to MP4 via PowerPoint (Windows) or MoviePy (Linux/macOS).' }, | |
| ] | |
| // βββ Validation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| type FieldErrors = Record<string, string> | |
| /** Returns a map of { fieldId -> errorMessage } for a given step. Empty = valid. */ | |
| function validateStep( | |
| id: StepId, | |
| settings: GenerateSettings, | |
| text: string, | |
| mode: SourceMode = 'text', | |
| autoThumbnailBuilder = false, | |
| ): FieldErrors { | |
| const errs: FieldErrors = {} | |
| const num = (v: unknown): number | null => { | |
| if (v === undefined || v === null || v === '') return null | |
| const n = Number(v) | |
| return Number.isFinite(n) ? n : null | |
| } | |
| switch (id) { | |
| case 'project': { | |
| if (!(settings.class_name ?? '').trim()) errs.class_name = 'Pick a class.' | |
| if (!(settings.subject ?? '').trim()) errs.subject = 'Pick a subject.' | |
| if (!(settings.title ?? '').trim()) | |
| errs.title = 'Enter a chapter title β this is used in the video and thumbnail.' | |
| if (!settings.output_format) errs.output_format = 'Pick an output format.' | |
| return errs | |
| } | |
| case 'content': { | |
| if (!text.trim()) errs.text = mode === 'html' ? 'Paste your HTML here' : 'Paste your source text here' | |
| return errs | |
| } | |
| case 'screenshot': { | |
| const zoom = num(settings.zoom) | |
| if (zoom === null || zoom <= 0 || zoom > 10) errs.zoom = 'Zoom must be between 0.1 and 10' | |
| const overlap = num(settings.overlap) | |
| if (overlap === null || overlap < 0) errs.overlap = 'Overlap must be 0 or more' | |
| const vw = num(settings.viewport_width) | |
| if (vw === null || vw < 320) errs.viewport_width = 'Width must be at least 320px' | |
| const vh = num(settings.viewport_height) | |
| if (vh === null || vh < 240) errs.viewport_height = 'Height must be at least 240px' | |
| if (overlap !== null && vh !== null && overlap >= vh) { | |
| errs.overlap = 'Overlap must be less than viewport height' | |
| } | |
| const mx = num(settings.max_screenshots) | |
| if (mx === null || mx < 1) errs.max_screenshots = 'At least 1' | |
| return errs | |
| } | |
| case 'video': { | |
| if (!settings.resolution) errs.resolution = 'Pick a resolution' | |
| const q = num(settings.video_quality) | |
| if (q === null || q < 1 || q > 100) errs.video_quality = 'Between 1 and 100' | |
| const fps = num(settings.fps) | |
| if (fps === null || fps < 1 || fps > 120) errs.fps = 'Between 1 and 120' | |
| const sd = num(settings.slide_duration_sec) | |
| if (sd === null || sd <= 0) errs.slide_duration_sec = 'Must be greater than 0' | |
| return errs | |
| } | |
| case 'thumbnail': { | |
| if (settings.intro_thumbnail_enabled) { | |
| if (!autoThumbnailBuilder && !(settings.intro_thumbnail_filename ?? '').trim()) { | |
| errs.intro_thumbnail_filename = 'Upload an image first' | |
| } | |
| const d = num(settings.intro_thumbnail_duration_sec) | |
| if (d === null || d <= 0) { | |
| errs.intro_thumbnail_duration_sec = 'Duration must be greater than 0' | |
| } | |
| } | |
| if (settings.outro_thumbnail_enabled) { | |
| if (!autoThumbnailBuilder && !(settings.outro_thumbnail_filename ?? '').trim()) { | |
| errs.outro_thumbnail_filename = 'Upload an image first' | |
| } | |
| const d = num(settings.outro_thumbnail_duration_sec) | |
| if (d === null || d <= 0) { | |
| errs.outro_thumbnail_duration_sec = 'Duration must be greater than 0' | |
| } | |
| } | |
| return errs | |
| } | |
| case 'advanced': { | |
| if (!settings.auto_timing_screenshot_slides) { | |
| const f = num(settings.fixed_seconds_per_screenshot_slide) | |
| if (f === null || f <= 0) { | |
| errs.fixed_seconds_per_screenshot_slide = 'Seconds must be greater than 0' | |
| } | |
| } | |
| return errs | |
| } | |
| } | |
| } | |
| /** Scroll the first error field on a step into view and focus it. */ | |
| function focusFirstError(stepId: StepId, errs: FieldErrors) { | |
| const first = Object.keys(errs)[0] | |
| if (!first) return | |
| // Defer so the inline error nodes have rendered. | |
| setTimeout(() => { | |
| const el = document.getElementById(fieldId(stepId, first)) | |
| if (el) { | |
| el.scrollIntoView({ behavior: 'smooth', block: 'center' }) | |
| if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) { | |
| el.focus({ preventScroll: true }) | |
| } | |
| } | |
| }, 0) | |
| } | |
| const fieldId = (step: StepId, name: string) => `field-${step}-${name}` | |
| // βββ Page ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export default function TextToVideo({ sourceMode = 'text' }: { sourceMode?: SourceMode }) { | |
| const nav = useNavigate() | |
| const tracked = useTrackedGenerate(sourceMode === 'html' ? 'html-to-video' : 'text-to-video') | |
| const { state, cancel } = tracked | |
| const generateSource = sourceMode === 'html' ? tracked.generateFromHtml : tracked.generate | |
| const running = false | |
| const [text, setText] = useState('') | |
| const { settings: appSettings } = useSettings() | |
| const [settings, setSettings] = useState<GenerateSettings>(() => ({ | |
| ...DEFAULT_SETTINGS, | |
| output_format: sourceMode === 'html' ? 'video' : appSettings.defaultOutputFormat, | |
| // When the global Auto-thumbnail-builder preference is on, default | |
| // both per-run slots to enabled so the wizard mounts with the same | |
| // intent the user expressed in Settings. They can still toggle each | |
| // slot off independently before submitting. | |
| intro_thumbnail_enabled: | |
| appSettings.autoThumbnailBuilder | |
| ? true | |
| : DEFAULT_SETTINGS.intro_thumbnail_enabled, | |
| outro_thumbnail_enabled: | |
| appSettings.autoThumbnailBuilder | |
| ? true | |
| : DEFAULT_SETTINGS.outro_thumbnail_enabled, | |
| })) | |
| const [lastRunSnapshot, setLastRunSnapshot] = useState<LastRunSnapshot | null>(() => | |
| readLastRunSnapshot(sourceMode), | |
| ) | |
| const [replaceTargets, setReplaceTargets] = useState<ReplacementTargets | null>(null) | |
| const [stepId, setStepId] = useState<StepId>('project') | |
| const [showPreflight, setShowPreflight] = useState(false) | |
| const [autoThumbnailPreviewUrl, setAutoThumbnailPreviewUrl] = useState<string | null>(null) | |
| const [autoThumbnailError, setAutoThumbnailError] = useState<string | null>(null) | |
| const [autoThumbnailSaving, setAutoThumbnailSaving] = useState(false) | |
| const [autoThumbnailEditOpen, setAutoThumbnailEditOpen] = useState(false) | |
| const preflightProceedingRef = useRef(false) | |
| /** Step ids whose inline errors should be visible (only populated after the | |
| * user clicks Next on an invalid step). Silent until then. */ | |
| const [erroredSteps, setErroredSteps] = useState<Set<StepId>>(new Set()) | |
| /** C1: live-validation toggle. Once any step has been touched / visited | |
| * past the first one, we set this and per-step error icons appear in | |
| * the tab strip even before the user clicks Next. */ | |
| const [liveValidate, setLiveValidate] = useState(false) | |
| /** C3: surface a "you have unsaved progress" prompt whenever a draft was | |
| * found at mount time and not yet consumed. */ | |
| const [draftSnapshot, setDraftSnapshot] = useState<LastRunSnapshot | null>(() => | |
| readDraftSnapshot(sourceMode), | |
| ) | |
| useEffect(() => { | |
| const draft = consumeProcessEditHandoff(sourceMode === 'html' ? 'html-to-video' : 'text-to-video') | |
| if (!draft) return | |
| setText(draft.text) | |
| setSettings((prev) => ({ ...prev, ...draft.settings })) | |
| setReplaceTargets(draft.replaceTargets) | |
| setStepId('project') | |
| setErroredSteps(new Set()) | |
| setDraftSnapshot(null) | |
| }, []) | |
| const set = <K extends keyof GenerateSettings>(key: K, v: GenerateSettings[K]) => { | |
| setSettings((prev) => ({ ...prev, [key]: v })) | |
| // C1: as soon as the user starts editing, flip on live validation so | |
| // the per-step error icons update without waiting for a Next click. | |
| if (!liveValidate) setLiveValidate(true) | |
| } | |
| /** | |
| * C3: every 5s while the user is making progress, snapshot the current | |
| * state into localStorage. We skip the save when the page is idle (no | |
| * text and no overrides past defaults) so we don't write empty drafts. | |
| */ | |
| useEffect(() => { | |
| const id = window.setInterval(() => { | |
| const hasContent = | |
| text.trim().length > 0 || | |
| (settings.title ?? '').trim().length > 0 || | |
| (settings.auto_thumbnail_chapter_num ?? '').trim().length > 0 || | |
| (settings.intro_thumbnail_filename ?? '').trim().length > 0 || | |
| (settings.outro_thumbnail_filename ?? '').trim().length > 0 | |
| if (!hasContent) return | |
| saveDraftSnapshot(text, settings, sourceMode) | |
| }, DRAFT_AUTOSAVE_MS) | |
| return () => window.clearInterval(id) | |
| }, [text, settings, sourceMode]) | |
| // Beforeunload safety: write a final snapshot synchronously when the user | |
| // closes the tab. The 5s interval may have just fired; this is a belt for | |
| // the case where they edit the very last 4s before quitting. | |
| useEffect(() => { | |
| const handler = () => saveDraftSnapshot(text, settings, sourceMode) | |
| window.addEventListener('beforeunload', handler) | |
| return () => window.removeEventListener('beforeunload', handler) | |
| }, [text, settings, sourceMode]) | |
| const restoreDraft = () => { | |
| if (!draftSnapshot) return | |
| setSettings((prev) => ({ ...prev, ...draftSnapshot.settings })) | |
| if (typeof draftSnapshot.text === 'string') setText(draftSnapshot.text) | |
| setStepId('project') | |
| setErroredSteps(new Set()) | |
| setDraftSnapshot(null) | |
| } | |
| const dismissDraft = () => { | |
| clearDraftSnapshot(sourceMode) | |
| setDraftSnapshot(null) | |
| } | |
| const shouldAutoBuildThumbnail = | |
| sourceMode === 'text' && | |
| appSettings.autoThumbnailBuilder && | |
| (settings.output_format === 'video' || settings.output_format === 'pptx') | |
| useEffect(() => { | |
| // Live preview is only used as a placeholder for the intro tile when no | |
| // file has been saved yet. Once the user clicks "Use as intro thumbnail" | |
| // the saved server file takes over, so we stop computing it. (The | |
| // editor's own mini-preview is independent and always reflects the | |
| // current template.) | |
| if (!shouldAutoBuildThumbnail || (settings.intro_thumbnail_filename ?? '').trim()) { | |
| setAutoThumbnailPreviewUrl((prev) => { | |
| if (prev) URL.revokeObjectURL(prev) | |
| return null | |
| }) | |
| return | |
| } | |
| let cancelled = false | |
| let objectUrl: string | null = null | |
| buildAutoThumbnailFile(settings, text) | |
| .then((file) => { | |
| if (cancelled) return | |
| objectUrl = URL.createObjectURL(file) | |
| setAutoThumbnailPreviewUrl((prev) => { | |
| if (prev) URL.revokeObjectURL(prev) | |
| return objectUrl | |
| }) | |
| }) | |
| .catch((err) => { | |
| if (!cancelled) setAutoThumbnailError(err instanceof Error ? err.message : String(err)) | |
| }) | |
| return () => { | |
| cancelled = true | |
| if (objectUrl) URL.revokeObjectURL(objectUrl) | |
| } | |
| }, [ | |
| shouldAutoBuildThumbnail, | |
| settings.class_name, | |
| settings.subject, | |
| settings.title, | |
| settings.auto_thumbnail_side_image_url, | |
| settings.auto_thumbnail_chapter_num, | |
| settings.auto_thumbnail_year, | |
| settings.auto_thumbnail_chapter_prefix, | |
| settings.auto_thumbnail_image_offset_x, | |
| settings.auto_thumbnail_image_offset_y, | |
| settings.auto_thumbnail_image_zoom, | |
| settings.auto_thumbnail_canvas_background, | |
| settings.auto_thumbnail_overrides, | |
| settings.auto_thumbnail_added_elements, | |
| settings.auto_thumbnail_hidden_elements, | |
| settings.intro_thumbnail_filename, | |
| settings, | |
| text, | |
| ]) | |
| useEffect(() => { | |
| return () => { | |
| const url = settings.auto_thumbnail_side_image_url | |
| if (url?.startsWith('blob:')) URL.revokeObjectURL(url) | |
| const outroUrl = settings.auto_thumbnail_outro_side_image_url | |
| if (outroUrl?.startsWith('blob:')) URL.revokeObjectURL(outroUrl) | |
| } | |
| }, [settings.auto_thumbnail_side_image_url, settings.auto_thumbnail_outro_side_image_url]) | |
| // Compute the chapter / unit number that *would* be used by the | |
| // auto-thumbnail renderer right now. Manual override beats auto-detection | |
| // beats the "Chapter 1" fallback. Used to derive the outro number, which | |
| // is one greater than whatever the intro renders with. | |
| const currentChapterNum = (): string => { | |
| const manual = (settings.auto_thumbnail_chapter_num ?? '').trim() | |
| if (manual) return manual | |
| const detected = detectChapterMeta(text, settings) | |
| if (detected) return detected.num | |
| return '1' | |
| } | |
| const useAutoThumbnailNow = async (slot: 'intro' | 'outro' | 'both' = 'intro') => { | |
| if (autoThumbnailSaving) return | |
| setAutoThumbnailSaving(true) | |
| setAutoThumbnailError(null) | |
| try { | |
| // 2Γ pixel ratio gives a 3840Γ2160 master file for sharper YouTube | |
| // uploads; the default 1.5Γ already exceeds the on-screen 1920Γ1080. | |
| const pixelRatio = settings.auto_thumbnail_export_2x ? 2 : 1.5 | |
| // Build the intro file from the current settings as-is. | |
| // Build the outro file from a clone with `auto_thumbnail_chapter_num` | |
| // bumped by one β so unit 2's outro reads "Unit 3", chapter 5's outro | |
| // reads "Chapter 6", ΰ€ͺΰ€Ύΰ€ ΰ₯ͺ's outro reads ΰ€ͺΰ€Ύΰ€ ΰ₯«, etc. The on-screen | |
| // settings stay at their current value so re-saving the intro after | |
| // doesn't accidentally bump it. (Use for both = intro at N + outro at | |
| // N+1 in one click.) | |
| let introFile: File | null = null | |
| if (slot === 'intro' || slot === 'both') { | |
| introFile = await buildAutoThumbnailFile(settings, text, pixelRatio) | |
| } | |
| let outroFile: File | null = null | |
| if (slot === 'outro' || slot === 'both') { | |
| const outroSettings: GenerateSettings = { | |
| ...settings, | |
| title: settings.auto_thumbnail_outro_title?.trim() || settings.title, | |
| auto_thumbnail_side_image_url: | |
| settings.auto_thumbnail_outro_side_image_url || settings.auto_thumbnail_side_image_url, | |
| auto_thumbnail_chapter_num: incrementChapterNum(currentChapterNum()), | |
| } | |
| outroFile = await buildAutoThumbnailFile(outroSettings, text, pixelRatio) | |
| } | |
| // Each slot uploads its own file so updating one never disturbs the | |
| // other. ('both' uploads twice; tiny cost vs. the surprise of one | |
| // shared file overwriting both slots when only one was edited.) | |
| if (introFile) { | |
| const { filename } = await api.uploadThumbnail(introFile) | |
| setSettings((prev) => ({ | |
| ...prev, | |
| intro_thumbnail_enabled: true, | |
| intro_thumbnail_filename: filename, | |
| auto_thumbnail_generated: true, | |
| })) | |
| } | |
| if (outroFile) { | |
| const { filename } = await api.uploadThumbnail(outroFile) | |
| setSettings((prev) => ({ | |
| ...prev, | |
| outro_thumbnail_enabled: true, | |
| outro_thumbnail_filename: filename, | |
| auto_thumbnail_outro_generated: true, | |
| })) | |
| } | |
| } catch (err) { | |
| setAutoThumbnailError(err instanceof Error ? err.message : String(err)) | |
| } finally { | |
| setAutoThumbnailSaving(false) | |
| } | |
| } | |
| const setAutoThumbnailSideImage = (file: File | null) => { | |
| setSettings((prev) => { | |
| const previousUrl = prev.auto_thumbnail_side_image_url | |
| if (previousUrl?.startsWith('blob:')) URL.revokeObjectURL(previousUrl) | |
| return { | |
| ...prev, | |
| auto_thumbnail_side_image_url: file ? URL.createObjectURL(file) : undefined, | |
| } | |
| }) | |
| } | |
| const setAutoThumbnailOutroSideImage = (file: File | null) => { | |
| setSettings((prev) => { | |
| const previousUrl = prev.auto_thumbnail_outro_side_image_url | |
| if (previousUrl?.startsWith('blob:')) URL.revokeObjectURL(previousUrl) | |
| return { | |
| ...prev, | |
| auto_thumbnail_outro_side_image_url: file ? URL.createObjectURL(file) : undefined, | |
| } | |
| }) | |
| } | |
| // Swap intro β outro thumbnail file references. Useful when the end-card | |
| // saved as outro of one unit should become the intro of the next ("Reuse | |
| // previous run" β swap β ready). Files on disk are untouched; only the | |
| // pointers and the auto-generated flags are exchanged. | |
| const swapIntroOutroThumbnails = () => { | |
| setSettings((prev) => ({ | |
| ...prev, | |
| intro_thumbnail_filename: prev.outro_thumbnail_filename, | |
| outro_thumbnail_filename: prev.intro_thumbnail_filename, | |
| intro_thumbnail_enabled: | |
| prev.outro_thumbnail_filename?.trim() ? true : prev.intro_thumbnail_enabled, | |
| outro_thumbnail_enabled: | |
| prev.intro_thumbnail_filename?.trim() ? true : prev.outro_thumbnail_enabled, | |
| auto_thumbnail_generated: prev.auto_thumbnail_outro_generated, | |
| auto_thumbnail_outro_generated: prev.auto_thumbnail_generated, | |
| })) | |
| } | |
| const perStepErrors: Record<StepId, FieldErrors> = useMemo(() => ({ | |
| project: validateStep('project', settings, text, sourceMode, shouldAutoBuildThumbnail), | |
| content: validateStep('content', settings, text, sourceMode, shouldAutoBuildThumbnail), | |
| screenshot: validateStep('screenshot', settings, text, sourceMode, shouldAutoBuildThumbnail), | |
| video: validateStep('video', settings, text, sourceMode, shouldAutoBuildThumbnail), | |
| thumbnail: validateStep('thumbnail', settings, text, sourceMode, shouldAutoBuildThumbnail), | |
| advanced: validateStep('advanced', settings, text, sourceMode, shouldAutoBuildThumbnail), | |
| }), [settings, text, sourceMode, shouldAutoBuildThumbnail]) | |
| const stepValid = (id: StepId) => Object.keys(perStepErrors[id]).length === 0 | |
| // Visible steps depend on the chosen output format β irrelevant steps | |
| // (e.g. Video / Thumbnail for html / images output) are hidden so the user | |
| // doesn't fill fields that will never be used. | |
| const outputFormat: OutputFormat = settings.output_format ?? 'images' | |
| const visibleSteps = useMemo( | |
| () => STEP_DEFS.filter((s) => !s.hiddenFor?.includes(outputFormat)), | |
| [outputFormat], | |
| ) | |
| // If the user toggled output_format and hid the currently-selected step, | |
| // fall back to the project step *derived* (no effect, no cascading render). | |
| const activeStepId: StepId = visibleSteps.some((s) => s.id === stepId) ? stepId : 'project' | |
| const stepIndex = visibleSteps.findIndex((s) => s.id === activeStepId) | |
| const currentErrors = perStepErrors[activeStepId] | |
| // Errors are shown after the user first attempted to leave an invalid step. | |
| // Once shown, they stay live β the Field border flips red/green as the user | |
| // types β which is the normal "touched-then-validate-on-change" pattern. | |
| const showCurrentErrors = | |
| erroredSteps.has(activeStepId) && Object.keys(currentErrors).length > 0 | |
| /** A step (in the visible list) is reachable if every earlier visible step is valid. */ | |
| const canNavigateTo = (target: StepId): boolean => { | |
| const targetIdx = visibleSteps.findIndex((s) => s.id === target) | |
| if (targetIdx <= stepIndex) return true | |
| for (let i = 0; i < targetIdx; i++) { | |
| if (!stepValid(visibleSteps[i].id)) return false | |
| } | |
| return true | |
| } | |
| // Only the *visible* steps participate in the final validation. | |
| const allValid = visibleSteps.every((s) => stepValid(s.id)) | |
| const onStart = () => { | |
| // Surface every outstanding error at once and jump to the first broken step. | |
| if (!allValid) { | |
| const broken = visibleSteps.find((s) => !stepValid(s.id))! | |
| setErroredSteps(new Set(visibleSteps.filter((s) => !stepValid(s.id)).map((s) => s.id))) | |
| setStepId(broken.id) | |
| focusFirstError(broken.id, perStepErrors[broken.id]) | |
| return | |
| } | |
| preflightProceedingRef.current = false | |
| setShowPreflight(true) | |
| } | |
| const reuseLastRun = () => { | |
| if (!lastRunSnapshot) return | |
| // Full restore: text + every field the previous run set. Start from | |
| // the current baseline so anything *not* captured in the snapshot | |
| // (new settings added after the snapshot was saved) keeps its | |
| // current value instead of becoming `undefined`. | |
| setSettings((prev) => ({ ...prev, ...lastRunSnapshot.settings })) | |
| if (typeof lastRunSnapshot.text === 'string') setText(lastRunSnapshot.text) | |
| // After a restore, land the user back on the first step so they | |
| // can quickly confirm the restored values before running. | |
| setStepId('project') | |
| setErroredSteps(new Set()) | |
| } | |
| const onPreflightProceed = async () => { | |
| if (preflightProceedingRef.current) return | |
| preflightProceedingRef.current = true | |
| setShowPreflight(false) | |
| const payload: GenerateSettings = { ...settings } | |
| payload.class_name = (payload.class_name ?? '').trim() || undefined | |
| payload.subject = (payload.subject ?? '').trim() || undefined | |
| payload.title = (payload.title ?? '').trim() || undefined | |
| payload.concurrent_pipeline_runs = appSettings.concurrentPipelineRuns | |
| setAutoThumbnailError(null) | |
| // Only auto-build the intro / outro slots when the user has the | |
| // per-run toggle on. The global Auto-thumbnail-builder preference | |
| // means "if the user wants a thumbnail, generate it for them" β it | |
| // does NOT force-enable the slot. Letting the user toggle each slot | |
| // off skips the corresponding auto-build step. | |
| if (shouldAutoBuildThumbnail && payload.intro_thumbnail_enabled) { | |
| const existingIntroThumbnail = (payload.intro_thumbnail_filename ?? '').trim() | |
| if (!existingIntroThumbnail) { | |
| try { | |
| const pixelRatio = payload.auto_thumbnail_export_2x ? 2 : 1.5 | |
| const file = await buildAutoThumbnailFile(payload, text, pixelRatio) | |
| const { filename } = await api.uploadThumbnail(file) | |
| payload.intro_thumbnail_filename = filename | |
| payload.auto_thumbnail_generated = true | |
| setSettings((prev) => ({ | |
| ...prev, | |
| intro_thumbnail_filename: filename, | |
| auto_thumbnail_generated: true, | |
| })) | |
| } catch (err) { | |
| preflightProceedingRef.current = false | |
| setAutoThumbnailError(err instanceof Error ? err.message : String(err)) | |
| setStepId('thumbnail') | |
| return | |
| } | |
| } | |
| } | |
| if (shouldAutoBuildThumbnail && payload.outro_thumbnail_enabled) { | |
| try { | |
| const pixelRatio = payload.auto_thumbnail_export_2x ? 2 : 1.5 | |
| const existingOutroThumbnail = (payload.outro_thumbnail_filename ?? '').trim() | |
| if (!existingOutroThumbnail) { | |
| const outroPayload: GenerateSettings = { | |
| ...payload, | |
| title: payload.auto_thumbnail_outro_title?.trim() || payload.title, | |
| auto_thumbnail_side_image_url: | |
| payload.auto_thumbnail_outro_side_image_url || payload.auto_thumbnail_side_image_url, | |
| auto_thumbnail_chapter_num: incrementChapterNum(currentChapterNum()), | |
| } | |
| const file = await buildAutoThumbnailFile(outroPayload, text, pixelRatio) | |
| const { filename } = await api.uploadThumbnail(file) | |
| payload.outro_thumbnail_filename = filename | |
| payload.auto_thumbnail_outro_generated = true | |
| setSettings((prev) => ({ | |
| ...prev, | |
| outro_thumbnail_filename: filename, | |
| auto_thumbnail_outro_generated: true, | |
| })) | |
| } | |
| } catch (err) { | |
| preflightProceedingRef.current = false | |
| setAutoThumbnailError(err instanceof Error ? err.message : String(err)) | |
| setStepId('thumbnail') | |
| return | |
| } | |
| } | |
| // Snapshot the full wizard state (text + settings) so the next | |
| // session can restore everything via "Reuse previous run". | |
| setLastRunSnapshot(saveLastRunSnapshot(text, payload, sourceMode)) | |
| // C3: the run was actually started, so the in-progress draft is no | |
| // longer relevant β clear it. Reuse-previous-run still works through | |
| // the LAST_RUN snapshot we just saved. | |
| clearDraftSnapshot(sourceMode) | |
| // Enqueues and (if idle) kicks off immediately. Navigate right away so | |
| // the user sees either the running run or the queue entry without | |
| // staying on the wizard. | |
| const targets = replaceTargets | |
| const { queueId } = generateSource(text, payload, targets ? { replaceTargets: targets } : undefined) | |
| setReplaceTargets(null) | |
| nav(`/processes?queue=${encodeURIComponent(queueId)}`) | |
| } | |
| const goNext = () => { | |
| if (!stepValid(activeStepId)) { | |
| setErroredSteps((prev) => new Set(prev).add(activeStepId)) | |
| focusFirstError(activeStepId, currentErrors) | |
| return | |
| } | |
| if (stepIndex >= 0 && stepIndex < visibleSteps.length - 1) { | |
| setStepId(visibleSteps[stepIndex + 1].id) | |
| } | |
| } | |
| const goPrev = () => { | |
| if (stepIndex > 0) setStepId(visibleSteps[stepIndex - 1].id) | |
| } | |
| const onPickTab = (target: StepId) => { | |
| const targetIdx = visibleSteps.findIndex((s) => s.id === target) | |
| if (targetIdx <= stepIndex) { | |
| // Going backward β always allowed. | |
| setStepId(target) | |
| return | |
| } | |
| // Going forward β every visible step up to (and including) the current | |
| // step must be valid. Surface the first broken step's errors. | |
| for (let i = 0; i < targetIdx; i++) { | |
| const s = visibleSteps[i] | |
| if (!stepValid(s.id)) { | |
| setErroredSteps((prev) => new Set(prev).add(s.id)) | |
| setStepId(s.id) | |
| focusFirstError(s.id, perStepErrors[s.id]) | |
| return | |
| } | |
| } | |
| setStepId(target) | |
| } | |
| return ( | |
| <div className="container-form space-y-6"> | |
| <div> | |
| <div className="eyebrow"> | |
| <span className="h-1 w-1 rounded-full bg-brand-500" /> | |
| {sourceMode === 'html' ? 'Tool Β· HTML β Video' : 'Tool Β· Text β Video'} | |
| </div> | |
| <h1 className="h-page mt-2"> | |
| {sourceMode === 'html' ? 'HTML to Video' : 'Text to Video'} | |
| </h1> | |
| <p className="mt-2 text-sm text-muted"> | |
| Step through the wizard to configure the run. Nothing starts until you hit{' '} | |
| <span className="font-medium">Start Process</span> on the last step. | |
| </p> | |
| </div> | |
| {draftSnapshot && ( | |
| <Banner | |
| tone="warning" | |
| icon={<Sparkles size={16} />} | |
| title="Unsaved draft from your last visit" | |
| actions={ | |
| <> | |
| <button type="button" className="btn-secondary btn-sm" onClick={restoreDraft}> | |
| Resume draft | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm !text-amber-900 hover:!bg-amber-100 dark:!text-amber-200" | |
| onClick={dismissDraft} | |
| > | |
| Discard | |
| </button> | |
| </> | |
| } | |
| > | |
| {draftSnapshot.text.trim().length > 0 | |
| ? `${draftSnapshot.text.length.toLocaleString()} characters of text plus settings.` | |
| : 'Project info and settings preserved.'} | |
| {' '}Resume where you left off, or discard. | |
| </Banner> | |
| )} | |
| <Tabs | |
| steps={visibleSteps} | |
| currentId={activeStepId} | |
| onPick={onPickTab} | |
| canNavigateTo={canNavigateTo} | |
| stepValid={stepValid} | |
| showErrors={(id) => liveValidate || erroredSteps.has(id)} | |
| /> | |
| <div | |
| role="tabpanel" | |
| id={`wizard-panel-${activeStepId}`} | |
| aria-labelledby={`wizard-tab-${activeStepId}`} | |
| tabIndex={0} | |
| className="card space-y-6" | |
| > | |
| {activeStepId === 'project' && ( | |
| <ProjectStep | |
| settings={settings} | |
| onChange={set} | |
| lastRunSnapshot={lastRunSnapshot} | |
| onReuseLast={reuseLastRun} | |
| running={running} | |
| errors={showCurrentErrors ? currentErrors : {}} | |
| sourceMode={sourceMode} | |
| classOptions={ | |
| appSettings.customClassOptions.length > 0 | |
| ? appSettings.customClassOptions | |
| : DEFAULT_CLASS_OPTIONS | |
| } | |
| subjectOptions={ | |
| appSettings.customSubjectOptions.length > 0 | |
| ? appSettings.customSubjectOptions | |
| : DEFAULT_SUBJECT_OPTIONS | |
| } | |
| /> | |
| )} | |
| {activeStepId === 'content' && ( | |
| <ContentStep | |
| text={text} | |
| onText={setText} | |
| settings={settings} | |
| onChange={set} | |
| running={running} | |
| errors={showCurrentErrors ? currentErrors : {}} | |
| sourceMode={sourceMode} | |
| /> | |
| )} | |
| {activeStepId === 'screenshot' && ( | |
| <ScreenshotStep | |
| settings={settings} | |
| onChange={set} | |
| errors={showCurrentErrors ? currentErrors : {}} | |
| /> | |
| )} | |
| {activeStepId === 'video' && ( | |
| <VideoStep | |
| settings={settings} | |
| onChange={set} | |
| errors={showCurrentErrors ? currentErrors : {}} | |
| /> | |
| )} | |
| {activeStepId === 'thumbnail' && ( | |
| <ThumbnailStep | |
| settings={settings} | |
| text={text} | |
| onChange={set} | |
| errors={showCurrentErrors ? currentErrors : {}} | |
| autoThumbnailBuilder={shouldAutoBuildThumbnail} | |
| autoThumbnailPreviewUrl={autoThumbnailPreviewUrl} | |
| autoThumbnailError={autoThumbnailError} | |
| autoThumbnailSaving={autoThumbnailSaving} | |
| autoThumbnailEditOpen={autoThumbnailEditOpen} | |
| onUseAutoThumbnail={useAutoThumbnailNow} | |
| onToggleAutoThumbnailEdit={() => setAutoThumbnailEditOpen((v) => !v)} | |
| onAutoThumbnailSideImage={setAutoThumbnailSideImage} | |
| onAutoThumbnailOutroSideImage={setAutoThumbnailOutroSideImage} | |
| onSwapIntroOutroThumbnails={swapIntroOutroThumbnails} | |
| /> | |
| )} | |
| {activeStepId === 'advanced' && ( | |
| <AdvancedStep | |
| settings={settings} | |
| onChange={set} | |
| canFinish={allValid} | |
| onStart={onStart} | |
| running={running} | |
| state={state} | |
| cancel={cancel} | |
| errors={showCurrentErrors ? currentErrors : {}} | |
| /> | |
| )} | |
| {showCurrentErrors && Object.keys(currentErrors).length > 0 && ( | |
| <div className="flex items-start gap-2 rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200"> | |
| <AlertCircle size={16} className="mt-0.5 shrink-0" /> | |
| <div> | |
| Fix {Object.keys(currentErrors).length} issue | |
| {Object.keys(currentErrors).length === 1 ? '' : 's'} on this step before continuing. | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Back / Next + C14 Start-on-every-step β sticky bottom action bar | |
| so the primary CTA is always reachable on long steps. */} | |
| <div className="sticky-action-bar justify-between"> | |
| <button | |
| type="button" | |
| className="btn-secondary" | |
| onClick={goPrev} | |
| disabled={stepIndex === 0 || running} | |
| > | |
| <ArrowLeft size={14} /> Back | |
| </button> | |
| <div className="text-xs text-muted tabular" data-slot="number"> | |
| Step {Math.max(stepIndex + 1, 1)} of {visibleSteps.length} | |
| </div> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| {/* C14: as soon as every visible step validates, expose Start | |
| Process here too β no need to tab all the way to Advanced. */} | |
| {allValid && activeStepId !== 'advanced' && ( | |
| <button | |
| type="button" | |
| className="btn-primary" | |
| onClick={onStart} | |
| disabled={running} | |
| title="All steps look good β start the run now" | |
| > | |
| <Play size={14} /> Start Process | |
| </button> | |
| )} | |
| {activeStepId !== 'advanced' && ( | |
| <button | |
| type="button" | |
| className={allValid ? 'btn-secondary' : 'btn-primary'} | |
| onClick={goNext} | |
| disabled={running} | |
| > | |
| Next <ArrowRight size={14} /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {showPreflight && ( | |
| <PreflightModal | |
| outputFormat={settings.output_format ?? 'images'} | |
| onCancel={() => setShowPreflight(false)} | |
| onProceed={onPreflightProceed} | |
| /> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // βββ Tabs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function Tabs({ | |
| steps, | |
| currentId, | |
| onPick, | |
| canNavigateTo, | |
| stepValid, | |
| showErrors, | |
| }: { | |
| steps: StepDef[] | |
| currentId: StepId | |
| onPick: (id: StepId) => void | |
| canNavigateTo: (id: StepId) => boolean | |
| stepValid: (id: StepId) => boolean | |
| showErrors: (id: StepId) => boolean | |
| }) { | |
| const currentIndex = steps.findIndex((s) => s.id === currentId) | |
| const tabRefs = useRef<Array<HTMLButtonElement | null>>([]) | |
| // Roving-tabindex + arrow-key navigation per WAI-ARIA Authoring Practices | |
| // tablist pattern. Only the active tab is in the tab order; arrows walk | |
| // siblings, Home/End jump to the ends, Enter/Space activates. | |
| const focusTab = (idx: number) => { | |
| const n = steps.length | |
| const target = ((idx % n) + n) % n | |
| tabRefs.current[target]?.focus() | |
| } | |
| const onKey = (e: React.KeyboardEvent, idx: number) => { | |
| switch (e.key) { | |
| case 'ArrowRight': | |
| e.preventDefault() | |
| focusTab(idx + 1) | |
| break | |
| case 'ArrowLeft': | |
| e.preventDefault() | |
| focusTab(idx - 1) | |
| break | |
| case 'Home': | |
| e.preventDefault() | |
| focusTab(0) | |
| break | |
| case 'End': | |
| e.preventDefault() | |
| focusTab(steps.length - 1) | |
| break | |
| // Enter / Space fall through to the native button click β no special | |
| // handling needed, onClick fires either way. | |
| } | |
| } | |
| return ( | |
| <ol role="tablist" aria-label="Wizard steps" className="flex w-full flex-wrap items-center gap-1"> | |
| {steps.map((s, i) => { | |
| const active = s.id === currentId | |
| const valid = stepValid(s.id) | |
| const isDone = i < currentIndex && valid | |
| const reachable = canNavigateTo(s.id) | |
| // C2: per-step error badge β only when validation has been | |
| // surfaced (after Next on an invalid step, or once the user has | |
| // started typing) AND the step is actually invalid AND it's not | |
| // the one currently being edited. | |
| const hasError = showErrors(s.id) && !valid && !active | |
| return ( | |
| <li key={s.id} role="presentation" className="flex min-w-0 flex-1 items-center gap-1"> | |
| <button | |
| ref={(el) => { | |
| tabRefs.current[i] = el | |
| }} | |
| type="button" | |
| role="tab" | |
| id={`wizard-tab-${s.id}`} | |
| aria-selected={active} | |
| aria-controls={`wizard-panel-${s.id}`} | |
| aria-invalid={hasError || undefined} | |
| aria-disabled={!reachable || undefined} | |
| tabIndex={active ? 0 : -1} | |
| onClick={() => onPick(s.id)} | |
| onKeyDown={(e) => onKey(e, i)} | |
| title={ | |
| !reachable | |
| ? 'Fill earlier steps first' | |
| : hasError | |
| ? `${s.label} β needs attention` | |
| : s.label | |
| } | |
| className={ | |
| 'flex min-w-0 flex-1 items-center gap-2 rounded-md border px-3 py-2 text-xs font-medium transition-colors ' + | |
| (active | |
| ? 'border-brand-500 bg-brand-50 text-brand-700 dark:border-brand-400 dark:bg-brand-500/10 dark:text-brand-200' | |
| : hasError | |
| ? 'border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200' | |
| : isDone | |
| ? 'border-brand-200 bg-brand-50/60 text-brand-600 hover:bg-brand-50 dark:border-brand-500/30 dark:bg-brand-500/10 dark:text-brand-200' | |
| : reachable | |
| ? 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-800 dark:border-white/10 dark:bg-white/[0.03] dark:text-slate-300 dark:hover:text-slate-100' | |
| : 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400 dark:border-white/5 dark:bg-white/[0.02] dark:text-slate-500') | |
| } | |
| > | |
| <span | |
| className={ | |
| 'flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] ' + | |
| (active | |
| ? 'bg-brand-500 text-white' | |
| : hasError | |
| ? 'bg-rose-500 text-white' | |
| : isDone | |
| ? 'bg-brand-500/80 text-white' | |
| : reachable | |
| ? 'bg-slate-200 text-slate-600 dark:bg-white/10 dark:text-slate-300' | |
| : 'bg-slate-100 text-slate-400 dark:bg-white/5 dark:text-slate-500') | |
| } | |
| > | |
| {hasError ? ( | |
| <AlertTriangle size={12} /> | |
| ) : isDone ? ( | |
| <Check size={12} /> | |
| ) : ( | |
| i + 1 | |
| )} | |
| </span> | |
| <span className="truncate">{s.shortLabel}</span> | |
| </button> | |
| </li> | |
| ) | |
| })} | |
| </ol> | |
| ) | |
| } | |
| // βββ Step bodies βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| type Setter = <K extends keyof GenerateSettings>(k: K, v: GenerateSettings[K]) => void | |
| function StepHeader({ title, subtitle }: { title: string; subtitle?: string }) { | |
| return ( | |
| <div> | |
| <h2 className="font-display text-xl font-semibold text-slate-900 dark:text-slate-50"> | |
| {title} | |
| </h2> | |
| {subtitle && ( | |
| <p className="mt-1 text-sm text-slate-600 dark:text-slate-400">{subtitle}</p> | |
| )} | |
| </div> | |
| ) | |
| } | |
| function ProjectStep({ | |
| settings, | |
| onChange, | |
| lastRunSnapshot, | |
| onReuseLast, | |
| running, | |
| errors, | |
| sourceMode, | |
| classOptions, | |
| subjectOptions, | |
| }: { | |
| settings: GenerateSettings | |
| onChange: Setter | |
| lastRunSnapshot: LastRunSnapshot | null | |
| onReuseLast: () => void | |
| running: boolean | |
| errors: FieldErrors | |
| sourceMode: SourceMode | |
| classOptions: readonly string[] | |
| subjectOptions: readonly string[] | |
| }) { | |
| // "Reuse previous run" is offered whenever we have any captured | |
| // signal from last time β project info, typed text, or non-default | |
| // advanced settings. This is broader than the previous | |
| // project-only gate. | |
| const canReuse = Boolean( | |
| lastRunSnapshot && | |
| (lastRunSnapshot.text?.trim() || | |
| lastRunSnapshot.settings?.class_name || | |
| lastRunSnapshot.settings?.subject || | |
| lastRunSnapshot.settings?.title || | |
| lastRunSnapshot.settings?.output_format || | |
| lastRunSnapshot.settings?.model_choice), | |
| ) | |
| return ( | |
| <> | |
| <div className="flex flex-wrap items-start justify-between gap-3"> | |
| <StepHeader | |
| title="Project info" | |
| subtitle="Enter class, subject, and chapter title before adding content. The chapter title is also used by the auto thumbnail builder." | |
| /> | |
| {canReuse && ( | |
| <button | |
| type="button" | |
| className="btn-secondary" | |
| onClick={onReuseLast} | |
| disabled={running} | |
| title="Restore the text, project info, and all settings from your last run" | |
| > | |
| Reuse previous run | |
| </button> | |
| )} | |
| </div> | |
| <div className="grid gap-4 sm:grid-cols-2"> | |
| <Field label="Class" required error={errors.class_name}> | |
| <select | |
| id={fieldId('project', 'class_name')} | |
| className={inputCls(errors.class_name)} | |
| value={settings.class_name ?? 'Class 10'} | |
| onChange={(e) => onChange('class_name', e.target.value)} | |
| disabled={running} | |
| > | |
| {classOptions.map((value) => ( | |
| <option key={value} value={value}> | |
| {value} | |
| </option> | |
| ))} | |
| </select> | |
| </Field> | |
| <Field label="Subject" required error={errors.subject}> | |
| <select | |
| id={fieldId('project', 'subject')} | |
| className={inputCls(errors.subject)} | |
| value={settings.subject ?? 'Nepali'} | |
| onChange={(e) => onChange('subject', e.target.value)} | |
| disabled={running} | |
| > | |
| {subjectOptions.map((value) => ( | |
| <option key={value} value={value}> | |
| {value} | |
| </option> | |
| ))} | |
| </select> | |
| </Field> | |
| <Field label="Chapter title for video and thumbnail" required className="sm:col-span-2" error={errors.title}> | |
| <input | |
| id={fieldId('project', 'title')} | |
| className={inputCls(errors.title)} | |
| placeholder="Chapter 2 - ΰ€Έΰ₯ΰ€΅ΰ€Ύΰ€¦" | |
| value={settings.title ?? ''} | |
| onChange={(e) => onChange('title', e.target.value)} | |
| disabled={running} | |
| /> | |
| </Field> | |
| </div> | |
| <OutputFormatPicker | |
| value={settings.output_format ?? 'images'} | |
| onChange={(v) => onChange('output_format', v)} | |
| running={running} | |
| error={errors.output_format} | |
| sourceMode={sourceMode} | |
| /> | |
| </> | |
| ) | |
| } | |
| /** Output-format selector with Windows-only options disabled upstream | |
| * when the backend platform isn't Windows. We still show a heads-up | |
| * for Windows hosts so users know the preflight will verify PPT. */ | |
| function OutputFormatPicker({ | |
| value, | |
| onChange, | |
| running, | |
| error, | |
| sourceMode, | |
| }: { | |
| value: OutputFormat | |
| onChange: (v: OutputFormat) => void | |
| running: boolean | |
| error?: string | |
| sourceMode: SourceMode | |
| }) { | |
| const { platform, videoEngineReady, pptxReady } = useBackendCapabilities() | |
| const engineOnly = (v: OutputFormat) => v === 'pptx' || v === 'video' | |
| return ( | |
| <div id={fieldId('project', 'output_format')}> | |
| <div className="label">Output format</div> | |
| <div className="grid gap-2 sm:grid-cols-2"> | |
| {OUTPUT_OPTIONS.filter((o) => sourceMode === 'text' || o.value !== 'html').map((o) => { | |
| const active = value === o.value | |
| // Gate engine-dependent outputs on the new preflight | |
| // ``video_engine`` capability so the MoviePy branch unlocks | |
| // MP4 export on Linux. PPTX still needs PowerPoint COM. | |
| const pptxBlocked = | |
| o.value === 'pptx' && platform !== 'unknown' && !pptxReady | |
| const videoBlocked = | |
| o.value === 'video' && platform !== 'unknown' && !videoEngineReady | |
| const disabledByPlatform = pptxBlocked || videoBlocked | |
| const disabled = running || disabledByPlatform | |
| const tooltip = pptxBlocked | |
| ? 'PowerPoint deck export requires a Windows host with PowerPoint installed β this backend reports a non-Windows OS.' | |
| : videoBlocked | |
| ? 'No video engine available β install MoviePy (pip install moviepy) or run on Windows with PowerPoint.' | |
| : undefined | |
| return ( | |
| <button | |
| key={o.value} | |
| type="button" | |
| onClick={() => !disabled && onChange(o.value)} | |
| disabled={disabled} | |
| aria-disabled={disabled} | |
| title={tooltip} | |
| className={ | |
| 'flex w-full items-start gap-3 rounded-md border px-3 py-3 text-left transition-colors ' + | |
| (active | |
| ? 'border-brand-500 bg-brand-50 dark:border-brand-400 dark:bg-brand-500/10' | |
| : 'border-slate-200 bg-white hover:border-slate-300 dark:border-white/10 dark:bg-white/[0.03]') + | |
| (disabledByPlatform ? ' cursor-not-allowed opacity-60 hover:border-slate-200' : '') | |
| } | |
| > | |
| <div | |
| className={ | |
| 'mt-0.5 flex h-12 w-16 shrink-0 items-center justify-center rounded-md border ' + | |
| (active | |
| ? 'border-brand-300 bg-brand-100/60 dark:border-brand-400/40 dark:bg-brand-500/15' | |
| : 'border-slate-200 bg-slate-50 dark:border-white/10 dark:bg-white/[0.04]') | |
| } | |
| aria-hidden | |
| > | |
| <OutputFormatThumb format={o.value} active={active} /> | |
| </div> | |
| <div className="min-w-0 flex-1"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm font-medium text-slate-900 dark:text-slate-50"> | |
| {o.label} | |
| </span> | |
| {o.value === 'pptx' && ( | |
| <span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-slate-500 dark:bg-white/10 dark:text-slate-300"> | |
| Windows | |
| </span> | |
| )} | |
| {o.value === 'video' && engineOnly(o.value) && videoEngineReady && ( | |
| <span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"> | |
| {platform === 'windows' ? 'PowerPoint' : 'MoviePy'} | |
| </span> | |
| )} | |
| </div> | |
| <div className="mt-0.5 text-xs text-slate-500 dark:text-slate-400"> | |
| {pptxBlocked | |
| ? 'Unavailable β backend isnβt Windows.' | |
| : videoBlocked | |
| ? 'Unavailable β install MoviePy or run on Windows.' | |
| : o.desc} | |
| </div> | |
| </div> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| {error && <FieldError message={error} />} | |
| <WindowsOnlyWarning outputFormat={value} /> | |
| </div> | |
| ) | |
| } | |
| /** | |
| * C5: tiny visual mini-illustration of each output format. Pure SVG so it | |
| * scales cleanly in dark/light themes without bringing in a sprite asset. | |
| * Sits in the 16Γ12 (h-12 w-16) frame next to each format card. | |
| */ | |
| function OutputFormatThumb({ format, active }: { format: OutputFormat; active: boolean }) { | |
| const stroke = active ? 'currentColor' : 'rgb(148 163 184)' // brand-active vs slate-400 | |
| const fill = active ? 'rgba(99,102,241,0.12)' : 'rgba(148,163,184,0.12)' | |
| const accent = active ? 'rgb(99 102 241)' : 'rgb(100 116 139)' // brand-500 vs slate-500 | |
| const svgClass = active ? 'text-brand-600 dark:text-brand-300' : 'text-slate-500 dark:text-slate-300' | |
| switch (format) { | |
| case 'html': | |
| return ( | |
| <svg viewBox="0 0 56 36" width={48} height={32} className={svgClass}> | |
| <rect x="3" y="3" width="50" height="30" rx="3" fill={fill} stroke={stroke} strokeWidth="1.2" /> | |
| <text x="28" y="22" textAnchor="middle" fontSize="11" fontWeight="700" fill={accent} fontFamily="ui-monospace, Menlo, monospace">{'</>'}</text> | |
| </svg> | |
| ) | |
| case 'images': | |
| return ( | |
| <svg viewBox="0 0 56 36" width={48} height={32} className={svgClass}> | |
| <rect x="3" y="3" width="20" height="30" rx="2" fill={fill} stroke={stroke} strokeWidth="1.2" /> | |
| <rect x="18" y="3" width="20" height="30" rx="2" fill={fill} stroke={stroke} strokeWidth="1.2" /> | |
| <rect x="33" y="3" width="20" height="30" rx="2" fill={fill} stroke={stroke} strokeWidth="1.2" /> | |
| <circle cx="40" cy="14" r="2.2" fill={accent} /> | |
| <path d="M36 25 L41 20 L47 26 L52 22 L52 31 L36 31 Z" fill={accent} opacity="0.7" /> | |
| </svg> | |
| ) | |
| case 'pptx': | |
| return ( | |
| <svg viewBox="0 0 56 36" width={48} height={32} className={svgClass}> | |
| <rect x="4" y="6" width="48" height="24" rx="2" fill={fill} stroke={stroke} strokeWidth="1.2" /> | |
| <rect x="9" y="11" width="22" height="3" rx="1" fill={accent} opacity="0.85" /> | |
| <rect x="9" y="17" width="32" height="2" rx="1" fill={accent} opacity="0.45" /> | |
| <rect x="9" y="21" width="28" height="2" rx="1" fill={accent} opacity="0.45" /> | |
| <rect x="9" y="25" width="20" height="2" rx="1" fill={accent} opacity="0.45" /> | |
| </svg> | |
| ) | |
| case 'video': | |
| return ( | |
| <svg viewBox="0 0 56 36" width={48} height={32} className={svgClass}> | |
| <rect x="3" y="3" width="50" height="30" rx="3" fill={fill} stroke={stroke} strokeWidth="1.2" /> | |
| <rect x="3" y="3" width="50" height="4" fill={accent} opacity="0.2" /> | |
| <rect x="3" y="29" width="50" height="4" fill={accent} opacity="0.2" /> | |
| <polygon points="22,12 22,24 34,18" fill={accent} /> | |
| </svg> | |
| ) | |
| } | |
| } | |
| function WindowsOnlyWarning({ outputFormat }: { outputFormat: OutputFormat }) { | |
| const { platform, videoEngineReady, pptxReady } = useBackendCapabilities() | |
| const needsEngine = outputFormat === 'pptx' || outputFormat === 'video' | |
| if (!needsEngine) return null | |
| if (outputFormat === 'pptx' && !pptxReady && platform !== 'unknown') { | |
| return ( | |
| <p className="mt-2 rounded-md border border-rose-300/60 bg-rose-50 px-3 py-2 text-xs text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"> | |
| <strong>PowerPoint deck</strong> export won't work on this backend β it requires a Windows | |
| host with PowerPoint installed. Pick <em>MP4 video</em>, <em>Screenshots</em>, or{' '} | |
| <em>HTML file</em> to avoid surprise output. | |
| </p> | |
| ) | |
| } | |
| if (outputFormat === 'video' && !videoEngineReady && platform !== 'unknown') { | |
| return ( | |
| <p className="mt-2 rounded-md border border-rose-300/60 bg-rose-50 px-3 py-2 text-xs text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"> | |
| <strong>MP4 video</strong> export won't work β no video engine is available on this | |
| backend. Install MoviePy (<code>pip install moviepy</code>) or run on a Windows host | |
| with PowerPoint installed. | |
| </p> | |
| ) | |
| } | |
| if (outputFormat === 'video' && platform !== 'windows') { | |
| return ( | |
| <p className="mt-2 text-xs text-slate-500 dark:text-slate-400"> | |
| MP4 export will use the <strong>MoviePy</strong> engine (libx264 ultrafast, 4K @ 30 fps). | |
| </p> | |
| ) | |
| } | |
| return null | |
| } | |
| /** | |
| * C7: card-based AI-model chooser. Each card shows the model's tradeoff | |
| * dimensions (speed / quality / context) at a glance instead of a raw | |
| * dropdown. Same canonical `model_choice` values as before so backend | |
| * routing is unchanged. | |
| */ | |
| type ModelTier = 'fast' | 'medium' | 'good' | 'best' | |
| const MODEL_OPTIONS: Array<{ | |
| value: string | |
| label: string | |
| vendor: string | |
| speed: ModelTier | |
| quality: ModelTier | |
| context: string | |
| blurb: string | |
| icon: typeof Zap | |
| }> = [ | |
| { value: 'default', label: 'Default', vendor: 'Qwen 3.5 122B', speed: 'good', quality: 'good', context: 'Standard', blurb: 'Balanced default for textbook chapters.', icon: Sparkles }, | |
| { value: 'fast', label: 'Fast (1M ctx)', vendor: 'DeepSeek V4 Flash', speed: 'best', quality: 'medium', context: '1M tokens', blurb: 'Whole books in one go, fastest turnaround.', icon: Zap }, | |
| { value: 'short', label: 'Shortest & fastest', vendor: 'Llama 3.1 8B', speed: 'best', quality: 'fast', context: 'Standard', blurb: 'Tiny chapters / quick drafts.', icon: Gauge }, | |
| { value: 'balanced', label: 'Balanced', vendor: 'GLM 4.7', speed: 'good', quality: 'good', context: 'Standard', blurb: 'Higher fidelity for the same time budget.', icon: Sliders }, | |
| { value: 'quality', label: 'Highest quality', vendor: 'DeepSeek V3.2', speed: 'medium', quality: 'best', context: 'Standard', blurb: 'Slow but the best at structured exercises.', icon: Wand2 }, | |
| { value: 'long', label: 'Long context', vendor: 'DeepSeek V4 Pro', speed: 'medium', quality: 'good', context: '1M tokens', blurb: 'Very long inputs, careful tone.', icon: Layers }, | |
| ] | |
| function tierBars(tier: ModelTier): number { | |
| switch (tier) { | |
| case 'fast': return 1 | |
| case 'medium': return 2 | |
| case 'good': return 3 | |
| case 'best': return 4 | |
| } | |
| } | |
| function ModelTierBar({ tier, label }: { tier: ModelTier; label: string }) { | |
| const filled = tierBars(tier) | |
| return ( | |
| <div className="flex items-center gap-1.5 text-[10px] text-slate-500 dark:text-slate-400"> | |
| <span className="w-12 truncate">{label}</span> | |
| <div className="flex gap-0.5"> | |
| {[1, 2, 3, 4].map((i) => ( | |
| <span | |
| key={i} | |
| className={ | |
| 'h-1.5 w-3 rounded-sm ' + | |
| (i <= filled | |
| ? 'bg-brand-500 dark:bg-brand-400' | |
| : 'bg-slate-200 dark:bg-white/10') | |
| } | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function ModelChooser({ | |
| value, | |
| onChange, | |
| disabled, | |
| }: { | |
| value: string | |
| onChange: (v: string) => void | |
| disabled?: boolean | |
| }) { | |
| return ( | |
| <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| {MODEL_OPTIONS.map((m) => { | |
| const active = value === m.value | |
| const Icon = m.icon | |
| return ( | |
| <button | |
| key={m.value} | |
| type="button" | |
| onClick={() => !disabled && onChange(m.value)} | |
| disabled={disabled} | |
| aria-pressed={active} | |
| className={ | |
| 'flex flex-col gap-2 rounded-md border px-3 py-2.5 text-left transition-colors ' + | |
| (active | |
| ? 'border-brand-500 bg-brand-50 dark:border-brand-400 dark:bg-brand-500/10' | |
| : 'border-slate-200 bg-white hover:border-slate-300 dark:border-white/10 dark:bg-white/[0.03]') | |
| } | |
| > | |
| <div className="flex items-start gap-2"> | |
| <Icon size={16} className={active ? 'mt-0.5 text-brand-600' : 'mt-0.5 text-slate-400'} /> | |
| <div className="min-w-0 flex-1"> | |
| <div className="truncate text-sm font-medium text-slate-900 dark:text-slate-50"> | |
| {m.label} | |
| </div> | |
| <div className="mt-0.5 truncate text-[11px] text-slate-500 dark:text-slate-400"> | |
| {m.vendor} | |
| </div> | |
| </div> | |
| {m.context !== 'Standard' && ( | |
| <span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-slate-500 dark:bg-white/10 dark:text-slate-300"> | |
| {m.context} | |
| </span> | |
| )} | |
| </div> | |
| <div className="flex flex-col gap-0.5"> | |
| <ModelTierBar tier={m.speed} label="Speed" /> | |
| <ModelTierBar tier={m.quality} label="Quality" /> | |
| </div> | |
| <div className="text-[11px] text-slate-500 dark:text-slate-400">{m.blurb}</div> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| ) | |
| } | |
| /** | |
| * C12: tabbed Default / Custom system prompt editor with a small set of | |
| * presets for the most common tones. Plain textarea inside (no CodeMirror | |
| * dependency added) but mono font + larger height. | |
| */ | |
| const SYSTEM_PROMPT_PRESETS: { label: string; value: string }[] = [ | |
| { | |
| label: 'Tabular', | |
| value: | |
| 'Render the chapter as a clean, dense table-of-contents style HTML page. Use <table> for any list structures. Keep the visual rhythm consistent.', | |
| }, | |
| { | |
| label: 'Simple narrative', | |
| value: | |
| 'Use short paragraphs, simple sentence structure, and friendly tone. Aim for clarity over comprehensiveness. Avoid bullet lists.', | |
| }, | |
| { | |
| label: 'Bilingual (Nepali β English)', | |
| value: | |
| 'Render every heading in both Nepali (Devanagari) and English. Body paragraphs may stay monolingual, but the structure must be presented in both languages.', | |
| }, | |
| { | |
| label: 'Exam-prep flashcards', | |
| value: | |
| 'Format the output as a flashcard deck: each concept becomes a <section> with a question on top and the answer below in a contrasting block. No paragraphs longer than 3 sentences.', | |
| }, | |
| ] | |
| function SystemPromptEditor({ | |
| value, | |
| onChange, | |
| disabled, | |
| }: { | |
| value: string | |
| onChange: (v: string) => void | |
| disabled?: boolean | |
| }) { | |
| const [tab, setTab] = useState<'default' | 'custom'>(value.trim().length > 0 ? 'custom' : 'default') | |
| const onPickPreset = (preset: string) => { | |
| setTab('custom') | |
| onChange(preset) | |
| } | |
| return ( | |
| <div className="space-y-2"> | |
| <div className="flex flex-wrap items-center gap-1"> | |
| <button | |
| type="button" | |
| className={ | |
| 'rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ' + | |
| (tab === 'default' | |
| ? 'border-brand-500 bg-brand-50 text-brand-700 dark:border-brand-400 dark:bg-brand-500/10 dark:text-brand-200' | |
| : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-white/10 dark:bg-white/[0.03] dark:text-slate-300') | |
| } | |
| onClick={() => { | |
| setTab('default') | |
| if (value.trim()) onChange('') | |
| }} | |
| disabled={disabled} | |
| > | |
| Default prompt | |
| </button> | |
| <button | |
| type="button" | |
| className={ | |
| 'rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ' + | |
| (tab === 'custom' | |
| ? 'border-brand-500 bg-brand-50 text-brand-700 dark:border-brand-400 dark:bg-brand-500/10 dark:text-brand-200' | |
| : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-white/10 dark:bg-white/[0.03] dark:text-slate-300') | |
| } | |
| onClick={() => setTab('custom')} | |
| disabled={disabled} | |
| > | |
| Custom | |
| </button> | |
| {tab === 'custom' && ( | |
| <div className="ml-1 flex flex-wrap items-center gap-1 border-l border-slate-200 pl-2 dark:border-white/10"> | |
| <span className="text-[10px] uppercase tracking-wider text-slate-500 dark:text-slate-400"> | |
| Presets | |
| </span> | |
| {SYSTEM_PROMPT_PRESETS.map((p) => ( | |
| <button | |
| key={p.label} | |
| type="button" | |
| onClick={() => onPickPreset(p.value)} | |
| disabled={disabled} | |
| className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-600 hover:bg-slate-50 dark:border-white/10 dark:bg-white/[0.03] dark:text-slate-300" | |
| > | |
| {p.label} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {tab === 'default' ? ( | |
| <p className="rounded-md border border-dashed border-slate-200 px-3 py-3 text-xs text-slate-500 dark:border-white/10 dark:text-slate-400"> | |
| Using the default backend system prompt β tuned for textbook chapters. | |
| Switch to <span className="font-medium">Custom</span> to override or pick a preset. | |
| </p> | |
| ) : ( | |
| <textarea | |
| className="textarea h-32 resize-y font-mono text-[12.5px] leading-5" | |
| placeholder="Optional extra instructions for HTML generationβ¦" | |
| value={value} | |
| onChange={(e) => onChange(e.target.value)} | |
| disabled={disabled} | |
| spellCheck={false} | |
| /> | |
| )} | |
| {tab === 'custom' && ( | |
| <div className="text-right text-[11px] text-slate-500 dark:text-slate-400"> | |
| {value.length.toLocaleString()} / 8000 | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| function ContentStep({ | |
| text, | |
| onText, | |
| settings, | |
| onChange, | |
| running, | |
| errors, | |
| sourceMode, | |
| }: { | |
| text: string | |
| onText: (s: string) => void | |
| settings: GenerateSettings | |
| onChange: Setter | |
| running: boolean | |
| errors: FieldErrors | |
| sourceMode: SourceMode | |
| }) { | |
| const isHtml = sourceMode === 'html' | |
| const onFile = async (file: File) => { | |
| onText(await file.text()) | |
| } | |
| const beautify = async () => { | |
| if (!text.trim()) return | |
| const res = await api.beautify(text) | |
| onText(res.html) | |
| } | |
| const minify = async () => { | |
| if (!text.trim()) return | |
| const res = await api.minify(text) | |
| onText(res.html) | |
| } | |
| return ( | |
| <> | |
| <StepHeader | |
| title={isHtml ? 'HTML input' : 'AI & text'} | |
| subtitle={ | |
| isHtml | |
| ? 'Paste or upload the HTML you already generated. The workflow continues from screenshots onward.' | |
| : 'Pick the model, paste the source text, and (optionally) override the system prompt.' | |
| } | |
| /> | |
| {!isHtml && ( | |
| <Field label="AI Model"> | |
| <ModelChooser | |
| value={settings.model_choice ?? 'default'} | |
| onChange={(v) => onChange('model_choice', v)} | |
| disabled={running} | |
| /> | |
| </Field> | |
| )} | |
| {isHtml && ( | |
| <div className="flex flex-wrap gap-2"> | |
| <label className="btn-secondary cursor-pointer"> | |
| <Upload size={14} /> Upload .html | |
| <input | |
| type="file" | |
| accept=".html,text/html" | |
| className="hidden" | |
| onChange={(e) => { | |
| const f = e.target.files?.[0] | |
| if (f) void onFile(f) | |
| }} | |
| disabled={running} | |
| /> | |
| </label> | |
| <button type="button" className="btn-secondary" onClick={() => void beautify()} disabled={running || !text.trim()}> | |
| Beautify | |
| </button> | |
| <button type="button" className="btn-secondary" onClick={() => void minify()} disabled={running || !text.trim()}> | |
| Minify | |
| </button> | |
| </div> | |
| )} | |
| <Field label={isHtml ? 'HTML input' : 'Text input'} required error={errors.text}> | |
| <textarea | |
| id={fieldId('content', 'text')} | |
| className={inputCls(errors.text) + ' textarea h-60 resize-y font-mono'} | |
| placeholder="Paste your lesson notes hereβ¦" | |
| value={text} | |
| onChange={(e) => onText(e.target.value)} | |
| disabled={running} | |
| /> | |
| <div className="mt-1 text-xs text-slate-500"> | |
| ~{Math.round(text.length / 4)} tokens Β· {text.length} characters | |
| </div> | |
| </Field> | |
| {!isHtml && ( | |
| <Field label="System prompt (optional)"> | |
| <SystemPromptEditor | |
| value={settings.system_prompt ?? ''} | |
| onChange={(v) => onChange('system_prompt', v)} | |
| disabled={running} | |
| /> | |
| </Field> | |
| )} | |
| </> | |
| ) | |
| } | |
| function ScreenshotStep({ | |
| settings, | |
| onChange, | |
| errors, | |
| }: { | |
| settings: GenerateSettings | |
| onChange: Setter | |
| errors: FieldErrors | |
| }) { | |
| return ( | |
| <> | |
| <StepHeader | |
| title="Screenshot settings" | |
| subtitle="How Playwright captures the rendered HTML." | |
| /> | |
| <div className="grid gap-4 sm:grid-cols-3"> | |
| <NumField | |
| step="screenshot" | |
| name="zoom" | |
| label="Zoom" | |
| numStep={0.1} | |
| value={settings.zoom} | |
| onChange={(v) => onChange('zoom', v)} | |
| error={errors.zoom} | |
| /> | |
| <NumField | |
| step="screenshot" | |
| name="overlap" | |
| label="Overlap (px)" | |
| value={settings.overlap} | |
| onChange={(v) => onChange('overlap', v)} | |
| error={errors.overlap} | |
| /> | |
| <NumField | |
| step="screenshot" | |
| name="max_screenshots" | |
| label="Max screenshots" | |
| value={settings.max_screenshots} | |
| onChange={(v) => onChange('max_screenshots', v)} | |
| error={errors.max_screenshots} | |
| /> | |
| <NumField | |
| step="screenshot" | |
| name="viewport_width" | |
| label="Viewport width" | |
| value={settings.viewport_width} | |
| onChange={(v) => onChange('viewport_width', v)} | |
| error={errors.viewport_width} | |
| /> | |
| <NumField | |
| step="screenshot" | |
| name="viewport_height" | |
| label="Viewport height" | |
| value={settings.viewport_height} | |
| onChange={(v) => onChange('viewport_height', v)} | |
| error={errors.viewport_height} | |
| /> | |
| </div> | |
| </> | |
| ) | |
| } | |
| function VideoStep({ | |
| settings, | |
| onChange, | |
| errors, | |
| }: { | |
| settings: GenerateSettings | |
| onChange: Setter | |
| errors: FieldErrors | |
| }) { | |
| return ( | |
| <> | |
| <StepHeader | |
| title="Video settings" | |
| subtitle="Rendering parameters for PowerPoint / MP4 export. Only applied when output is PowerPoint or MP4." | |
| /> | |
| <div className="grid gap-4 sm:grid-cols-2"> | |
| <Field label="Resolution" error={errors.resolution}> | |
| <select | |
| id={fieldId('video', 'resolution')} | |
| className={inputCls(errors.resolution)} | |
| value={settings.resolution ?? '1080p'} | |
| onChange={(e) => | |
| onChange('resolution', e.target.value as GenerateSettings['resolution']) | |
| } | |
| > | |
| <option value="720p">720p (HD)</option> | |
| <option value="1080p">1080p (Full HD)</option> | |
| <option value="1440p">1440p (2K)</option> | |
| <option value="4k">4K (UHD)</option> | |
| </select> | |
| </Field> | |
| <NumField | |
| step="video" | |
| name="video_quality" | |
| label="Video quality (1-100)" | |
| value={settings.video_quality} | |
| onChange={(v) => onChange('video_quality', v)} | |
| error={errors.video_quality} | |
| /> | |
| <NumField | |
| step="video" | |
| name="fps" | |
| label="FPS" | |
| value={settings.fps} | |
| onChange={(v) => onChange('fps', v)} | |
| error={errors.fps} | |
| /> | |
| <NumField | |
| step="video" | |
| name="slide_duration_sec" | |
| label="Default slide duration (sec)" | |
| value={settings.slide_duration_sec} | |
| onChange={(v) => onChange('slide_duration_sec', v)} | |
| error={errors.slide_duration_sec} | |
| /> | |
| </div> | |
| </> | |
| ) | |
| } | |
| function ThumbnailStep({ | |
| settings, | |
| text, | |
| onChange, | |
| errors, | |
| autoThumbnailBuilder, | |
| autoThumbnailPreviewUrl, | |
| autoThumbnailError, | |
| autoThumbnailSaving, | |
| autoThumbnailEditOpen, | |
| onUseAutoThumbnail, | |
| onToggleAutoThumbnailEdit, | |
| onAutoThumbnailSideImage, | |
| onAutoThumbnailOutroSideImage, | |
| onSwapIntroOutroThumbnails, | |
| }: { | |
| settings: GenerateSettings | |
| text: string | |
| onChange: Setter | |
| errors: FieldErrors | |
| autoThumbnailBuilder: boolean | |
| autoThumbnailPreviewUrl: string | null | |
| autoThumbnailError: string | null | |
| autoThumbnailSaving: boolean | |
| autoThumbnailEditOpen: boolean | |
| onUseAutoThumbnail: (slot?: 'intro' | 'outro' | 'both') => void | |
| onToggleAutoThumbnailEdit: () => void | |
| onAutoThumbnailSideImage: (file: File | null) => void | |
| onAutoThumbnailOutroSideImage: (file: File | null) => void | |
| onSwapIntroOutroThumbnails: () => void | |
| }) { | |
| return ( | |
| <> | |
| <StepHeader | |
| title="Thumbnails" | |
| subtitle="Two optional thumbnail slots: intro (slide 2) and outro (2nd-to-last slide). Both are inserted into the final PPT / MP4." | |
| /> | |
| <div className="grid gap-4 lg:grid-cols-2"> | |
| <ThumbnailSlot | |
| kind="intro" | |
| title="Intro thumbnail" | |
| position={autoThumbnailBuilder ? 'Auto-built from project info unless replaced' : 'Inserted on slide 2'} | |
| enabled={settings.intro_thumbnail_enabled ?? false} | |
| filename={settings.intro_thumbnail_filename ?? ''} | |
| generatedPreviewUrl={autoThumbnailPreviewUrl} | |
| durationSec={settings.intro_thumbnail_duration_sec} | |
| onEnabledChange={(v) => onChange('intro_thumbnail_enabled', v)} | |
| onFilenameChange={(v) => onChange('intro_thumbnail_filename', v)} | |
| onDurationChange={(v) => onChange('intro_thumbnail_duration_sec', v)} | |
| filenameError={errors.intro_thumbnail_filename} | |
| durationError={errors.intro_thumbnail_duration_sec} | |
| /> | |
| <ThumbnailSlot | |
| kind="outro" | |
| title="Outro thumbnail" | |
| position={autoThumbnailBuilder ? 'Auto-built from the same detected project info' : 'Inserted on the 2nd-to-last slide'} | |
| enabled={settings.outro_thumbnail_enabled ?? false} | |
| filename={settings.outro_thumbnail_filename ?? ''} | |
| // Outro is an explicit-save slot β until the user clicks | |
| // "Use as outro thumbnail", we keep this tile empty rather than | |
| // mirroring the live editor preview. This avoids the false | |
| // appearance that an outro is auto-saved when only intro was. | |
| generatedPreviewUrl={null} | |
| durationSec={settings.outro_thumbnail_duration_sec} | |
| onEnabledChange={(v) => onChange('outro_thumbnail_enabled', v)} | |
| onFilenameChange={(v) => onChange('outro_thumbnail_filename', v)} | |
| onDurationChange={(v) => onChange('outro_thumbnail_duration_sec', v)} | |
| filenameError={errors.outro_thumbnail_filename} | |
| durationError={errors.outro_thumbnail_duration_sec} | |
| /> | |
| </div> | |
| {autoThumbnailBuilder && ( | |
| <AutoThumbnailPanel | |
| settings={settings} | |
| text={text} | |
| onChange={onChange} | |
| autoThumbnailError={autoThumbnailError} | |
| autoThumbnailSaving={autoThumbnailSaving} | |
| autoThumbnailEditOpen={autoThumbnailEditOpen} | |
| onUseAutoThumbnail={onUseAutoThumbnail} | |
| onToggleAutoThumbnailEdit={onToggleAutoThumbnailEdit} | |
| onAutoThumbnailSideImage={onAutoThumbnailSideImage} | |
| onAutoThumbnailOutroSideImage={onAutoThumbnailOutroSideImage} | |
| onSwapIntroOutroThumbnails={onSwapIntroOutroThumbnails} | |
| /> | |
| )} | |
| </> | |
| ) | |
| } | |
| function AutoThumbnailPanel({ | |
| settings, | |
| text, | |
| onChange, | |
| autoThumbnailError, | |
| autoThumbnailSaving, | |
| autoThumbnailEditOpen, | |
| onUseAutoThumbnail, | |
| onToggleAutoThumbnailEdit, | |
| onAutoThumbnailSideImage, | |
| onAutoThumbnailOutroSideImage, | |
| onSwapIntroOutroThumbnails, | |
| }: { | |
| settings: GenerateSettings | |
| text: string | |
| onChange: Setter | |
| autoThumbnailError: string | null | |
| autoThumbnailSaving: boolean | |
| autoThumbnailEditOpen: boolean | |
| onUseAutoThumbnail: (slot?: 'intro' | 'outro' | 'both') => void | |
| onToggleAutoThumbnailEdit: () => void | |
| onAutoThumbnailSideImage: (file: File | null) => void | |
| onAutoThumbnailOutroSideImage: (file: File | null) => void | |
| onSwapIntroOutroThumbnails: () => void | |
| }) { | |
| const hasSavedIntro = Boolean((settings.intro_thumbnail_filename ?? '').trim()) | |
| const hasSavedOutro = Boolean((settings.outro_thumbnail_filename ?? '').trim()) | |
| const outroAutoGenerated = Boolean(settings.auto_thumbnail_outro_generated) | |
| const [templateVersion, setTemplateVersion] = useState(0) | |
| const [savedTemplates, setSavedTemplates] = useState<SavedThumbnailTemplate[]>([]) | |
| const [templateSaveStatus, setTemplateSaveStatus] = useState<string | null>(null) | |
| // Auto-suggested name derives from the current class/subject. We only | |
| // store an override when the user has actually edited the field, so the | |
| // suggestion follows class/subject changes without a setState-in-effect. | |
| const [templateNameOverride, setTemplateNameOverride] = useState<string | null>(null) | |
| const suggestedTemplateName = useMemo( | |
| () => `${settings.class_name ?? 'Class'} ${settings.subject ?? 'Subject'}`.trim(), | |
| [settings.class_name, settings.subject], | |
| ) | |
| const templateName = templateNameOverride ?? suggestedTemplateName | |
| useEffect(() => { | |
| let cancelled = false | |
| api.listThumbnailTemplates(settings.class_name, settings.subject) | |
| .then((res) => { if (!cancelled) setSavedTemplates(res.templates ?? []) }) | |
| .catch(() => { if (!cancelled) setSavedTemplates([]) }) | |
| return () => { cancelled = true } | |
| }, [settings.class_name, settings.subject, templateVersion]) | |
| const saveCurrentTemplate = async () => { | |
| const className = (settings.class_name ?? '').trim() | |
| const subject = (settings.subject ?? '').trim() | |
| if (!className || !subject) { | |
| setTemplateSaveStatus('Class and subject are required before saving.') | |
| return | |
| } | |
| const name = templateName.trim() || `${className} ${subject}` | |
| // Server-side dedup is keyed on (className, subject, name) β warn the | |
| // user before silently overwriting an existing entry. | |
| const collision = savedTemplates.find( | |
| (item) => | |
| item.className.trim().toLowerCase() === className.toLowerCase() && | |
| item.subject.trim().toLowerCase() === subject.toLowerCase() && | |
| item.name.trim().toLowerCase() === name.toLowerCase(), | |
| ) | |
| if (collision && !window.confirm( | |
| `A template named "${name}" already exists for ${className} ${subject}. Overwrite it?`, | |
| )) { | |
| setTemplateSaveStatus('Save cancelled.') | |
| return | |
| } | |
| setTemplateSaveStatus('Saving...') | |
| try { | |
| await api.saveThumbnailTemplate({ | |
| name, | |
| className, | |
| subject, | |
| settings: captureThumbnailTemplateSettings(settings), | |
| }) | |
| setTemplateSaveStatus(collision ? 'Overwritten.' : 'Saved.') | |
| setTemplateVersion((v) => v + 1) | |
| } catch (err) { | |
| setTemplateSaveStatus(err instanceof Error ? err.message : 'Could not save template.') | |
| } | |
| } | |
| const loadSavedTemplate = (id: string) => { | |
| const match = savedTemplates.find((item) => item.id === id) | |
| if (!match) return | |
| Object.entries(match.settings).forEach(([key, value]) => { | |
| onChange(key as keyof GenerateSettings, value as GenerateSettings[keyof GenerateSettings]) | |
| }) | |
| } | |
| const deleteSavedTemplate = async (id: string) => { | |
| await api.deleteThumbnailTemplate(id) | |
| setTemplateVersion((v) => v + 1) | |
| } | |
| // Mini preview rendered from the same template the run uses, so the user | |
| // sees the actual output (not a CSS approximation) before pressing "Use it". | |
| const template = useMemo( | |
| () => buildAutoThumbnailTemplate(settings, text), | |
| [settings, text], | |
| ) | |
| const [miniPreview, setMiniPreview] = useState<string | null>(null) | |
| useEffect(() => { | |
| let cancelled = false | |
| renderTemplateToDataUrl(template, 0.6) | |
| .then((url) => { if (!cancelled) setMiniPreview(url) }) | |
| .catch(() => { if (!cancelled) setMiniPreview(null) }) | |
| return () => { cancelled = true } | |
| }, [template]) | |
| return ( | |
| <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-white/10 dark:bg-slate-950/40"> | |
| {/* ββ Header strip ββββββββββββββββββββββββββββββββββββββββββββ */} | |
| <div className="flex flex-wrap items-start gap-4 border-b border-slate-200 bg-gradient-to-r from-brand-50 to-white p-5 dark:border-white/10 dark:from-brand-500/10 dark:to-transparent"> | |
| <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-500 text-white shadow-sm"> | |
| <Wand2 size={18} /> | |
| </span> | |
| <div className="min-w-0 flex-1"> | |
| <div className="text-base font-semibold text-slate-900 dark:text-slate-50"> | |
| Auto thumbnail builder | |
| </div> | |
| <div className="mt-0.5 text-sm text-slate-600 dark:text-slate-300"> | |
| We compose a clean Education Classic intro from your class, subject and chapter title β drop in a photo and tweak any element to taste. | |
| </div> | |
| {autoThumbnailError && ( | |
| <div className="mt-2 flex items-start gap-1.5 rounded-md bg-rose-50 px-2 py-1.5 text-xs text-rose-700 dark:bg-rose-500/10 dark:text-rose-200"> | |
| <AlertCircle size={13} className="mt-px shrink-0" /> {autoThumbnailError} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* ββ Mini preview + actions βββββββββββββββββββββββββββββββββ */} | |
| <div className="grid gap-5 p-5 sm:grid-cols-[260px,1fr] sm:items-center"> | |
| <div className="overflow-hidden rounded-lg border border-slate-200 bg-slate-100 shadow-inner dark:border-white/10 dark:bg-slate-900"> | |
| <div | |
| className="relative w-full" | |
| style={{ | |
| aspectRatio: `${template.canvasWidth} / ${template.canvasHeight}`, | |
| backgroundColor: template.canvasBackground, | |
| }} | |
| > | |
| {miniPreview ? ( | |
| <img | |
| src={miniPreview} | |
| alt="Auto thumbnail preview" | |
| className="absolute inset-0 h-full w-full object-cover" | |
| /> | |
| ) : ( | |
| <div className="flex h-full w-full items-center justify-center text-xs text-slate-500 dark:text-slate-400"> | |
| Rendering preview⦠| |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex flex-col gap-3"> | |
| <div className="flex flex-wrap items-center gap-2 text-xs"> | |
| {hasSavedIntro ? ( | |
| <span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"> | |
| <Check size={11} /> Intro saved | |
| </span> | |
| ) : ( | |
| <span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-800 dark:bg-amber-500/15 dark:text-amber-200"> | |
| <Sparkles size={11} /> Intro not saved | |
| </span> | |
| )} | |
| {hasSavedOutro ? ( | |
| <span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"> | |
| <Check size={11} /> Outro {outroAutoGenerated ? 'saved' : 'uploaded'} | |
| </span> | |
| ) : ( | |
| <span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 font-medium text-slate-600 dark:bg-white/5 dark:text-slate-300"> | |
| <Sparkles size={11} /> Outro optional | |
| </span> | |
| )} | |
| <span className="text-slate-500 dark:text-slate-400"> | |
| {settings.auto_thumbnail_export_2x ? '3840 Γ 2160' : `${template.canvasWidth} Γ ${template.canvasHeight}`} Β· PNG | |
| </span> | |
| <label className="ml-auto inline-flex items-center gap-1.5 text-slate-600 dark:text-slate-300"> | |
| <input | |
| type="checkbox" | |
| className="h-3.5 w-3.5 rounded border-slate-300" | |
| checked={Boolean(settings.auto_thumbnail_export_2x)} | |
| onChange={(e) => onChange('auto_thumbnail_export_2x', e.target.checked)} | |
| /> | |
| <span title="Export at 2Γ pixel ratio (3840Γ2160). Slower but sharper for YouTube uploads."> | |
| Export at 2Γ (4K) | |
| </span> | |
| </label> | |
| </div> | |
| <div className="rounded-md border border-slate-200 bg-white p-3 dark:border-white/10 dark:bg-white/[0.03]"> | |
| <div className="mb-2 text-xs font-semibold text-slate-700 dark:text-slate-200"> | |
| Saved templates for {settings.class_name ?? 'Class'} {settings.subject ?? ''} | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| <input | |
| className="input h-9 min-w-[180px] flex-1" | |
| value={templateName} | |
| onChange={(e) => setTemplateNameOverride(e.target.value)} | |
| placeholder="Template name" | |
| /> | |
| <button type="button" className="btn-secondary btn-sm" onClick={saveCurrentTemplate}> | |
| Save template | |
| </button> | |
| </div> | |
| {savedTemplates.length > 0 && ( | |
| <div className="mt-2 flex flex-wrap gap-2"> | |
| {savedTemplates.map((item) => ( | |
| <span key={item.id} className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-slate-50 px-2 py-1 text-xs dark:border-white/10 dark:bg-white/5"> | |
| <button type="button" className="font-medium text-brand-700 dark:text-brand-300" onClick={() => loadSavedTemplate(item.id)}> | |
| {item.name} | |
| </button> | |
| <button type="button" className="text-slate-400 hover:text-rose-500" onClick={() => deleteSavedTemplate(item.id)} title="Delete saved template"> | |
| Γ | |
| </button> | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| {templateSaveStatus && ( | |
| <div className="mt-2 text-xs text-slate-500 dark:text-slate-400"> | |
| {templateSaveStatus} | |
| </div> | |
| )} | |
| </div> | |
| <div className="grid gap-3 rounded-md border border-slate-200 bg-white p-3 text-xs dark:border-white/10 dark:bg-white/[0.03] sm:grid-cols-[1fr,auto] sm:items-end"> | |
| <label className="block"> | |
| <span className="label">Outro title / topic</span> | |
| <input | |
| className="input h-9" | |
| value={settings.auto_thumbnail_outro_title ?? ''} | |
| onChange={(e) => onChange('auto_thumbnail_outro_title', e.target.value)} | |
| placeholder="Ask/title for outro, e.g. Next Unit Preview" | |
| /> | |
| </label> | |
| <label className="btn-secondary btn-sm cursor-pointer"> | |
| <Upload size={13} /> Outro image | |
| <input | |
| type="file" | |
| accept="image/png,image/jpeg,image/webp" | |
| className="hidden" | |
| onChange={(e) => { | |
| const file = e.target.files?.[0] | |
| if (file) onAutoThumbnailOutroSideImage(file) | |
| e.target.value = '' | |
| }} | |
| /> | |
| </label> | |
| <div className="text-slate-500 dark:text-slate-400 sm:col-span-2"> | |
| Outro uses {incrementChapterNum((settings.auto_thumbnail_chapter_num ?? '').trim() || detectChapterMeta(text, settings)?.num || '1')} for the unit/chapter number. If no outro title or image is set, it falls back to the intro title and image. | |
| </div> | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| <button | |
| type="button" | |
| className="btn-primary btn-sm" | |
| onClick={() => onUseAutoThumbnail('intro')} | |
| disabled={autoThumbnailSaving} | |
| title={ | |
| hasSavedIntro | |
| ? 'Re-render the current edited template and replace the saved intro thumbnail.' | |
| : 'Render the current template and use it as the project intro thumbnail.' | |
| } | |
| > | |
| <Check size={13} /> {autoThumbnailSaving | |
| ? 'Savingβ¦' | |
| : hasSavedIntro | |
| ? 'Update intro thumbnail' | |
| : 'Use as intro thumbnail'} | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-secondary btn-sm" | |
| onClick={() => onUseAutoThumbnail('outro')} | |
| disabled={autoThumbnailSaving} | |
| title={ | |
| hasSavedOutro | |
| ? 'Re-render the current edited template and replace the saved outro thumbnail.' | |
| : 'Render the current template and use it as the project outro thumbnail. Edit the template between intro and outro saves to give them different looks.' | |
| } | |
| > | |
| <Check size={13} /> {autoThumbnailSaving | |
| ? 'Savingβ¦' | |
| : hasSavedOutro | |
| ? 'Update outro thumbnail' | |
| : 'Use as outro thumbnail'} | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm" | |
| onClick={() => onUseAutoThumbnail('both')} | |
| disabled={autoThumbnailSaving} | |
| title="Save the current template as both the intro and outro thumbnail in one click." | |
| > | |
| <Check size={13} /> Use for both | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm" | |
| onClick={onSwapIntroOutroThumbnails} | |
| disabled={autoThumbnailSaving || (!hasSavedIntro && !hasSavedOutro)} | |
| title={ | |
| hasSavedIntro || hasSavedOutro | |
| ? 'Swap the intro and outro thumbnail files. Useful when the end-card you saved as the outro of the previous unit should become the intro of this one.' | |
| : 'Save an intro or outro thumbnail first to enable swap.' | |
| } | |
| > | |
| <ArrowLeftRight size={13} /> Swap intro β outro | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-secondary btn-sm" | |
| onClick={onToggleAutoThumbnailEdit} | |
| > | |
| <FileText size={13} /> {autoThumbnailEditOpen ? 'Close editor' : 'Edit'} | |
| </button> | |
| <label className="btn-ghost btn-sm cursor-pointer"> | |
| <Upload size={13} /> Replace side image | |
| <input | |
| type="file" | |
| accept="image/png,image/jpeg,image/webp" | |
| className="hidden" | |
| onChange={(e) => { | |
| const file = e.target.files?.[0] | |
| if (file) onAutoThumbnailSideImage(file) | |
| e.target.value = '' | |
| }} | |
| /> | |
| </label> | |
| </div> | |
| <div className="text-xs text-slate-500 dark:text-slate-400"> | |
| Tip: edit the green canvas, the yellow header bar, the chapter pill, or drop in your own image β everything updates the preview live. | |
| </div> | |
| </div> | |
| </div> | |
| {/* ββ Visual editor ββββββββββββββββββββββββββββββββββββββββββ */} | |
| {autoThumbnailEditOpen && ( | |
| <div className="border-t border-slate-200 dark:border-white/10"> | |
| <ThumbnailVisualEditor | |
| settings={settings} | |
| text={text} | |
| onChange={onChange} | |
| onAutoThumbnailSideImage={onAutoThumbnailSideImage} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| const NUDGE_KEYS: Record<string, [number, number]> = { | |
| ArrowLeft: [-1, 0], | |
| ArrowRight: [1, 0], | |
| ArrowUp: [0, -1], | |
| ArrowDown: [0, 1], | |
| } | |
| type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' | |
| type InspectorTab = 'content' | 'style' | 'layout' | 'layers' | |
| const PRESET_LABELS: Record<string, string> = { | |
| leftPanel: 'Left panel', | |
| rightPanel: 'Right image background', | |
| title: 'Header bar - class & subject', | |
| chapterLabel: 'Chapter label (ΰ€ͺΰ€Ύΰ€ N)', | |
| chapterLine1: 'Chapter title - line 1', | |
| chapterLine2: 'Chapter title - line 2', | |
| chapterLine3: 'Chapter title - line 3', | |
| labelNew: 'New badge', | |
| labelChapter: 'Chapter badge', | |
| rightImage: 'Right image', | |
| badgeYear: 'Year starburst', | |
| badgeNew: 'New starburst', | |
| } | |
| function ThumbnailVisualEditor({ | |
| settings, | |
| text, | |
| onChange, | |
| onAutoThumbnailSideImage, | |
| }: { | |
| settings: GenerateSettings | |
| text: string | |
| onChange: Setter | |
| onAutoThumbnailSideImage: (file: File | null) => void | |
| }) { | |
| const template = useMemo( | |
| () => buildAutoThumbnailTemplate(settings, text), | |
| [settings, text], | |
| ) | |
| const [selectedId, setSelectedId] = useState<string | null>(() => { | |
| return template.elements.chapterLine1?.id ?? null | |
| }) | |
| const tab = 'content' as InspectorTab | |
| const [canvasScale, setCanvasScale] = useState(1) | |
| const frameRef = useRef<HTMLDivElement | null>(null) | |
| const [contextMenu, setContextMenu] = useState<{ | |
| x: number | |
| y: number | |
| elementId: string | |
| } | null>(null) | |
| // Render the canvas-derived preview every time the template changes β the | |
| // <img> *is* the real output, so the editor matches the saved PNG exactly. | |
| const selected = selectedId ? template.elements[selectedId] : undefined | |
| const overrides = settings.auto_thumbnail_overrides ?? {} | |
| const added = settings.auto_thumbnail_added_elements ?? {} | |
| const hidden = settings.auto_thumbnail_hidden_elements ?? [] | |
| const isAddedElement = (id: string) => Boolean(added[id]) | |
| useEffect(() => { | |
| const frame = frameRef.current | |
| if (!frame) return | |
| const updateScale = () => { | |
| const rect = frame.getBoundingClientRect() | |
| setCanvasScale(rect.width > 0 ? rect.width / template.canvasWidth : 1) | |
| } | |
| updateScale() | |
| const observer = new ResizeObserver(updateScale) | |
| observer.observe(frame) | |
| return () => observer.disconnect() | |
| }, [template.canvasWidth]) | |
| const patchElement = (id: string, patch: Partial<ThumbnailElement>) => { | |
| onChange('auto_thumbnail_overrides', { | |
| ...overrides, | |
| [id]: { ...((overrides[id] as Partial<ThumbnailElement>) ?? {}), ...patch }, | |
| }) | |
| } | |
| const patchSelected = (patch: Partial<ThumbnailElement>) => { | |
| if (!selected) return | |
| patchElement(selected.id, patch) | |
| } | |
| const clearSelectedOverride = () => { | |
| if (!selected) return | |
| if (isAddedElement(selected.id)) return | |
| const next = { ...overrides } | |
| delete next[selected.id] | |
| onChange('auto_thumbnail_overrides', next) | |
| } | |
| const removeSelected = () => { | |
| if (!selected) return | |
| if (isAddedElement(selected.id)) { | |
| const next = { ...added } | |
| delete next[selected.id] | |
| onChange('auto_thumbnail_added_elements', next) | |
| setSelectedId(null) | |
| return | |
| } | |
| if (!hidden.includes(selected.id)) { | |
| onChange('auto_thumbnail_hidden_elements', [...hidden, selected.id]) | |
| } | |
| setSelectedId(null) | |
| } | |
| const restoreElement = (id: string) => { | |
| if (!hidden.includes(id)) return | |
| onChange('auto_thumbnail_hidden_elements', hidden.filter((x) => x !== id)) | |
| } | |
| const duplicateSelected = () => { | |
| if (!selected) return | |
| const id = nextElementId('copy') | |
| const copy = duplicateElement(selected, id) | |
| onChange('auto_thumbnail_added_elements', { | |
| ...added, | |
| [id]: copy as unknown as Record<string, unknown>, | |
| }) | |
| setSelectedId(id) | |
| } | |
| const bringToFront = () => { | |
| const maxZ = Math.max(0, ...Object.values(template.elements).map((e) => e.zIndex ?? 5)) | |
| patchSelected({ zIndex: maxZ + 1 }) | |
| } | |
| const sendToBack = () => { | |
| const minZ = Math.min(0, ...Object.values(template.elements).map((e) => e.zIndex ?? 5)) | |
| patchSelected({ zIndex: minZ - 1 }) | |
| } | |
| const resetAllOverrides = () => { | |
| onChange('auto_thumbnail_overrides', undefined) | |
| onChange('auto_thumbnail_added_elements', undefined) | |
| onChange('auto_thumbnail_hidden_elements', undefined) | |
| onChange('auto_thumbnail_canvas_background', undefined) | |
| } | |
| const startDrag = ( | |
| e: React.PointerEvent<HTMLElement>, | |
| element: ThumbnailElement, | |
| mode: 'move' | ResizeHandle, | |
| ) => { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| setSelectedId(element.id) | |
| if (element.locked) return | |
| const frame = frameRef.current | |
| if (!frame) return | |
| const rect = frame.getBoundingClientRect() | |
| const sx = template.canvasWidth / rect.width | |
| const sy = template.canvasHeight / rect.height | |
| const startX = (e.clientX - rect.left) * sx | |
| const startY = (e.clientY - rect.top) * sy | |
| // Alt-drag on an image element pans the focal point inside the frame | |
| // instead of moving the element. Same gesture most photo apps use. | |
| const isPanGesture = mode === 'move' && element.type === 'image' && e.altKey | |
| const start = { | |
| posX: element.posX, | |
| posY: element.posY, | |
| width: element.width ?? 120, | |
| height: element.height ?? 80, | |
| imageOffsetX: element.imageOffsetX ?? 50, | |
| imageOffsetY: element.imageOffsetY ?? 50, | |
| } | |
| const onMove = (event: PointerEvent) => { | |
| const x = (event.clientX - rect.left) * sx | |
| const y = (event.clientY - rect.top) * sy | |
| const dx = x - startX | |
| const dy = y - startY | |
| if (isPanGesture) { | |
| const ow = element.width || 1 | |
| const oh = element.height || 1 | |
| patchElement(element.id, { | |
| imageOffsetX: Math.max(0, Math.min(100, Math.round(start.imageOffsetX - (dx / ow) * 100))), | |
| imageOffsetY: Math.max(0, Math.min(100, Math.round(start.imageOffsetY - (dy / oh) * 100))), | |
| }) | |
| } else if (mode === 'move') { | |
| patchElement(element.id, { | |
| posX: Math.round(start.posX + dx), | |
| posY: Math.round(start.posY + dy), | |
| }) | |
| } else { | |
| const next = { ...start } | |
| if (mode.includes('e')) next.width = Math.max(20, Math.round(start.width + dx)) | |
| if (mode.includes('s')) next.height = Math.max(20, Math.round(start.height + dy)) | |
| if (mode.includes('w')) { | |
| const newW = Math.max(20, Math.round(start.width - dx)) | |
| next.posX = Math.round(start.posX + (start.width - newW)) | |
| next.width = newW | |
| } | |
| if (mode.includes('n')) { | |
| const newH = Math.max(20, Math.round(start.height - dy)) | |
| next.posY = Math.round(start.posY + (start.height - newH)) | |
| next.height = newH | |
| } | |
| patchElement(element.id, next) | |
| } | |
| } | |
| const onUp = () => { | |
| window.removeEventListener('pointermove', onMove) | |
| window.removeEventListener('pointerup', onUp) | |
| } | |
| window.addEventListener('pointermove', onMove) | |
| window.addEventListener('pointerup', onUp) | |
| } | |
| const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { | |
| if (!selected || selected.locked) return | |
| if (NUDGE_KEYS[e.key]) { | |
| e.preventDefault() | |
| const [dx, dy] = NUDGE_KEYS[e.key] | |
| const step = e.shiftKey ? 10 : 1 | |
| patchSelected({ | |
| posX: selected.posX + dx * step, | |
| posY: selected.posY + dy * step, | |
| }) | |
| } else if (e.key === 'Escape') { | |
| setSelectedId(null) | |
| } | |
| } | |
| const orderedElements = Object.values(template.elements) | |
| .filter((el) => el.visible !== false) | |
| .sort((a, b) => (a.zIndex ?? 5) - (b.zIndex ?? 5)) | |
| const allElements = Object.values(template.elements).sort( | |
| (a, b) => (b.zIndex ?? 5) - (a.zIndex ?? 5), | |
| ) | |
| return ( | |
| <div className="bg-slate-50 dark:bg-slate-950/30"> | |
| {/* ββ Project metadata bar βββββββββββββββββββββββββββββββββββ */} | |
| <div className="hidden"> | |
| <Field label="Chapter #"> | |
| <input | |
| className="input h-9" | |
| style={{ width: 70 }} | |
| value={settings.auto_thumbnail_chapter_num ?? ''} | |
| onChange={(e) => onChange('auto_thumbnail_chapter_num', e.target.value)} | |
| placeholder="2" | |
| /> | |
| </Field> | |
| <Field label="Year"> | |
| <input | |
| className="input h-9" | |
| style={{ width: 90 }} | |
| value={settings.auto_thumbnail_year ?? '2083'} | |
| onChange={(e) => onChange('auto_thumbnail_year', e.target.value)} | |
| placeholder="2083" | |
| /> | |
| </Field> | |
| <Field label="Chapter prefix"> | |
| <input | |
| className="input h-9" | |
| style={{ width: 110 }} | |
| value={settings.auto_thumbnail_chapter_prefix ?? ''} | |
| onChange={(e) => onChange('auto_thumbnail_chapter_prefix', e.target.value)} | |
| placeholder="ΰ€ͺΰ€Ύΰ€ " | |
| /> | |
| </Field> | |
| <div className="ml-auto flex flex-wrap items-end gap-2"> | |
| <Field label="Canvas background"> | |
| <div className="flex items-center gap-2"> | |
| <input | |
| type="color" | |
| className="h-9 w-12 rounded-md border border-slate-200 bg-white" | |
| value={asHexColor(settings.auto_thumbnail_canvas_background ?? template.canvasBackground, '#4caf50')} | |
| onChange={(e) => onChange('auto_thumbnail_canvas_background', e.target.value)} | |
| /> | |
| {settings.auto_thumbnail_canvas_background && ( | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm" | |
| onClick={() => onChange('auto_thumbnail_canvas_background', undefined)} | |
| title="Reset canvas background" | |
| > | |
| <RotateCcw size={12} /> | |
| </button> | |
| )} | |
| </div> | |
| </Field> | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm" | |
| onClick={resetAllOverrides} | |
| title="Reset every customisation back to the defaults" | |
| > | |
| <RotateCcw size={12} /> Reset all | |
| </button> | |
| </div> | |
| </div> | |
| {/* ββ Workspace ββββββββββββββββββββββββββββββββββββββββββββββ */} | |
| <div className="grid gap-4 p-5 lg:grid-cols-[minmax(0,1fr),360px]"> | |
| {/* β Canvas + add bar β */} | |
| <div className="min-w-0"> | |
| <div className="grid grid-cols-[32px_minmax(0,1fr)] grid-rows-[24px_auto] gap-0 rounded-xl bg-slate-100 p-2 dark:bg-slate-900/40"> | |
| <div /> | |
| <ThumbnailRuler orientation="horizontal" /> | |
| <ThumbnailRuler orientation="vertical" /> | |
| <div | |
| ref={frameRef} | |
| data-thumbnail-frame="true" | |
| tabIndex={0} | |
| onKeyDown={onKeyDown} | |
| className="relative w-full overflow-hidden rounded-xl border border-slate-300 bg-slate-200 shadow-sm outline-none focus:ring-2 focus:ring-brand-500 dark:border-white/10 dark:bg-slate-900" | |
| style={{ | |
| aspectRatio: `${template.canvasWidth} / ${template.canvasHeight}`, | |
| backgroundColor: template.canvasBackground, | |
| }} | |
| onPointerDown={(e) => { | |
| if (e.target === e.currentTarget) setSelectedId(null) | |
| }} | |
| > | |
| {orderedElements.map((el) => ( | |
| <ThumbnailEditableElement | |
| key={el.id} | |
| element={el} | |
| selected={selectedId === el.id} | |
| canvasWidth={template.canvasWidth} | |
| canvasHeight={template.canvasHeight} | |
| canvasScale={canvasScale} | |
| onPointerDown={(event, mode) => startDrag(event, el, mode)} | |
| onSelect={() => setSelectedId(el.id)} | |
| onContextMenu={(event) => | |
| setContextMenu({ | |
| x: event.clientX, | |
| y: event.clientY, | |
| elementId: el.id, | |
| }) | |
| } | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 bg-white p-2 dark:border-white/10 dark:bg-slate-950/40"> | |
| <span className="px-1 text-xs text-slate-500 dark:text-slate-400"> | |
| Select a box to edit. Drag to move, corner handles to resize, arrow keys nudge. Alt-drag an image to pan its focal point. | |
| </span> | |
| <span className="hidden"> | |
| Drag to move Β· Shift+arrows = 10px Β· βD duplicates Β· Del removes | |
| </span> | |
| </div> | |
| </div> | |
| {/* β Inspector β */} | |
| <div className="rounded-xl border border-slate-200 bg-white dark:border-white/10 dark:bg-slate-950/40"> | |
| {/* Selected element header */} | |
| <div className="flex items-center justify-between gap-2 border-b border-slate-200 px-4 py-3 dark:border-white/10"> | |
| <div className="min-w-0"> | |
| <div className="text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400"> | |
| {selected ? 'Editing' : 'Inspector'} | |
| </div> | |
| <div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50"> | |
| {selected ? elementDisplayName(selected) : 'Click anything on the canvas to edit it'} | |
| </div> | |
| </div> | |
| {selected && ( | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm shrink-0" | |
| onClick={() => setSelectedId(null)} | |
| title="Deselect" | |
| > | |
| Γ | |
| </button> | |
| )} | |
| </div> | |
| {/* Tab strip */} | |
| <div className="hidden" /> | |
| {/* Tab body */} | |
| <div className="p-4 text-sm"> | |
| {tab === 'layers' ? ( | |
| <LayersPanel | |
| allElements={allElements} | |
| selectedId={selectedId} | |
| hidden={hidden} | |
| added={added} | |
| onSelect={setSelectedId} | |
| onToggleVisibility={(el) => { | |
| if (isAddedElement(el.id)) { | |
| patchElement(el.id, { visible: !(el.visible !== false) }) | |
| return | |
| } | |
| if (hidden.includes(el.id)) restoreElement(el.id) | |
| else onChange('auto_thumbnail_hidden_elements', [...hidden, el.id]) | |
| }} | |
| onToggleLock={(el) => patchElement(el.id, { locked: !el.locked })} | |
| onBringFront={(el) => patchElement(el.id, { | |
| zIndex: Math.max(0, ...Object.values(template.elements).map((e) => e.zIndex ?? 5)) + 1, | |
| })} | |
| onSendBack={(el) => patchElement(el.id, { | |
| zIndex: Math.min(0, ...Object.values(template.elements).map((e) => e.zIndex ?? 5)) - 1, | |
| })} | |
| onRemove={(el) => { | |
| setSelectedId(el.id) | |
| removeSelected() | |
| }} | |
| /> | |
| ) : !selected ? ( | |
| <div className="flex flex-col items-start gap-2 rounded-md border border-dashed border-slate-200 bg-slate-50 p-4 text-xs text-slate-500 dark:border-white/10 dark:bg-white/5 dark:text-slate-400"> | |
| <Sparkles size={14} className="text-brand-500" /> | |
| <div> | |
| Click any element on the preview β the header bar, the chapter title, the side photo β to edit it here. Or use the toolbar below the canvas to add new text, shapes and badges. | |
| </div> | |
| </div> | |
| ) : tab === 'content' ? ( | |
| <ContentTab | |
| selected={selected} | |
| settings={settings} | |
| patchSelected={patchSelected} | |
| onAutoThumbnailSideImage={onAutoThumbnailSideImage} | |
| onReset={clearSelectedOverride} | |
| /> | |
| ) : tab === 'style' ? ( | |
| <StyleTab selected={selected} patchSelected={patchSelected} /> | |
| ) : ( | |
| <LayoutTab | |
| selected={selected} | |
| patchSelected={patchSelected} | |
| duplicateSelected={duplicateSelected} | |
| bringToFront={bringToFront} | |
| sendToBack={sendToBack} | |
| clearSelectedOverride={clearSelectedOverride} | |
| removeSelected={removeSelected} | |
| isCustom={isAddedElement(selected.id)} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {contextMenu && ( | |
| <ThumbnailContextMenu | |
| x={contextMenu.x} | |
| y={contextMenu.y} | |
| element={template.elements[contextMenu.elementId]} | |
| isAdded={isAddedElement(contextMenu.elementId)} | |
| isHidden={hidden.includes(contextMenu.elementId)} | |
| onClose={() => setContextMenu(null)} | |
| onDuplicate={() => { | |
| setSelectedId(contextMenu.elementId) | |
| duplicateSelected() | |
| }} | |
| onBringFront={() => { | |
| const target = template.elements[contextMenu.elementId] | |
| if (!target) return | |
| const maxZ = Math.max(0, ...Object.values(template.elements).map((e) => e.zIndex ?? 5)) | |
| patchElement(target.id, { zIndex: maxZ + 1 }) | |
| }} | |
| onSendBack={() => { | |
| const target = template.elements[contextMenu.elementId] | |
| if (!target) return | |
| const minZ = Math.min(0, ...Object.values(template.elements).map((e) => e.zIndex ?? 5)) | |
| patchElement(target.id, { zIndex: minZ - 1 }) | |
| }} | |
| onToggleLock={() => { | |
| const target = template.elements[contextMenu.elementId] | |
| if (!target) return | |
| patchElement(target.id, { locked: !target.locked }) | |
| }} | |
| onToggleVisibility={() => { | |
| const id = contextMenu.elementId | |
| if (isAddedElement(id)) { | |
| const target = template.elements[id] | |
| if (target) patchElement(id, { visible: !(target.visible !== false) }) | |
| return | |
| } | |
| if (hidden.includes(id)) restoreElement(id) | |
| else onChange('auto_thumbnail_hidden_elements', [...hidden, id]) | |
| }} | |
| onDelete={() => { | |
| setSelectedId(contextMenu.elementId) | |
| removeSelected() | |
| }} | |
| /> | |
| )} | |
| </div> | |
| ) | |
| } | |
| /* βββββββββββββββββ Right-click element context menu ββββββββββββββββββ */ | |
| function ThumbnailContextMenu({ | |
| x, | |
| y, | |
| element, | |
| isAdded, | |
| isHidden, | |
| onClose, | |
| onDuplicate, | |
| onBringFront, | |
| onSendBack, | |
| onToggleLock, | |
| onToggleVisibility, | |
| onDelete, | |
| }: { | |
| x: number | |
| y: number | |
| element: ThumbnailElement | undefined | |
| isAdded: boolean | |
| isHidden: boolean | |
| onClose: () => void | |
| onDuplicate: () => void | |
| onBringFront: () => void | |
| onSendBack: () => void | |
| onToggleLock: () => void | |
| onToggleVisibility: () => void | |
| onDelete: () => void | |
| }) { | |
| // Close on Esc or any click outside the menu β same model as native | |
| // OS context menus, no scrim needed. | |
| useEffect(() => { | |
| const onKey = (e: KeyboardEvent) => { | |
| if (e.key === 'Escape') onClose() | |
| } | |
| const onClick = () => onClose() | |
| window.addEventListener('keydown', onKey) | |
| window.addEventListener('click', onClick) | |
| window.addEventListener('contextmenu', onClick) | |
| return () => { | |
| window.removeEventListener('keydown', onKey) | |
| window.removeEventListener('click', onClick) | |
| window.removeEventListener('contextmenu', onClick) | |
| } | |
| }, [onClose]) | |
| if (!element) return null | |
| const wrap = (handler: () => void) => (e: React.MouseEvent) => { | |
| e.stopPropagation() | |
| handler() | |
| onClose() | |
| } | |
| // Clamp to viewport so the menu never opens off-screen. | |
| const left = Math.min(x, window.innerWidth - 220) | |
| const top = Math.min(y, window.innerHeight - 260) | |
| const item = | |
| 'flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-xs hover:bg-slate-100 dark:hover:bg-white/10' | |
| return ( | |
| <div | |
| role="menu" | |
| className="fixed z-[1000] min-w-[200px] rounded-md border border-slate-200 bg-white py-1 text-slate-800 shadow-lg dark:border-white/10 dark:bg-slate-900 dark:text-slate-100" | |
| style={{ left, top }} | |
| onClick={(e) => e.stopPropagation()} | |
| onContextMenu={(e) => e.preventDefault()} | |
| > | |
| <div className="px-3 py-1 text-[11px] uppercase tracking-wide text-slate-500 dark:text-slate-400"> | |
| {elementDisplayName(element)} | |
| </div> | |
| <button type="button" className={item} onClick={wrap(onDuplicate)}> | |
| Duplicate <span className="text-slate-400">βD</span> | |
| </button> | |
| <button type="button" className={item} onClick={wrap(onBringFront)}> | |
| Bring to front | |
| </button> | |
| <button type="button" className={item} onClick={wrap(onSendBack)}> | |
| Send to back | |
| </button> | |
| <div className="my-1 border-t border-slate-200 dark:border-white/10" /> | |
| <button type="button" className={item} onClick={wrap(onToggleLock)}> | |
| {element.locked ? 'Unlock' : 'Lock'} | |
| </button> | |
| <button type="button" className={item} onClick={wrap(onToggleVisibility)}> | |
| {isHidden || element.visible === false ? 'Show' : 'Hide'} | |
| </button> | |
| <div className="my-1 border-t border-slate-200 dark:border-white/10" /> | |
| <button | |
| type="button" | |
| className={`${item} text-rose-600 hover:bg-rose-50 dark:text-rose-300 dark:hover:bg-rose-500/15`} | |
| onClick={wrap(onDelete)} | |
| > | |
| {isAdded ? 'Delete' : 'Hide from canvas'} <span className="text-slate-400">Del</span> | |
| </button> | |
| </div> | |
| ) | |
| } | |
| /* βββββββββββββββββββββββββ Inspector tabs ββββββββββββββββββββββββββ */ | |
| function ContentTab({ | |
| selected, | |
| settings, | |
| patchSelected, | |
| onAutoThumbnailSideImage, | |
| onReset, | |
| }: { | |
| selected: ThumbnailElement | |
| settings: GenerateSettings | |
| patchSelected: (patch: Partial<ThumbnailElement>) => void | |
| onAutoThumbnailSideImage: (file: File | null) => void | |
| onReset: () => void | |
| }) { | |
| if (selected.type === 'image') { | |
| const fitMode = selected.imageFitMode ?? 'cover' | |
| const zoom = selected.imageZoom ?? 100 | |
| const hasImage = Boolean(selected.imageUrl || settings.auto_thumbnail_side_image_url) | |
| return ( | |
| <div className="space-y-3"> | |
| <Field label="Image"> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <label className="btn-secondary btn-sm cursor-pointer"> | |
| <Upload size={12} /> {hasImage ? 'Replace' : 'Upload'} image | |
| <input | |
| type="file" | |
| accept="image/png,image/jpeg,image/webp,image/bmp" | |
| className="hidden" | |
| onChange={(e) => { | |
| const file = e.target.files?.[0] | |
| if (file) onAutoThumbnailSideImage(file) | |
| e.target.value = '' | |
| }} | |
| /> | |
| </label> | |
| {hasImage && ( | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm" | |
| onClick={() => onAutoThumbnailSideImage(null)} | |
| > | |
| <Trash2 size={12} /> Remove | |
| </button> | |
| )} | |
| </div> | |
| </Field> | |
| <Field label="Fit mode"> | |
| <div className="grid grid-cols-3 gap-1"> | |
| {(['cover', 'contain', 'stretch'] as const).map((mode) => ( | |
| <button | |
| key={mode} | |
| type="button" | |
| className={ | |
| 'rounded-md border px-2 py-1 text-xs ' + | |
| (fitMode === mode | |
| ? 'border-brand-500 bg-brand-50 text-brand-700 dark:bg-brand-500/15 dark:text-brand-200' | |
| : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-950/40 dark:text-slate-200') | |
| } | |
| onClick={() => patchSelected({ imageFitMode: mode })} | |
| title={ | |
| mode === 'cover' | |
| ? 'Fill the box, crop overflow' | |
| : mode === 'contain' | |
| ? 'Show the whole image, may letterbox' | |
| : 'Stretch to fill, ignore aspect ratio' | |
| } | |
| > | |
| {mode === 'cover' ? 'Fill' : mode === 'contain' ? 'Fit' : 'Stretch'} | |
| </button> | |
| ))} | |
| </div> | |
| </Field> | |
| {hasImage && ( | |
| <Field label="Drag to position image"> | |
| <ImagePanThumbnail element={selected} onChange={patchSelected} /> | |
| </Field> | |
| )} | |
| <Field label="Anchor"> | |
| <div className="grid grid-cols-3 gap-1"> | |
| {[ | |
| [0, 0, 'β'], [50, 0, 'β'], [100, 0, 'β'], | |
| [0, 50, 'β'], [50, 50, 'β’'], [100, 50, 'β'], | |
| [0, 100, 'β'], [50, 100, 'β'], [100, 100, 'β'], | |
| ].map(([ox, oy, label]) => ( | |
| <button | |
| key={`${ox}-${oy}`} | |
| type="button" | |
| className="rounded-md border border-slate-200 bg-white px-1 py-1 text-sm text-slate-700 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-950/40 dark:text-slate-200" | |
| onClick={() => | |
| patchSelected({ | |
| imageOffsetX: ox as number, | |
| imageOffsetY: oy as number, | |
| }) | |
| } | |
| title={`${ox}% / ${oy}%`} | |
| > | |
| {label} | |
| </button> | |
| ))} | |
| </div> | |
| </Field> | |
| <Field label={`Zoom (${zoom}%)`}> | |
| <input | |
| type="range" | |
| min={50} | |
| max={300} | |
| step={1} | |
| className="w-full" | |
| value={zoom} | |
| onChange={(e) => patchSelected({ imageZoom: Number(e.target.value) })} | |
| /> | |
| </Field> | |
| <Field label={`Dark overlay (${selected.imageOverlay ?? 0}%)`}> | |
| <input | |
| type="range" | |
| min={0} | |
| max={100} | |
| step={1} | |
| className="w-full" | |
| value={selected.imageOverlay ?? 0} | |
| onChange={(e) => patchSelected({ imageOverlay: Number(e.target.value) })} | |
| /> | |
| </Field> | |
| <div className="flex flex-wrap gap-2"> | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm" | |
| onClick={() => | |
| patchSelected({ | |
| imageOffsetX: 50, | |
| imageOffsetY: 50, | |
| imageZoom: 100, | |
| imageFitMode: 'cover', | |
| }) | |
| } | |
| title="Reset framing β center, 100% zoom, cover" | |
| > | |
| <RotateCcw size={12} /> Reset framing | |
| </button> | |
| <button type="button" className="btn-ghost btn-sm" onClick={onReset}> | |
| <RotateCcw size={12} /> Reset overrides | |
| </button> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| const isText = selected.type !== 'panel' && selected.type !== 'shape' | |
| return ( | |
| <div className="space-y-4"> | |
| {isText && ( | |
| <> | |
| <Field label="Text"> | |
| <textarea | |
| className="textarea h-24" | |
| value={selected.text} | |
| onChange={(e) => patchSelected({ text: e.target.value })} | |
| /> | |
| </Field> | |
| <Field label={`Font size (${selected.fontSize}px)`}> | |
| <input | |
| type="range" | |
| min={12} | |
| max={200} | |
| className="w-full" | |
| value={Math.min(selected.fontSize, 200)} | |
| onChange={(e) => patchSelected({ fontSize: Number(e.target.value) })} | |
| /> | |
| </Field> | |
| <Field label="Text colour"> | |
| <input | |
| type="color" | |
| className="h-10 w-full rounded-md border border-slate-200 bg-white" | |
| value={asHexColor(selected.color, '#000000')} | |
| onChange={(e) => patchSelected({ color: e.target.value })} | |
| /> | |
| </Field> | |
| </> | |
| )} | |
| <Field label="Box colour"> | |
| <input | |
| type="color" | |
| className="h-10 w-full rounded-md border border-slate-200 bg-white" | |
| value={asHexColor(selected.backgroundColor, '#ffffff')} | |
| onChange={(e) => patchSelected({ backgroundColor: e.target.value })} | |
| /> | |
| </Field> | |
| <button type="button" className="btn-ghost btn-sm" onClick={onReset}> | |
| <RotateCcw size={12} /> Reset selected | |
| </button> | |
| </div> | |
| ) | |
| } | |
| /** Mini-preview panel that lets the user drag the image's focal point. | |
| * | |
| * Behaviour mirrors what most photo apps call "pan inside frame" β we treat | |
| * the rendered thumbnail as a draggable surface and translate pixel deltas | |
| * into `imageOffsetX/Y` percentages (0=left/top, 100=right/bottom of the | |
| * scaled image). The visual is a 1:1 reflection of the on-canvas frame so | |
| * the user sees the exact framing they're choosing. */ | |
| function ImagePanThumbnail({ | |
| element, | |
| onChange, | |
| }: { | |
| element: ThumbnailElement | |
| onChange: (patch: Partial<ThumbnailElement>) => void | |
| }) { | |
| const ref = useRef<HTMLDivElement | null>(null) | |
| const [dragging, setDragging] = useState(false) | |
| const fitMode = element.imageFitMode ?? 'cover' | |
| const zoom = element.imageZoom ?? 100 | |
| const offsetX = element.imageOffsetX ?? 50 | |
| const offsetY = element.imageOffsetY ?? 50 | |
| // Match the canvas aspect ratio so the visible framing matches reality. | |
| const aspect = element.width && element.height ? element.width / element.height : 16 / 9 | |
| const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => { | |
| if (!element.imageUrl) return | |
| event.preventDefault() | |
| setDragging(true) | |
| ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) | |
| } | |
| const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => { | |
| if (!dragging || !ref.current) return | |
| const rect = ref.current.getBoundingClientRect() | |
| if (rect.width <= 0 || rect.height <= 0) return | |
| const px = ((event.clientX - rect.left) / rect.width) * 100 | |
| const py = ((event.clientY - rect.top) / rect.height) * 100 | |
| onChange({ | |
| imageOffsetX: Math.max(0, Math.min(100, Math.round(px))), | |
| imageOffsetY: Math.max(0, Math.min(100, Math.round(py))), | |
| }) | |
| } | |
| const stop = (event: React.PointerEvent<HTMLDivElement>) => { | |
| if (!dragging) return | |
| setDragging(false) | |
| try { | |
| ;(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId) | |
| } catch { | |
| /* not captured */ | |
| } | |
| } | |
| const bgSize = | |
| fitMode === 'stretch' | |
| ? '100% 100%' | |
| : fitMode === 'contain' | |
| ? zoom === 100 ? 'contain' : `${zoom}% ${zoom}%` | |
| : zoom === 100 ? 'cover' : `${zoom}% auto` | |
| return ( | |
| <div | |
| ref={ref} | |
| role="presentation" | |
| onPointerDown={handlePointerDown} | |
| onPointerMove={handlePointerMove} | |
| onPointerUp={stop} | |
| onPointerCancel={stop} | |
| className="relative w-full overflow-hidden rounded-md border border-slate-200 bg-slate-100 dark:border-white/10 dark:bg-slate-900" | |
| style={{ | |
| aspectRatio: `${aspect}`, | |
| backgroundImage: element.imageUrl ? `url("${element.imageUrl}")` : undefined, | |
| backgroundSize: bgSize, | |
| backgroundPosition: `${offsetX}% ${offsetY}%`, | |
| backgroundRepeat: 'no-repeat', | |
| backgroundColor: element.backgroundColor || '#111111', | |
| cursor: dragging ? 'grabbing' : element.imageUrl ? 'grab' : 'default', | |
| touchAction: 'none', | |
| }} | |
| > | |
| {/* Crosshair marker showing the current focal point. */} | |
| <div | |
| className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow" | |
| style={{ | |
| left: `${offsetX}%`, | |
| top: `${offsetY}%`, | |
| backgroundColor: 'rgba(15, 23, 42, 0.6)', | |
| }} | |
| /> | |
| {!element.imageUrl && ( | |
| <div className="absolute inset-0 flex items-center justify-center text-xs text-slate-500"> | |
| Upload an image to drag its focal point | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| function StyleTab({ | |
| selected, | |
| patchSelected, | |
| }: { | |
| selected: ThumbnailElement | |
| patchSelected: (patch: Partial<ThumbnailElement>) => void | |
| }) { | |
| const isText = selected.type !== 'panel' && selected.type !== 'shape' && selected.type !== 'image' | |
| return ( | |
| <div className="space-y-4"> | |
| {isText && ( | |
| <div className="grid grid-cols-2 gap-3"> | |
| <NumField | |
| step="thumbnail" name="selected_font_size" label="Font size" | |
| value={selected.fontSize} | |
| // Clamp to 8β200 to match the slider above; lets the user type | |
| // very large headings without losing the 200 px ceiling that the | |
| // slider enforces. | |
| onChange={(v) => patchSelected({ fontSize: Math.min(200, Math.max(8, v)) })} | |
| /> | |
| <Field label="Weight"> | |
| <select | |
| className="input h-9" | |
| value={selected.fontWeight} | |
| onChange={(e) => patchSelected({ fontWeight: e.target.value })} | |
| > | |
| <option value="400">Regular</option> | |
| <option value="500">Medium</option> | |
| <option value="600">Semibold</option> | |
| <option value="700">Bold</option> | |
| <option value="800">Extra-bold</option> | |
| <option value="900">Black</option> | |
| </select> | |
| </Field> | |
| <Field label="Align"> | |
| <select | |
| className="input h-9" | |
| value={selected.textAlign ?? 'center'} | |
| onChange={(e) => | |
| patchSelected({ textAlign: e.target.value as ThumbnailElement['textAlign'] }) | |
| } | |
| > | |
| <option value="left">Left</option> | |
| <option value="center">Center</option> | |
| <option value="right">Right</option> | |
| </select> | |
| </Field> | |
| <NumField | |
| step="thumbnail" name="sel_letter" label="Letter spacing" | |
| value={selected.letterSpacing ?? 0} | |
| onChange={(v) => patchSelected({ letterSpacing: v })} | |
| /> | |
| <Field label="Text colour"> | |
| <input | |
| type="color" | |
| className="h-9 w-full rounded-md border border-slate-200 bg-white" | |
| value={asHexColor(selected.color, '#000000')} | |
| onChange={(e) => patchSelected({ color: e.target.value })} | |
| /> | |
| </Field> | |
| <NumField | |
| step="thumbnail" name="sel_shadow_blur" label="Text shadow" | |
| value={selected.shadowBlur ?? 0} | |
| onChange={(v) => patchSelected({ shadowBlur: Math.max(0, v) })} | |
| /> | |
| <NumField | |
| step="thumbnail" name="sel_stroke" label="Outline width" | |
| value={selected.strokeWidth ?? 0} | |
| onChange={(v) => patchSelected({ strokeWidth: Math.max(0, v) })} | |
| /> | |
| {(selected.strokeWidth ?? 0) > 0 && ( | |
| <Field label="Outline colour"> | |
| <input | |
| type="color" | |
| className="h-9 w-full rounded-md border border-slate-200 bg-white" | |
| value={asHexColor(selected.strokeColor ?? '#000000', '#000000')} | |
| onChange={(e) => patchSelected({ strokeColor: e.target.value })} | |
| /> | |
| </Field> | |
| )} | |
| </div> | |
| )} | |
| {selected.type !== 'image' && ( | |
| <div className="grid grid-cols-2 gap-3"> | |
| <Field label="Fill"> | |
| <div className="flex items-center gap-2"> | |
| <input | |
| type="color" | |
| className="h-9 w-12 rounded-md border border-slate-200 bg-white" | |
| value={asHexColor(selected.backgroundColor, '#ffffff')} | |
| onChange={(e) => patchSelected({ backgroundColor: e.target.value })} | |
| /> | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm" | |
| onClick={() => patchSelected({ backgroundColor: 'transparent' })} | |
| title="Make fill transparent" | |
| > | |
| Clear | |
| </button> | |
| </div> | |
| </Field> | |
| <NumField | |
| step="thumbnail" name="sel_radius" label="Corner radius" | |
| value={selected.borderRadius} | |
| onChange={(v) => patchSelected({ borderRadius: Math.max(0, v) })} | |
| /> | |
| </div> | |
| )} | |
| {selected.type === 'shape' && ( | |
| <Field label="Shape"> | |
| <select | |
| className="input h-9" | |
| value={selected.shapeType ?? 'rectangle'} | |
| onChange={(e) => patchSelected({ shapeType: e.target.value as ThumbnailShapeType })} | |
| > | |
| <option value="rectangle">Rectangle</option> | |
| <option value="pill">Pill</option> | |
| <option value="circle">Circle</option> | |
| <option value="line">Line</option> | |
| </select> | |
| </Field> | |
| )} | |
| <NumField | |
| step="thumbnail" name="sel_opacity" label="Opacity (%)" | |
| value={selected.opacity ?? 100} | |
| onChange={(v) => patchSelected({ opacity: Math.max(0, Math.min(100, v)) })} | |
| /> | |
| </div> | |
| ) | |
| } | |
| function LayoutTab({ | |
| selected, | |
| patchSelected, | |
| duplicateSelected, | |
| bringToFront, | |
| sendToBack, | |
| clearSelectedOverride, | |
| removeSelected, | |
| isCustom, | |
| }: { | |
| selected: ThumbnailElement | |
| patchSelected: (patch: Partial<ThumbnailElement>) => void | |
| duplicateSelected: () => void | |
| bringToFront: () => void | |
| sendToBack: () => void | |
| clearSelectedOverride: () => void | |
| removeSelected: () => void | |
| isCustom: boolean | |
| }) { | |
| return ( | |
| <div className="space-y-4"> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <NumField step="thumbnail" name="sel_x" label="X" value={selected.posX} onChange={(v) => patchSelected({ posX: v })} /> | |
| <NumField step="thumbnail" name="sel_y" label="Y" value={selected.posY} onChange={(v) => patchSelected({ posY: v })} /> | |
| <NumField step="thumbnail" name="sel_w" label="Width" value={selected.width ?? 0} onChange={(v) => patchSelected({ width: Math.max(20, v) })} /> | |
| <NumField step="thumbnail" name="sel_h" label="Height" value={selected.height ?? 0} onChange={(v) => patchSelected({ height: Math.max(20, v) })} /> | |
| <NumField step="thumbnail" name="sel_rot" label="RotationΒ°" value={selected.rotation ?? 0} onChange={(v) => patchSelected({ rotation: v })} /> | |
| <NumField step="thumbnail" name="sel_z" label="Z-index" value={selected.zIndex ?? 5} onChange={(v) => patchSelected({ zIndex: v })} /> | |
| </div> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <button type="button" className="btn-secondary btn-sm" onClick={duplicateSelected}> | |
| <CopyIcon size={12} /> Duplicate | |
| </button> | |
| <button type="button" className="btn-secondary btn-sm" onClick={bringToFront} title="Bring to front"> | |
| <Layers size={12} /> Front | |
| </button> | |
| <button type="button" className="btn-secondary btn-sm" onClick={sendToBack} title="Send to back"> | |
| <Layers size={12} /> Back | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-secondary btn-sm" | |
| onClick={() => patchSelected({ locked: !selected.locked })} | |
| > | |
| {selected.locked ? <Unlock size={12} /> : <Lock size={12} />} | |
| {selected.locked ? 'Unlock' : 'Lock'} | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-secondary btn-sm" | |
| onClick={() => patchSelected({ visible: !(selected.visible !== false) })} | |
| > | |
| {selected.visible !== false ? <EyeOff size={12} /> : <Eye size={12} />} | |
| {selected.visible !== false ? 'Hide' : 'Show'} | |
| </button> | |
| {!isCustom && ( | |
| <button type="button" className="btn-ghost btn-sm" onClick={clearSelectedOverride} title="Reset overrides for this element"> | |
| <RotateCcw size={12} /> Reset | |
| </button> | |
| )} | |
| <button type="button" className="btn-ghost btn-sm text-rose-600 dark:text-rose-300" onClick={removeSelected}> | |
| <Trash2 size={12} /> Remove | |
| </button> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function LayersPanel({ | |
| allElements, | |
| selectedId, | |
| hidden, | |
| added, | |
| onSelect, | |
| onToggleVisibility, | |
| onToggleLock, | |
| onBringFront, | |
| onSendBack, | |
| onRemove, | |
| }: { | |
| allElements: ThumbnailElement[] | |
| selectedId: string | null | |
| hidden: string[] | |
| added: NonNullable<GenerateSettings['auto_thumbnail_added_elements']> | |
| onSelect: (id: string) => void | |
| onToggleVisibility: (el: ThumbnailElement) => void | |
| onToggleLock: (el: ThumbnailElement) => void | |
| onBringFront: (el: ThumbnailElement) => void | |
| onSendBack: (el: ThumbnailElement) => void | |
| onRemove: (el: ThumbnailElement) => void | |
| }) { | |
| return ( | |
| <div className="space-y-1"> | |
| {allElements.map((el) => { | |
| const isHidden = el.visible === false || hidden.includes(el.id) | |
| const isCustom = Boolean(added[el.id]) | |
| const isSelected = selectedId === el.id | |
| return ( | |
| <div | |
| key={el.id} | |
| className={ | |
| 'flex items-center gap-1 rounded-md border px-2 py-1.5 text-xs transition-colors ' + | |
| (isSelected | |
| ? 'border-brand-500 bg-brand-50 dark:bg-brand-500/10' | |
| : 'border-transparent hover:bg-slate-50 dark:hover:bg-white/5') | |
| } | |
| onClick={() => onSelect(el.id)} | |
| > | |
| <button | |
| type="button" | |
| className="rounded p-1 text-slate-500 hover:bg-slate-100 dark:hover:bg-white/10" | |
| onClick={(e) => { e.stopPropagation(); onToggleVisibility(el) }} | |
| title={isHidden ? 'Show' : 'Hide'} | |
| > | |
| {isHidden ? <EyeOff size={12} /> : <Eye size={12} />} | |
| </button> | |
| <button | |
| type="button" | |
| className="rounded p-1 text-slate-500 hover:bg-slate-100 dark:hover:bg-white/10" | |
| onClick={(e) => { e.stopPropagation(); onToggleLock(el) }} | |
| title={el.locked ? 'Unlock' : 'Lock'} | |
| > | |
| {el.locked ? <Lock size={12} /> : <Unlock size={12} />} | |
| </button> | |
| <span className="ml-1 min-w-0 flex-1 truncate"> | |
| {elementDisplayName(el)} | |
| {isCustom && <span className="ml-1 text-[10px] uppercase text-brand-600">+ added</span>} | |
| </span> | |
| <button | |
| type="button" | |
| className="rounded p-1 text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10" | |
| onClick={(e) => { e.stopPropagation(); onBringFront(el) }} | |
| title="Bring forward" | |
| > | |
| β² | |
| </button> | |
| <button | |
| type="button" | |
| className="rounded p-1 text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10" | |
| onClick={(e) => { e.stopPropagation(); onSendBack(el) }} | |
| title="Send back" | |
| > | |
| βΌ | |
| </button> | |
| <button | |
| type="button" | |
| className="rounded p-1 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10" | |
| onClick={(e) => { e.stopPropagation(); onRemove(el) }} | |
| title="Remove / hide" | |
| > | |
| <Trash2 size={12} /> | |
| </button> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| ) | |
| } | |
| function elementDisplayName(el: ThumbnailElement): string { | |
| const friendly = PRESET_LABELS[el.id] | |
| if (friendly) return friendly | |
| const text = (el.text || '').replace(/\s+/g, ' ').trim().slice(0, 32) | |
| if (text) return text | |
| if (el.type === 'shape') return `Shape (${el.shapeType ?? 'rectangle'})` | |
| if (el.type === 'image') return 'Image' | |
| if (el.type === 'badge') return 'Badge' | |
| return el.type | |
| } | |
| function ThumbnailRuler({ orientation }: { orientation: 'horizontal' | 'vertical' }) { | |
| const ticks = Array.from({ length: 9 }, (_, index) => index) | |
| const horizontal = orientation === 'horizontal' | |
| return ( | |
| <div | |
| className={ | |
| 'relative overflow-hidden bg-slate-200 text-[9px] text-slate-500 dark:bg-slate-800 dark:text-slate-400 ' + | |
| (horizontal ? 'h-6 rounded-t-md border-b border-slate-300 dark:border-white/10' : 'w-8 rounded-l-md border-r border-slate-300 dark:border-white/10') | |
| } | |
| > | |
| {ticks.map((tick) => { | |
| const pct = (tick / (ticks.length - 1)) * 100 | |
| return ( | |
| <span | |
| key={tick} | |
| className="absolute bg-slate-400 dark:bg-slate-500" | |
| style={ | |
| horizontal | |
| ? { left: `${pct}%`, bottom: 0, width: 1, height: tick % 2 === 0 ? 12 : 7 } | |
| : { top: `${pct}%`, right: 0, width: tick % 2 === 0 ? 12 : 7, height: 1 } | |
| } | |
| /> | |
| ) | |
| })} | |
| {ticks.filter((tick) => tick % 2 === 0).map((tick) => { | |
| const pct = (tick / (ticks.length - 1)) * 100 | |
| return ( | |
| <span | |
| key={`label-${tick}`} | |
| className="absolute leading-none" | |
| style={ | |
| horizontal | |
| ? { left: `calc(${pct}% + 3px)`, top: 3 } | |
| : { top: `calc(${pct}% + 3px)`, left: 3 } | |
| } | |
| > | |
| {tick * 10} | |
| </span> | |
| ) | |
| })} | |
| </div> | |
| ) | |
| } | |
| function ThumbnailEditableElement({ | |
| element, | |
| selected, | |
| canvasWidth, | |
| canvasHeight, | |
| canvasScale, | |
| onPointerDown, | |
| onSelect, | |
| onContextMenu, | |
| }: { | |
| element: ThumbnailElement | |
| selected: boolean | |
| canvasWidth: number | |
| canvasHeight: number | |
| canvasScale: number | |
| onPointerDown: (event: React.PointerEvent<HTMLElement>, mode: 'move' | ResizeHandle) => void | |
| onSelect: () => void | |
| onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void | |
| }) { | |
| const style: React.CSSProperties = { | |
| position: 'absolute', | |
| left: `${(element.posX / canvasWidth) * 100}%`, | |
| top: `${(element.posY / canvasHeight) * 100}%`, | |
| width: `${((element.width ?? 120) / canvasWidth) * 100}%`, | |
| height: `${((element.height ?? 80) / canvasHeight) * 100}%`, | |
| zIndex: (element.zIndex ?? 5) + 100, | |
| cursor: element.locked ? 'default' : 'move', | |
| userSelect: 'none', | |
| border: selected | |
| ? '2px solid rgb(var(--brand-500))' | |
| : '1px dashed rgba(148, 163, 184, 0.0)', | |
| background: 'transparent', | |
| boxShadow: selected ? '0 0 0 2px rgba(var(--brand-500), 0.18)' : undefined, | |
| transform: element.rotation ? `rotate(${element.rotation}deg)` : undefined, | |
| transformOrigin: 'center', | |
| } | |
| const isText = element.type !== 'panel' && element.type !== 'shape' && element.type !== 'image' | |
| const fill = element.backgroundColor && element.backgroundColor !== 'transparent' | |
| ? element.backgroundColor | |
| : 'transparent' | |
| const liveStyle: React.CSSProperties = { | |
| position: 'absolute', | |
| inset: 0, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: | |
| (element.textAlign ?? 'center') === 'left' | |
| ? 'flex-start' | |
| : (element.textAlign ?? 'center') === 'right' | |
| ? 'flex-end' | |
| : 'center', | |
| overflow: 'hidden', | |
| padding: `${(element.paddingY ?? 0) * canvasScale}px ${(element.paddingX ?? 0) * canvasScale}px`, | |
| borderRadius: element.borderRadius * canvasScale, | |
| backgroundColor: fill, | |
| color: element.color, | |
| fontFamily: element.fontFamily, | |
| fontSize: `${element.fontSize * canvasScale}px`, | |
| fontWeight: element.fontWeight as React.CSSProperties['fontWeight'], | |
| textAlign: element.textAlign ?? 'center', | |
| lineHeight: 1.08, | |
| whiteSpace: 'pre-line', | |
| pointerEvents: 'none', | |
| } | |
| if (element.type === 'badge' && !element.borderRadius) { | |
| liveStyle.clipPath = 'polygon(50% 0%, 58% 14%, 73% 7%, 76% 24%, 93% 27%, 86% 42%, 100% 50%, 86% 58%, 93% 73%, 76% 76%, 73% 93%, 58% 86%, 50% 100%, 42% 86%, 27% 93%, 24% 76%, 7% 73%, 14% 58%, 0% 50%, 14% 42%, 7% 27%, 24% 24%, 27% 7%, 42% 14%)' | |
| } | |
| if (element.type === 'image') { | |
| liveStyle.backgroundColor = element.backgroundColor || '#111111' | |
| liveStyle.backgroundImage = element.imageUrl ? `url("${element.imageUrl}")` : undefined | |
| const fitMode = element.imageFitMode ?? 'cover' | |
| const zoom = element.imageZoom ?? 100 | |
| if (fitMode === 'stretch') { | |
| liveStyle.backgroundSize = '100% 100%' | |
| } else if (fitMode === 'contain') { | |
| liveStyle.backgroundSize = zoom === 100 ? 'contain' : `${zoom}% ${zoom}%` | |
| } else { | |
| // cover (default) β auto-zoom>100 lets the user push past 100% to crop. | |
| liveStyle.backgroundSize = zoom === 100 ? 'cover' : `${zoom}% auto` | |
| } | |
| liveStyle.backgroundPosition = `${element.imageOffsetX ?? 50}% ${element.imageOffsetY ?? 50}%` | |
| liveStyle.backgroundRepeat = 'no-repeat' | |
| } | |
| return ( | |
| <div | |
| style={style} | |
| onPointerDown={(e) => onPointerDown(e, 'move')} | |
| onClick={(e) => { | |
| e.stopPropagation() | |
| onSelect() | |
| }} | |
| onContextMenu={(e) => { | |
| if (!onContextMenu) return | |
| e.preventDefault() | |
| e.stopPropagation() | |
| onSelect() | |
| onContextMenu(e) | |
| }} | |
| onMouseEnter={(e) => { | |
| if (!selected) (e.currentTarget as HTMLElement).style.border = '1px dashed rgba(148, 163, 184, 0.65)' | |
| }} | |
| onMouseLeave={(e) => { | |
| if (!selected) (e.currentTarget as HTMLElement).style.border = '1px dashed rgba(148, 163, 184, 0)' | |
| }} | |
| > | |
| <div style={liveStyle}> | |
| {isText ? element.text : element.type === 'image' && !element.imageUrl ? '' : null} | |
| </div> | |
| {selected && !element.locked && ( | |
| <> | |
| {(['nw', 'ne', 'sw', 'se'] as ResizeHandle[]).map((h) => ( | |
| <span | |
| key={h} | |
| className="absolute h-3 w-3 rounded-full bg-white shadow ring-2 ring-brand-500" | |
| style={{ | |
| top: h.includes('n') ? -6 : undefined, | |
| bottom: h.includes('s') ? -6 : undefined, | |
| left: h.includes('w') ? -6 : undefined, | |
| right: h.includes('e') ? -6 : undefined, | |
| cursor: | |
| h === 'nw' || h === 'se' ? 'nwse-resize' : 'nesw-resize', | |
| }} | |
| onPointerDown={(e) => onPointerDown(e, h)} | |
| /> | |
| ))} | |
| {(['n', 's', 'e', 'w'] as ResizeHandle[]).map((h) => ( | |
| <span | |
| key={h} | |
| className="absolute h-2.5 w-2.5 rounded-sm bg-white shadow ring-2 ring-brand-500" | |
| style={{ | |
| top: h === 'n' ? -5 : h === 's' ? undefined : '50%', | |
| bottom: h === 's' ? -5 : undefined, | |
| left: h === 'w' ? -5 : h === 'e' ? undefined : '50%', | |
| right: h === 'e' ? -5 : undefined, | |
| transform: | |
| h === 'n' || h === 's' ? 'translateX(-50%)' : 'translateY(-50%)', | |
| cursor: h === 'n' || h === 's' ? 'ns-resize' : 'ew-resize', | |
| }} | |
| onPointerDown={(e) => onPointerDown(e, h)} | |
| /> | |
| ))} | |
| </> | |
| )} | |
| </div> | |
| ) | |
| } | |
| function asHexColor(value: string, fallback: string): string { | |
| return /^#[0-9a-f]{6}$/i.test(value) ? value : fallback | |
| } | |
| function ThumbnailSlot({ | |
| kind, | |
| title, | |
| position, | |
| enabled, | |
| filename, | |
| generatedPreviewUrl, | |
| durationSec, | |
| onEnabledChange, | |
| onFilenameChange, | |
| onDurationChange, | |
| filenameError, | |
| durationError, | |
| }: { | |
| kind: 'intro' | 'outro' | |
| title: string | |
| position: string | |
| enabled: boolean | |
| filename: string | |
| generatedPreviewUrl: string | null | |
| durationSec: number | undefined | |
| onEnabledChange: (v: boolean) => void | |
| onFilenameChange: (v: string) => void | |
| onDurationChange: (v: number) => void | |
| filenameError?: string | |
| durationError?: string | |
| }) { | |
| const [uploading, setUploading] = useState(false) | |
| const [uploadErr, setUploadErr] = useState<string | null>(null) | |
| const fileFieldName = `${kind}_thumbnail_filename` | |
| const durFieldName = `${kind}_thumbnail_duration_sec` | |
| const trimmed = filename.trim() | |
| // The saved server file always wins β that is what the run actually uses. | |
| // The live editor preview is only shown when no file has been saved yet, | |
| // so the auto-builder gets a placeholder visual on first load. Once the | |
| // user clicks "Use as intro thumbnail" / "Use as outro thumbnail", this | |
| // tile reflects exactly what is on disk; any further unsaved edits are | |
| // visible in the auto-thumbnail panel above. | |
| const previewSrc = trimmed ? api.thumbnailUrl(trimmed) : generatedPreviewUrl | |
| const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0] | |
| if (!file) return | |
| setUploading(true) | |
| setUploadErr(null) | |
| try { | |
| const { filename: stored } = await api.uploadThumbnail(file) | |
| onFilenameChange(stored) | |
| } catch (err) { | |
| setUploadErr(err instanceof Error ? err.message : String(err)) | |
| } finally { | |
| setUploading(false) | |
| e.target.value = '' | |
| } | |
| } | |
| return ( | |
| <div className="rounded-md border border-slate-200 bg-white p-4 dark:border-white/10 dark:bg-white/[0.03]"> | |
| <div className="mb-3 flex items-start justify-between gap-3"> | |
| <div className="min-w-0"> | |
| <div className="text-sm font-medium text-slate-900 dark:text-slate-50">{title}</div> | |
| <div className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{position}</div> | |
| </div> | |
| <Toggle label="" checked={enabled} onChange={onEnabledChange} /> | |
| </div> | |
| {enabled && ( | |
| <div className="space-y-3"> | |
| <Field label="Image" required error={filenameError}> | |
| <div className="flex flex-col gap-3"> | |
| <label | |
| className={ | |
| 'flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed px-3 py-5 text-sm transition-colors ' + | |
| (filenameError | |
| ? 'border-rose-400 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/60 dark:bg-rose-500/10 dark:text-rose-200' | |
| : 'border-slate-300 bg-slate-50 text-slate-600 hover:bg-slate-100 dark:border-white/10 dark:bg-white/[0.03] dark:text-slate-300') | |
| } | |
| > | |
| <Upload size={16} /> | |
| {uploading | |
| ? 'Uploading...' | |
| : trimmed || generatedPreviewUrl | |
| ? 'Replace image' | |
| : 'Upload image'} | |
| <input | |
| id={fieldId('thumbnail', fileFieldName)} | |
| type="file" | |
| accept="image/png,image/jpeg,image/webp,image/bmp" | |
| className="hidden" | |
| onChange={onPickFile} | |
| disabled={uploading} | |
| /> | |
| </label> | |
| {previewSrc && ( | |
| <div className="rounded-md border border-slate-200 bg-white p-2 dark:border-white/10 dark:bg-white/[0.03]"> | |
| <img | |
| src={previewSrc} | |
| alt={`${title} preview`} | |
| className="aspect-video w-full rounded object-cover" | |
| /> | |
| <div className="mt-2 flex items-center gap-3"> | |
| <div className="min-w-0 flex-1"> | |
| <div className="truncate text-xs font-medium text-slate-800 dark:text-slate-200"> | |
| {trimmed || 'Auto-generated preview'} | |
| </div> | |
| <div className="text-[11px] text-slate-500 dark:text-slate-400"> | |
| {trimmed ? 'Stored on backend' : 'Will be uploaded when the run starts'} | |
| </div> | |
| </div> | |
| {trimmed && ( | |
| <button | |
| type="button" | |
| className="btn-ghost text-xs" | |
| onClick={() => onFilenameChange('')} | |
| > | |
| Remove | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {uploadErr && <FieldError message={uploadErr} />} | |
| </div> | |
| </Field> | |
| <Field label="Duration (seconds)" error={durationError}> | |
| <input | |
| id={fieldId('thumbnail', durFieldName)} | |
| type="number" | |
| step={0.5} | |
| className={inputCls(durationError)} | |
| value={durationSec ?? ''} | |
| onChange={(e) => { | |
| const v = Number(e.target.value) | |
| if (!Number.isNaN(v)) onDurationChange(v) | |
| }} | |
| /> | |
| </Field> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| function AdvancedStep({ | |
| settings, | |
| onChange, | |
| canFinish, | |
| onStart, | |
| running, | |
| state, | |
| cancel, | |
| errors, | |
| }: { | |
| settings: GenerateSettings | |
| onChange: Setter | |
| canFinish: boolean | |
| onStart: () => void | |
| running: boolean | |
| state: ReturnType<typeof useTrackedGenerate>['state'] | |
| cancel: () => void | |
| errors: FieldErrors | |
| }) { | |
| const autoTiming = settings.auto_timing_screenshot_slides ?? true | |
| return ( | |
| <> | |
| <StepHeader | |
| title="Advanced settings" | |
| subtitle="Final checks. Hit Start Process when you're ready β preflight will run first." | |
| /> | |
| <div className="space-y-2"> | |
| <Toggle | |
| label="Use cache" | |
| description="Reuse AI output if the same input (+ model + system prompt) was generated before." | |
| checked={settings.use_cache ?? true} | |
| onChange={(v) => onChange('use_cache', v)} | |
| /> | |
| <Toggle | |
| label="Beautify HTML" | |
| description="Normalize AI HTML for cleaner screenshots." | |
| checked={settings.beautify_html ?? false} | |
| onChange={(v) => onChange('beautify_html', v)} | |
| /> | |
| <Toggle | |
| label="Close PowerPoint before start" | |
| description="Avoid export conflicts if PowerPoint is already open." | |
| checked={settings.close_powerpoint_before_start ?? true} | |
| onChange={(v) => onChange('close_powerpoint_before_start', v)} | |
| /> | |
| <Toggle | |
| label="Auto timing for screenshot slides" | |
| description="Distribute at least 500 seconds across inserted screenshot slides for video exports." | |
| checked={autoTiming} | |
| onChange={(v) => onChange('auto_timing_screenshot_slides', v)} | |
| /> | |
| {!autoTiming && ( | |
| <div className="pl-4"> | |
| <NumField | |
| step="advanced" | |
| name="fixed_seconds_per_screenshot_slide" | |
| label="Fixed seconds per screenshot slide" | |
| numStep={0.5} | |
| value={settings.fixed_seconds_per_screenshot_slide} | |
| onChange={(v) => onChange('fixed_seconds_per_screenshot_slide', v)} | |
| error={errors.fixed_seconds_per_screenshot_slide} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex flex-wrap items-center gap-3 border-t border-slate-200 pt-4 dark:border-white/10"> | |
| {!running ? ( | |
| <button type="button" className="btn-primary" onClick={onStart}> | |
| <Play size={16} /> Start Process | |
| </button> | |
| ) : ( | |
| <button type="button" className="btn-danger" onClick={cancel}> | |
| <StopCircle size={16} /> Cancel | |
| </button> | |
| )} | |
| {!canFinish && !running && ( | |
| <span className="text-xs text-amber-600 dark:text-amber-400"> | |
| Fix the outstanding step errors β click Start Process to jump to the first one. | |
| </span> | |
| )} | |
| {state.status === 'error' && !state.rejectedReason && ( | |
| <span className="text-sm text-red-600 dark:text-red-400">{state.error}</span> | |
| )} | |
| {state.status === 'cancelled' && ( | |
| <span className="text-sm text-amber-600 dark:text-amber-400">Cancelled</span> | |
| )} | |
| </div> | |
| {state.status === 'error' && state.rejectedReason && ( | |
| <BackendRejectedBanner | |
| reason={state.rejectedReason} | |
| message={state.error ?? 'Backend rejected the run.'} | |
| /> | |
| )} | |
| </> | |
| ) | |
| } | |
| // βββ Small primitives ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function Field({ | |
| label, | |
| children, | |
| required, | |
| className, | |
| error, | |
| }: { | |
| label: string | |
| children: React.ReactNode | |
| required?: boolean | |
| className?: string | |
| error?: string | |
| }) { | |
| return ( | |
| <div className={className}> | |
| <label className="label"> | |
| {label} | |
| {required && <span className="ml-1 text-rose-500 dark:text-rose-400">*</span>} | |
| </label> | |
| {children} | |
| {error && <FieldError message={error} />} | |
| </div> | |
| ) | |
| } | |
| function FieldError({ message }: { message: string }) { | |
| return ( | |
| <div className="mt-1 flex items-center gap-1 text-xs text-rose-600 dark:text-rose-400"> | |
| <AlertCircle size={12} /> | |
| {message} | |
| </div> | |
| ) | |
| } | |
| function NumField({ | |
| step, | |
| name, | |
| label, | |
| value, | |
| onChange, | |
| numStep, | |
| error, | |
| }: { | |
| step: StepId | |
| name: string | |
| label: string | |
| value: number | undefined | |
| onChange: (v: number) => void | |
| numStep?: number | |
| error?: string | |
| }) { | |
| return ( | |
| <Field label={label} error={error}> | |
| <input | |
| id={fieldId(step, name)} | |
| type="number" | |
| step={numStep} | |
| className={inputCls(error)} | |
| value={value ?? ''} | |
| onChange={(e) => { | |
| const v = Number(e.target.value) | |
| if (!Number.isNaN(v)) onChange(v) | |
| }} | |
| /> | |
| </Field> | |
| ) | |
| } | |
| function inputCls(error?: string) { | |
| return error | |
| ? 'input border-rose-400 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/60' | |
| : 'input' | |
| } | |