| 1 | import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react' |
| 2 | import { useNavigate } from 'react-router-dom' |
| 3 | import { Button } from '../components/ui/Button' |
| 4 | import { Input } from '../components/ui/Input' |
| 5 | import { Textarea } from '../components/ui/Input' |
| 6 | import { Card, CardContent } from '../components/ui/Card' |
| 7 | import { ScrollArea } from '../components/ui/ScrollArea' |
| 8 | import { |
| 9 | Select, |
| 10 | SelectContent, |
| 11 | SelectItem, |
| 12 | SelectTrigger, |
| 13 | SelectValue |
| 14 | } from '../components/ui/Select' |
| 15 | import { StyleSelect } from '../components/style/StyleSelect' |
| 16 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../components/ui/Tooltip' |
| 17 | import { CircleAlert, Eye, FileText, Loader2, Pencil, Sparkles, X } from 'lucide-react' |
| 18 | import { useSessionStore } from '../store' |
| 19 | import { useSettingsStore } from '../store' |
| 20 | import { useToastStore } from '../store' |
| 21 | import { ModelSplitButton } from '../components/model/ModelActionButton' |
| 22 | import { useModelAction } from '../hooks/useModelAction' |
| 23 | import { ipc, type FontListItem } from '@renderer/lib/ipc' |
| 24 | import { |
| 25 | normalizeAnimationPreferences, |
| 26 | type AnimationPreferenceId, |
| 27 | type FontSelection, |
| 28 | type ParsedDocumentPlanResult |
| 29 | } from '@shared/generation' |
| 30 | import { useT } from '../i18n' |
| 31 | import ReactMarkdown from 'react-markdown' |
| 32 | import { isSupportedImageMimeType } from '@shared/image-mime' |
| 33 | import { |
| 34 | buildSuggestionDraft, |
| 35 | formatSourceOutlineBriefText, |
| 36 | SessionCreateSuggestionDialog, |
| 37 | type DocumentPlanSuggestion, |
| 38 | type DocumentPlanSuggestionDraft |
| 39 | } from '../components/session-create/SessionCreateSuggestionDialog' |
| 40 | import { AnimationPreferenceChips } from '../components/session-create/AnimationPreferenceChips' |
| 41 | import { |
| 42 | DEFAULT_SLIDE_SIZE_ID, |
| 43 | SLIDE_SIZE_PRESETS, |
| 44 | type SlideSizePresetId |
| 45 | } from '@shared/slide-size' |
| 46 | const MIN_PAGE_COUNT = 1 |
| 47 | const MAX_PAGE_COUNT = 500 |
| 48 | const DEFAULT_PAGE_COUNT = 5 |
| 49 | const MAX_DOCUMENT_SIZE_MB = 10 |
| 50 | const MAX_DOCUMENT_SIZE_BYTES = MAX_DOCUMENT_SIZE_MB * 1024 * 1024 |
| 51 | const MAX_IMAGE_SIZE_MB = 5 |
| 52 | const MAX_IMAGE_SIZE_BYTES = MAX_IMAGE_SIZE_MB * 1024 * 1024 |
| 53 | const isImageFileName = (name: string): boolean => /\.(png|jpe?g|webp)$/i.test(name.trim()) |
| 54 | |
| 55 | const isSupportedImageFile = (file: File): boolean => |
| 56 | isSupportedImageMimeType(file.type) || isImageFileName(file.name || '') |
| 57 | |
| 58 | type AttachedReferenceFile = ParsedDocumentPlanResult['files'][number] |
| 59 | |
| 60 | const compactInputClass = |
| 61 | 'h-10 border-[#d8ccb5]/70 bg-white/75 px-3 py-2 text-sm shadow-[inset_0_1px_2px_rgba(73,61,44,0.04)] placeholder:text-[#9aa18b]' |
| 62 | const settingsInputClass = |
| 63 | 'h-8 border-[#d8ccb5]/70 bg-white/75 px-2.5 py-1.5 text-xs shadow-[inset_0_1px_2px_rgba(73,61,44,0.04)] placeholder:text-[#9aa18b]' |
| 64 | const settingsSelectTriggerClass = |
| 65 | 'h-8 border-[#d8ccb5]/70 bg-white/75 px-2.5 py-1.5 text-xs shadow-[inset_0_1px_2px_rgba(73,61,44,0.04)]' |
| 66 | const compactSelectContentClass = 'text-xs' |
| 67 | const compactSelectItemClass = 'px-2.5 py-1.5 text-xs' |
| 68 | const delay = (ms: number): Promise<void> => |
| 69 | new Promise((resolve) => window.setTimeout(resolve, ms)) |
| 70 | |
| 71 | const buildNeutralInitialPrompt = (args: { |
| 72 | topic: string |
| 73 | pageCount: number |
| 74 | styleLabel: string |
| 75 | }): string => |
| 76 | [ |
| 77 | `Create a ${args.pageCount}-slide presentation about "${args.topic}".`, |
| 78 | `Style preset: ${args.styleLabel}.`, |
| 79 | 'Determine the presentation content language from the topic, detailed brief, and source documents; do not infer it from the application UI language or this instruction language.' |
| 80 | ].join('\n') |
| 81 | |
| 82 | const resolvePageCount = (raw: string): number => { |
| 83 | const parsed = Number.parseInt(raw, 10) |
| 84 | if (!Number.isFinite(parsed)) return DEFAULT_PAGE_COUNT |
| 85 | return Math.min(MAX_PAGE_COUNT, Math.max(MIN_PAGE_COUNT, parsed)) |
| 86 | } |
| 87 | |
| 88 | export function SessionCreatePage(): ReactElement { |
| 89 | const navigate = useNavigate() |
| 90 | const { createSession, loading } = useSessionStore() |
| 91 | const { settings } = useSettingsStore() |
| 92 | const { success, error, warning } = useToastStore() |
| 93 | const modelAction = useModelAction() |
| 94 | const { modelConfigs, selectedModelConfigId, ensureModelActive } = modelAction |
| 95 | const t = useT() |
| 96 | const [submitting, setSubmitting] = useState(false) |
| 97 | const [topic, setTopic] = useState('') |
| 98 | const [brief, setBrief] = useState('') |
| 99 | const [briefMode, setBriefMode] = useState<'edit' | 'preview'>('edit') |
| 100 | const [selectedAnimationPreferenceIds, setSelectedAnimationPreferenceIds] = useState< |
| 101 | AnimationPreferenceId[] |
| 102 | >([]) |
| 103 | const [pageCount, setPageCount] = useState(String(DEFAULT_PAGE_COUNT)) |
| 104 | const [slideSizeId, setSlideSizeId] = useState<SlideSizePresetId>(DEFAULT_SLIDE_SIZE_ID) |
| 105 | const [selectedStyleId, setSelectedStyleId] = useState('') |
| 106 | const [selectedTitleFontId, setSelectedTitleFontId] = useState('auto') |
| 107 | const [selectedBodyFontId, setSelectedBodyFontId] = useState('auto') |
| 108 | const [styleOptions, setStyleOptions] = useState< |
| 109 | Array<{ |
| 110 | id: string |
| 111 | label: string |
| 112 | description: string |
| 113 | styleCase?: string |
| 114 | thumbnailPath?: string | null |
| 115 | previewPath?: string | null |
| 116 | favoriteAt?: number | null |
| 117 | }> |
| 118 | >([]) |
| 119 | const [fontOptions, setFontOptions] = useState<FontListItem[]>([]) |
| 120 | const [attachedReferenceFile, setAttachedReferenceFile] = useState<AttachedReferenceFile | null>( |
| 121 | null |
| 122 | ) |
| 123 | const [parsingDocument, setParsingDocument] = useState(false) |
| 124 | const [documentParseError, setDocumentParseError] = useState<string | null>(null) |
| 125 | const [referenceDocumentPath, setReferenceDocumentPath] = useState<string | null>(null) |
| 126 | const [suggestionDraft, setSuggestionDraft] = useState<DocumentPlanSuggestionDraft | null>(null) |
| 127 | const [acceptedSourcePlan, setAcceptedSourcePlan] = |
| 128 | useState<DocumentPlanSuggestion['sourcePlan']>(undefined) |
| 129 | const [suggestionDialogOpen, setSuggestionDialogOpen] = useState(false) |
| 130 | const [applyTopicSuggestion, setApplyTopicSuggestion] = useState(false) |
| 131 | const [applyPageCountSuggestion, setApplyPageCountSuggestion] = useState(false) |
| 132 | const [applyBriefSuggestion, setApplyBriefSuggestion] = useState(false) |
| 133 | const documentInputRef = useRef<HTMLInputElement | null>(null) |
| 134 | const pendingImageReference = attachedReferenceFile?.type === 'image' |
| 135 | |
| 136 | const validateForm = (modelConfigId = selectedModelConfigId): string => { |
| 137 | const topicText = topic.trim() |
| 138 | if (!topicText) return t('home.validationTopic') |
| 139 | |
| 140 | if (!styleOptions.length) return t('home.validationStylesLoading') |
| 141 | if (!selectedStyleId) return t('home.validationStyle') |
| 142 | const selectedStyle = styleOptions.find((option) => option.id === selectedStyleId) |
| 143 | if (!selectedStyle) return t('home.validationStyleMissing') |
| 144 | |
| 145 | const pageCountText = pageCount.trim() |
| 146 | if (!pageCountText) |
| 147 | return t('home.validationPageCount', { min: MIN_PAGE_COUNT, max: MAX_PAGE_COUNT }) |
| 148 | if (!/^\d+$/.test(pageCountText)) return t('home.validationPageCountNumber') |
| 149 | const rawPageCount = Number.parseInt(pageCountText, 10) |
| 150 | if (rawPageCount < MIN_PAGE_COUNT || rawPageCount > MAX_PAGE_COUNT) { |
| 151 | return t('home.validationPageCountRange', { min: MIN_PAGE_COUNT, max: MAX_PAGE_COUNT }) |
| 152 | } |
| 153 | |
| 154 | const briefText = brief.trim() |
| 155 | if (!briefText) return t('home.validationBrief') |
| 156 | |
| 157 | const selectedModelConfig = modelConfigs.find((config) => config.id === modelConfigId) |
| 158 | const resolvedApiKey = (selectedModelConfig?.apiKey || '').trim() |
| 159 | const resolvedModel = (selectedModelConfig?.model || '').trim() |
| 160 | const resolvedStoragePath = (settings?.storagePath || '').trim() |
| 161 | if (!resolvedApiKey || !resolvedModel || !resolvedStoragePath) return t('home.settingsRequired') |
| 162 | |
| 163 | return '' |
| 164 | } |
| 165 | |
| 166 | const requiredReady = (() => { |
| 167 | const topicText = topic.trim() |
| 168 | const pageCountText = pageCount.trim() |
| 169 | const briefText = brief.trim() |
| 170 | if (!topicText || !selectedStyleId || !selectedModelConfigId || !briefText) return false |
| 171 | if (!/^\d+$/.test(pageCountText)) return false |
| 172 | const n = Number.parseInt(pageCountText, 10) |
| 173 | return n >= MIN_PAGE_COUNT && n <= MAX_PAGE_COUNT |
| 174 | })() |
| 175 | |
| 176 | const loadStyleOptions = useCallback( |
| 177 | async (preferredStyleId?: string): Promise<void> => { |
| 178 | try { |
| 179 | const { items } = await ipc.listStyles() |
| 180 | const sorted = [...items].sort( |
| 181 | (a, b) => |
| 182 | (b.favoriteAt || 0) - (a.favoriteAt || 0) || |
| 183 | (b.updatedAt || 0) - (a.updatedAt || 0) || |
| 184 | (b.createdAt || 0) - (a.createdAt || 0) || |
| 185 | a.id.localeCompare(b.id) |
| 186 | ) |
| 187 | const options = sorted.map((item) => ({ |
| 188 | id: item.id, |
| 189 | label: item.label, |
| 190 | description: item.description, |
| 191 | styleCase: item.styleCase, |
| 192 | thumbnailPath: item.thumbnailPath, |
| 193 | previewPath: item.previewPath, |
| 194 | favoriteAt: item.favoriteAt |
| 195 | })) |
| 196 | setStyleOptions(options) |
| 197 | setSelectedStyleId((current) => { |
| 198 | if (preferredStyleId && options.some((option) => option.id === preferredStyleId)) { |
| 199 | return preferredStyleId |
| 200 | } |
| 201 | if (current && options.some((option) => option.id === current)) return current |
| 202 | return options.length > 0 ? options[0].id : '' |
| 203 | }) |
| 204 | } catch (err) { |
| 205 | error(t('home.styleLoadFailed'), { |
| 206 | description: err instanceof Error ? err.message : t('common.retryLater') |
| 207 | }) |
| 208 | } |
| 209 | }, |
| 210 | [error, t] |
| 211 | ) |
| 212 | |
| 213 | const loadFontOptions = useCallback(async (): Promise<void> => { |
| 214 | try { |
| 215 | const { googleFonts, userFonts } = await ipc.listFonts() |
| 216 | const options = [...userFonts, ...googleFonts] |
| 217 | setFontOptions(options) |
| 218 | const ids = new Set(options.map((font) => `${font.source}:${font.id}`)) |
| 219 | setSelectedTitleFontId((current) => |
| 220 | current === 'auto' || ids.has(current) ? current : 'auto' |
| 221 | ) |
| 222 | setSelectedBodyFontId((current) => |
| 223 | current === 'auto' || ids.has(current) ? current : 'auto' |
| 224 | ) |
| 225 | } catch { |
| 226 | setFontOptions([]) |
| 227 | setSelectedTitleFontId('auto') |
| 228 | setSelectedBodyFontId('auto') |
| 229 | } |
| 230 | }, []) |
| 231 | |
| 232 | useEffect(() => { |
| 233 | void loadStyleOptions() |
| 234 | }, [loadStyleOptions]) |
| 235 | |
| 236 | useEffect(() => { |
| 237 | void loadFontOptions() |
| 238 | }, [loadFontOptions]) |
| 239 | |
| 240 | const handleSubmit = async (modelConfigId: string): Promise<void> => { |
| 241 | if (parsingDocument) { |
| 242 | warning(t('home.referenceProcessingWait')) |
| 243 | return |
| 244 | } |
| 245 | if (pendingImageReference) { |
| 246 | warning(t('home.completeInfoTitle'), { description: t('home.imageReferenceNeedsParse') }) |
| 247 | return |
| 248 | } |
| 249 | const validationError = validateForm(modelConfigId) |
| 250 | if (validationError) { |
| 251 | if (validationError === t('home.settingsRequired')) { |
| 252 | warning(t('home.settingsRequiredTitle'), { |
| 253 | description: t('home.settingsRequired'), |
| 254 | action: { |
| 255 | label: t('home.goToSettings'), |
| 256 | onClick: () => navigate('/settings') |
| 257 | } |
| 258 | }) |
| 259 | return |
| 260 | } |
| 261 | warning(t('home.completeInfoTitle'), { description: validationError }) |
| 262 | return |
| 263 | } |
| 264 | const selectedStyle = styleOptions.find((option) => option.id === selectedStyleId)! |
| 265 | const findFontBySelectId = (id: string): FontListItem | undefined => |
| 266 | fontOptions.find((font) => `${font.source}:${font.id}` === id) |
| 267 | const selectedTitleFont = findFontBySelectId(selectedTitleFontId) |
| 268 | const selectedBodyFont = findFontBySelectId(selectedBodyFontId) |
| 269 | const fontSelection: FontSelection = |
| 270 | selectedTitleFont && selectedBodyFont |
| 271 | ? { |
| 272 | mode: 'pair', |
| 273 | title: { |
| 274 | source: selectedTitleFont.source, |
| 275 | family: selectedTitleFont.family, |
| 276 | id: selectedTitleFont.id |
| 277 | }, |
| 278 | body: { |
| 279 | source: selectedBodyFont.source, |
| 280 | family: selectedBodyFont.family, |
| 281 | id: selectedBodyFont.id |
| 282 | } |
| 283 | } |
| 284 | : { mode: 'auto' } |
| 285 | const topicText = topic.trim() |
| 286 | const briefText = brief.trim() |
| 287 | const safePageCount = Number.parseInt(pageCount.trim(), 10) |
| 288 | const initialPrompt = |
| 289 | briefText || |
| 290 | buildNeutralInitialPrompt({ |
| 291 | topic: topicText || 'Untitled topic', |
| 292 | pageCount: safePageCount, |
| 293 | styleLabel: selectedStyle.label |
| 294 | }) |
| 295 | |
| 296 | setSubmitting(true) |
| 297 | try { |
| 298 | const resolvedModelConfigId = await ensureModelActive(modelConfigId) |
| 299 | if (!resolvedModelConfigId) return |
| 300 | const sessionId = await createSession({ |
| 301 | topic: topicText, |
| 302 | styleId: selectedStyleId, |
| 303 | modelConfigId: resolvedModelConfigId, |
| 304 | pageCount: safePageCount, |
| 305 | slideSizeId, |
| 306 | referenceDocumentPath: referenceDocumentPath || undefined, |
| 307 | sourcePlan: acceptedSourcePlan, |
| 308 | fontSelection |
| 309 | }) |
| 310 | success(t('home.sessionCreated'), { |
| 311 | description: t('home.generationStarted'), |
| 312 | duration: 1000 |
| 313 | }) |
| 314 | setPageCount(String(safePageCount)) |
| 315 | await delay(500) |
| 316 | navigate(`/sessions/${sessionId}/generating`, { |
| 317 | state: { |
| 318 | initialPrompt, |
| 319 | modelConfigId: resolvedModelConfigId, |
| 320 | animationPreferences: normalizeAnimationPreferences(selectedAnimationPreferenceIds) |
| 321 | } |
| 322 | }) |
| 323 | } catch (err) { |
| 324 | error(t('home.sessionCreateFailed'), { |
| 325 | description: err instanceof Error ? err.message : t('common.retryLater') |
| 326 | }) |
| 327 | } finally { |
| 328 | setSubmitting(false) |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | const handleChooseReferenceClick = async (): Promise<void> => { |
| 333 | if (parsingDocument) return |
| 334 | documentInputRef.current?.click() |
| 335 | } |
| 336 | |
| 337 | const handleDocumentFilesSelected = async (files: FileList | null): Promise<void> => { |
| 338 | const selectedFiles = Array.from(files || []) |
| 339 | if (documentInputRef.current) { |
| 340 | documentInputRef.current.value = '' |
| 341 | } |
| 342 | if (selectedFiles.length === 0) return |
| 343 | if (selectedFiles.length > 1) { |
| 344 | const message = t('home.documentSingleOnly') |
| 345 | setDocumentParseError(message) |
| 346 | error(t('home.documentCountExceeded'), { |
| 347 | description: message |
| 348 | }) |
| 349 | return |
| 350 | } |
| 351 | const selectedFile = selectedFiles[0] |
| 352 | const isImage = isSupportedImageFile(selectedFile) |
| 353 | const maxSizeMb = isImage ? MAX_IMAGE_SIZE_MB : MAX_DOCUMENT_SIZE_MB |
| 354 | const maxSizeBytes = isImage ? MAX_IMAGE_SIZE_BYTES : MAX_DOCUMENT_SIZE_BYTES |
| 355 | if (selectedFile.size > maxSizeBytes) { |
| 356 | const message = isImage |
| 357 | ? t('home.imageTooLarge', { maxSize: maxSizeMb }) |
| 358 | : t('home.documentTooLarge', { maxSize: maxSizeMb }) |
| 359 | setDocumentParseError(message) |
| 360 | error(t('home.documentTooLargeTitle'), { |
| 361 | description: message |
| 362 | }) |
| 363 | return |
| 364 | } |
| 365 | |
| 366 | const payloadFiles = selectedFiles |
| 367 | .map((file) => ({ |
| 368 | path: window.electron?.getPathForFile?.(file) || '', |
| 369 | name: file.name |
| 370 | })) |
| 371 | .filter((file) => file.path) |
| 372 | |
| 373 | if (payloadFiles.length === 0) { |
| 374 | setDocumentParseError(t('home.documentPathFailed')) |
| 375 | error(t('home.documentPathFailedTitle')) |
| 376 | return |
| 377 | } |
| 378 | |
| 379 | setParsingDocument(true) |
| 380 | setDocumentParseError(null) |
| 381 | try { |
| 382 | const result = await ipc.prepareReferenceDocument({ files: payloadFiles }) |
| 383 | const referenceFile = result.files[0] |
| 384 | setAttachedReferenceFile(referenceFile || null) |
| 385 | setReferenceDocumentPath( |
| 386 | referenceFile && referenceFile.type !== 'image' ? referenceFile.path : null |
| 387 | ) |
| 388 | setSuggestionDraft(null) |
| 389 | setAcceptedSourcePlan(undefined) |
| 390 | success(isImage ? t('home.imageReferenceAttachedNeedsParse') : t('home.referenceAttached'), { |
| 391 | description: referenceFile?.name || selectedFile.name |
| 392 | }) |
| 393 | } catch (err) { |
| 394 | const message = err instanceof Error ? err.message : t('common.retryLater') |
| 395 | setDocumentParseError(message) |
| 396 | error(t('home.referenceAttachFailed'), { |
| 397 | description: message |
| 398 | }) |
| 399 | } finally { |
| 400 | setParsingDocument(false) |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | const handleRemoveReferenceFile = (): void => { |
| 405 | setAttachedReferenceFile(null) |
| 406 | setReferenceDocumentPath(null) |
| 407 | setSuggestionDraft(null) |
| 408 | setAcceptedSourcePlan(undefined) |
| 409 | setDocumentParseError(null) |
| 410 | } |
| 411 | |
| 412 | const handleRevealReferenceFile = async (): Promise<void> => { |
| 413 | if (!attachedReferenceFile) return |
| 414 | try { |
| 415 | await ipc.revealFile(attachedReferenceFile.path) |
| 416 | } catch (err) { |
| 417 | error(t('home.revealReferenceFileFailed'), { |
| 418 | description: err instanceof Error ? err.message : t('common.retryLater') |
| 419 | }) |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | const handleParseImageReference = async ( |
| 424 | modelConfigId = selectedModelConfigId |
| 425 | ): Promise<void> => { |
| 426 | if (!attachedReferenceFile || attachedReferenceFile.type !== 'image' || parsingDocument) return |
| 427 | const resolvedModelConfigId = await ensureModelActive(modelConfigId) |
| 428 | if (!resolvedModelConfigId) return |
| 429 | |
| 430 | setParsingDocument(true) |
| 431 | setDocumentParseError(null) |
| 432 | try { |
| 433 | const result = await ipc.parseImageReferenceDocument({ |
| 434 | file: { path: attachedReferenceFile.path, name: attachedReferenceFile.name }, |
| 435 | modelConfigId: resolvedModelConfigId |
| 436 | }) |
| 437 | const referenceFile = result.files[0] |
| 438 | if (!referenceFile) throw new Error(t('common.retryLater')) |
| 439 | setAttachedReferenceFile(referenceFile) |
| 440 | setReferenceDocumentPath(referenceFile.path) |
| 441 | setSuggestionDraft(null) |
| 442 | setAcceptedSourcePlan(undefined) |
| 443 | setSuggestionDialogOpen(false) |
| 444 | success(t('home.imageReferenceParsed'), { description: referenceFile.name }) |
| 445 | } catch (err) { |
| 446 | const message = err instanceof Error ? err.message : t('common.retryLater') |
| 447 | setDocumentParseError(message) |
| 448 | error(t('home.documentParseFailed'), { description: message }) |
| 449 | } finally { |
| 450 | setParsingDocument(false) |
| 451 | } |
| 452 | } |
| 453 | |
| 454 | const handleAnalyzeReference = async (modelConfigId: string): Promise<void> => { |
| 455 | if (!attachedReferenceFile || parsingDocument) return |
| 456 | const resolvedModelConfigId = await ensureModelActive(modelConfigId) |
| 457 | if (!resolvedModelConfigId) return |
| 458 | |
| 459 | setParsingDocument(true) |
| 460 | setDocumentParseError(null) |
| 461 | try { |
| 462 | const result = await ipc.parseDocumentPlan({ |
| 463 | files: [{ path: attachedReferenceFile.path, name: attachedReferenceFile.name }], |
| 464 | topic: topic.trim(), |
| 465 | existingBrief: brief.trim(), |
| 466 | modelConfigId: resolvedModelConfigId |
| 467 | }) |
| 468 | const nextSuggestion = { |
| 469 | topic: result.topic, |
| 470 | pageCount: result.pageCount, |
| 471 | briefText: result.briefText, |
| 472 | sourcePlan: result.sourcePlan |
| 473 | } |
| 474 | const referenceFile = result.files[0] || attachedReferenceFile |
| 475 | setAttachedReferenceFile(referenceFile) |
| 476 | setReferenceDocumentPath(referenceFile.type !== 'image' ? referenceFile.path : null) |
| 477 | setSuggestionDraft(buildSuggestionDraft(nextSuggestion)) |
| 478 | setAcceptedSourcePlan(undefined) |
| 479 | setApplyTopicSuggestion(!topic.trim()) |
| 480 | setApplyPageCountSuggestion(!result.sourcePlan?.pageSkeleton.length && !pageCount.trim()) |
| 481 | setApplyBriefSuggestion(Boolean(result.sourcePlan?.pageSkeleton.length) || !brief.trim()) |
| 482 | setSuggestionDialogOpen(true) |
| 483 | success(t('home.documentParsed')) |
| 484 | } catch (err) { |
| 485 | const message = err instanceof Error ? err.message : t('common.retryLater') |
| 486 | setDocumentParseError(message) |
| 487 | error(t('home.documentParseFailed'), { |
| 488 | description: message |
| 489 | }) |
| 490 | } finally { |
| 491 | setParsingDocument(false) |
| 492 | } |
| 493 | } |
| 494 | |
| 495 | const applyDocumentSuggestion = (): void => { |
| 496 | const draft = suggestionDraft |
| 497 | if (!draft) return |
| 498 | const sourceOutlinePageCount = draft.sourcePlan?.pageSkeleton.length || 0 |
| 499 | const hasSourceOutline = sourceOutlinePageCount > 0 |
| 500 | const shouldApplySourceOutline = hasSourceOutline && applyBriefSuggestion |
| 501 | |
| 502 | if (applyTopicSuggestion) setTopic(draft.topic) |
| 503 | if (shouldApplySourceOutline) { |
| 504 | setPageCount(String(resolvePageCount(String(sourceOutlinePageCount)))) |
| 505 | } else if (applyPageCountSuggestion) { |
| 506 | setPageCount(String(resolvePageCount(draft.pageCount))) |
| 507 | } |
| 508 | if (applyBriefSuggestion) { |
| 509 | setBrief( |
| 510 | draft.sourcePlan?.pageSkeleton.length |
| 511 | ? formatSourceOutlineBriefText(draft.sourcePlan.pageSkeleton) |
| 512 | : draft.briefText |
| 513 | ) |
| 514 | } |
| 515 | setAcceptedSourcePlan(shouldApplySourceOutline ? draft.sourcePlan : undefined) |
| 516 | setSuggestionDialogOpen(false) |
| 517 | } |
| 518 | |
| 519 | const titleFontOptions = fontOptions.filter((font) => font.role.includes('title')) |
| 520 | const bodyFontOptions = fontOptions.filter((font) => font.role.includes('body')) |
| 521 | const availableTitleFonts = titleFontOptions.length > 0 ? titleFontOptions : fontOptions |
| 522 | const availableBodyFonts = bodyFontOptions.length > 0 ? bodyFontOptions : fontOptions |
| 523 | const getSelectedFontLabel = (id: string): string => { |
| 524 | if (id === 'auto') return t('home.fontSchemeAuto') |
| 525 | const selectedFont = fontOptions.find((font) => `${font.source}:${font.id}` === id) |
| 526 | return selectedFont?.family || t('home.fontSchemeAuto') |
| 527 | } |
| 528 | const renderFontSelectItem = (font: FontListItem, roleLabel: string): ReactElement => { |
| 529 | const isUploaded = font.source === 'uploaded' |
| 530 | const sourceLabel = isUploaded ? t('home.fontSourceUploaded') : t('home.fontSourceBuiltIn') |
| 531 | return ( |
| 532 | <SelectItem |
| 533 | key={`${font.source}:${font.id}`} |
| 534 | value={`${font.source}:${font.id}`} |
| 535 | textValue={font.family} |
| 536 | className={compactSelectItemClass} |
| 537 | > |
| 538 | <span className="flex min-w-0 items-center gap-2"> |
| 539 | <span |
| 540 | className={`shrink-0 rounded px-1 py-0.5 text-[10px] font-medium ${ |
| 541 | isUploaded ? 'bg-[#eef9ec] text-[#4a7a46]' : 'bg-[#eef6ff] text-[#3e6685]' |
| 542 | }`} |
| 543 | > |
| 544 | {sourceLabel} |
| 545 | </span> |
| 546 | <span className="min-w-0 truncate">{font.family}</span> |
| 547 | <span className="ml-auto shrink-0 text-[10px] text-[#8b927f]">{roleLabel}</span> |
| 548 | </span> |
| 549 | </SelectItem> |
| 550 | ) |
| 551 | } |
| 552 | const fontSelectHint = |
| 553 | selectedTitleFontId === 'auto' && selectedBodyFontId === 'auto' |
| 554 | ? t('home.fontSchemeAutoHint') |
| 555 | : selectedTitleFontId !== 'auto' && selectedBodyFontId !== 'auto' |
| 556 | ? t('home.fontSchemeManualHint') |
| 557 | : t('home.fontSchemePartialHint') |
| 558 | |
| 559 | return ( |
| 560 | <div className="session-create-page mx-auto flex min-h-full w-full max-w-7xl flex-col gap-4 px-5 py-4 sm:px-6"> |
| 561 | <div className="flex max-w-4xl flex-col items-start gap-1.5 border-b border-[#e0d8c8] px-1 pb-4"> |
| 562 | <p className="rounded bg-[#d4e4c1]/78 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-[#3e4a32]"> |
| 563 | {t('home.eyebrow')} |
| 564 | </p> |
| 565 | <h1 className="organic-serif text-[32px] font-semibold leading-tight text-[#3e4a32]"> |
| 566 | {t('home.title')} |
| 567 | </h1> |
| 568 | <p className="text-sm leading-6 text-[#5d6b4d]">{t('home.description')}</p> |
| 569 | </div> |
| 570 | |
| 571 | <div> |
| 572 | <input |
| 573 | ref={documentInputRef} |
| 574 | type="file" |
| 575 | accept=".md,.txt,.text,.csv,.docx,image/png,image/jpeg,image/webp" |
| 576 | multiple={false} |
| 577 | className="hidden" |
| 578 | onChange={(event) => void handleDocumentFilesSelected(event.target.files)} |
| 579 | /> |
| 580 | {documentParseError && ( |
| 581 | <div className="mb-4 flex items-start gap-2 rounded-xl bg-[#fff2ef] px-4 py-3 text-xs text-[#8a3d33]"> |
| 582 | <CircleAlert className="mt-0.5 h-4 w-4 shrink-0" /> |
| 583 | <span>{documentParseError}</span> |
| 584 | </div> |
| 585 | )} |
| 586 | |
| 587 | <Card |
| 588 | data-session-create-workspace |
| 589 | className="session-create-workspace overflow-hidden rounded-2xl border border-[#ded8cb] shadow-[0_12px_28px_rgba(86,73,54,0.06)]" |
| 590 | > |
| 591 | <CardContent className="grid p-0 lg:grid-cols-[minmax(0,1.55fr)_minmax(320px,0.85fr)] [&_label]:text-[13px] [&_label]:font-semibold [&_label]:text-[#3e4a32]"> |
| 592 | <main |
| 593 | data-session-create-main |
| 594 | className="flex min-w-0 flex-col gap-5 bg-transparent p-5 lg:p-6" |
| 595 | > |
| 596 | <div> |
| 597 | <label className="mb-2 block">{t('home.topic')}</label> |
| 598 | <Input |
| 599 | placeholder={t('home.topicPlaceholder')} |
| 600 | value={topic} |
| 601 | onChange={(e) => setTopic(e.target.value)} |
| 602 | required |
| 603 | className={compactInputClass} |
| 604 | /> |
| 605 | </div> |
| 606 | |
| 607 | <div> |
| 608 | <div className="mb-2 flex items-center justify-between"> |
| 609 | <label className="block font-medium">{t('home.brief')}</label> |
| 610 | <div className="flex items-center gap-1 rounded-lg bg-[#fffdf8]/84 p-0.5"> |
| 611 | <button |
| 612 | type="button" |
| 613 | onClick={() => setBriefMode('edit')} |
| 614 | className={`flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors ${ |
| 615 | briefMode === 'edit' |
| 616 | ? 'bg-[#8fbc8f] text-[#3e4a32]' |
| 617 | : 'text-[#5d6b4d] hover:bg-[#d4e4c1]/70 hover:text-[#3e4a32]' |
| 618 | }`} |
| 619 | > |
| 620 | <Pencil className="h-3.5 w-3.5" /> |
| 621 | {t('common.edit')} |
| 622 | </button> |
| 623 | <button |
| 624 | type="button" |
| 625 | onClick={() => setBriefMode('preview')} |
| 626 | className={`flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors ${ |
| 627 | briefMode === 'preview' |
| 628 | ? 'bg-[#8fbc8f] text-[#3e4a32]' |
| 629 | : 'text-[#5d6b4d] hover:bg-[#d4e4c1]/70 hover:text-[#3e4a32]' |
| 630 | }`} |
| 631 | > |
| 632 | <Eye className="h-3.5 w-3.5" /> |
| 633 | {t('common.preview')} |
| 634 | </button> |
| 635 | </div> |
| 636 | </div> |
| 637 | <div className="overflow-hidden rounded-xl border border-[#e0d8c8] bg-[#fffdf8]/90"> |
| 638 | {briefMode === 'edit' ? ( |
| 639 | <Textarea |
| 640 | placeholder={t('home.briefPlaceholder')} |
| 641 | rows={8} |
| 642 | value={brief} |
| 643 | required |
| 644 | onChange={(e) => { |
| 645 | setAcceptedSourcePlan(undefined) |
| 646 | setBrief(e.target.value) |
| 647 | }} |
| 648 | className="min-h-[300px] resize-y border-0 bg-transparent px-4 py-3 text-xs leading-5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" |
| 649 | /> |
| 650 | ) : ( |
| 651 | <ScrollArea className="h-[300px] bg-transparent" viewportClassName="p-4"> |
| 652 | <ReactMarkdown |
| 653 | components={{ |
| 654 | h1: ({ children }) => ( |
| 655 | <h1 className="mb-2 text-lg font-semibold text-foreground"> |
| 656 | {children} |
| 657 | </h1> |
| 658 | ), |
| 659 | h2: ({ children }) => ( |
| 660 | <h2 className="mb-2 mt-3 text-base font-semibold text-foreground"> |
| 661 | {children} |
| 662 | </h2> |
| 663 | ), |
| 664 | h3: ({ children }) => ( |
| 665 | <h3 className="mb-1.5 mt-2.5 text-sm font-semibold text-foreground"> |
| 666 | {children} |
| 667 | </h3> |
| 668 | ), |
| 669 | p: ({ children }) => ( |
| 670 | <p className="mb-2 text-xs leading-5 text-muted-foreground"> |
| 671 | {children} |
| 672 | </p> |
| 673 | ), |
| 674 | ul: ({ children }) => ( |
| 675 | <ul className="mb-2 list-disc space-y-0.5 pl-5 text-xs text-muted-foreground"> |
| 676 | {children} |
| 677 | </ul> |
| 678 | ), |
| 679 | ol: ({ children }) => ( |
| 680 | <ol className="mb-2 list-decimal space-y-0.5 pl-5 text-xs text-muted-foreground"> |
| 681 | {children} |
| 682 | </ol> |
| 683 | ), |
| 684 | li: ({ children }) => <li>{children}</li>, |
| 685 | code: ({ children }) => ( |
| 686 | <code className="rounded bg-muted px-1.5 py-0.5 text-xs text-foreground"> |
| 687 | {children} |
| 688 | </code> |
| 689 | ), |
| 690 | blockquote: ({ children }) => ( |
| 691 | <blockquote className="mb-2 border-l-2 border-border pl-3 text-xs text-muted-foreground"> |
| 692 | {children} |
| 693 | </blockquote> |
| 694 | ) |
| 695 | }} |
| 696 | > |
| 697 | {brief || t('home.briefPlaceholder')} |
| 698 | </ReactMarkdown> |
| 699 | </ScrollArea> |
| 700 | )} |
| 701 | </div> |
| 702 | <div |
| 703 | data-session-create-reference-actions |
| 704 | className="mt-2 flex flex-wrap items-center justify-end gap-2" |
| 705 | > |
| 706 | {attachedReferenceFile && ( |
| 707 | <div className="flex min-w-0 max-w-full"> |
| 708 | <span |
| 709 | className={`inline-flex h-8 max-w-full items-center gap-1.5 rounded-lg border px-2.5 text-[11px] ${ |
| 710 | pendingImageReference |
| 711 | ? 'border-[#e7a19a]/80 bg-[#fff1ef] text-[#9a3f35]' |
| 712 | : 'border-[#c8d6ba] bg-[#fffdf8]/84 text-[#5d6b4d]' |
| 713 | }`} |
| 714 | title={ |
| 715 | pendingImageReference |
| 716 | ? t('home.imageReferenceTagTooltip') |
| 717 | : attachedReferenceFile.path |
| 718 | } |
| 719 | > |
| 720 | <FileText className="h-3 w-3 shrink-0" /> |
| 721 | <button |
| 722 | type="button" |
| 723 | onClick={() => void handleRevealReferenceFile()} |
| 724 | className="w-[150px] min-w-0 max-w-[150px] truncate text-left hover:underline" |
| 725 | title={t('home.revealReferenceFileTooltip')} |
| 726 | aria-label={t('home.revealReferenceFile')} |
| 727 | > |
| 728 | {attachedReferenceFile.name} |
| 729 | </button> |
| 730 | {pendingImageReference ? ( |
| 731 | <> |
| 732 | <span className="shrink-0 text-[#b24d43]"> |
| 733 | {t('home.imageReferenceNeedsParseShort')} |
| 734 | </span> |
| 735 | <button |
| 736 | type="button" |
| 737 | onClick={() => void handleParseImageReference(selectedModelConfigId)} |
| 738 | disabled={parsingDocument || submitting} |
| 739 | className="ml-1 inline-flex h-4 shrink-0 items-center rounded-full bg-[#c84f45] px-1.5 text-[10px] font-medium text-white hover:bg-[#ad4239] disabled:cursor-not-allowed disabled:opacity-60" |
| 740 | aria-label={t('home.parseImageReference')} |
| 741 | > |
| 742 | {parsingDocument |
| 743 | ? t('home.parsingImageReference') |
| 744 | : t('home.parseImageReference')} |
| 745 | </button> |
| 746 | </> |
| 747 | ) : null} |
| 748 | <button |
| 749 | type="button" |
| 750 | onClick={handleRemoveReferenceFile} |
| 751 | className={`inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full ${ |
| 752 | pendingImageReference |
| 753 | ? 'text-[#a04940] hover:bg-[#f2c2bd]' |
| 754 | : 'text-[#657552] hover:bg-[#c8ddb2]' |
| 755 | }`} |
| 756 | aria-label={t('home.removeReference')} |
| 757 | > |
| 758 | <X className="h-2.5 w-2.5" /> |
| 759 | </button> |
| 760 | </span> |
| 761 | </div> |
| 762 | )} |
| 763 | <TooltipProvider delayDuration={180}> |
| 764 | <div className="flex flex-wrap items-center justify-end gap-2"> |
| 765 | {!attachedReferenceFile && ( |
| 766 | <Tooltip> |
| 767 | <TooltipTrigger asChild> |
| 768 | <span className="inline-flex"> |
| 769 | <Button |
| 770 | type="button" |
| 771 | variant="ghost" |
| 772 | size="sm" |
| 773 | onClick={() => { |
| 774 | void handleChooseReferenceClick() |
| 775 | }} |
| 776 | disabled={parsingDocument} |
| 777 | className="h-8 shrink-0 rounded-lg border border-[#e0d8c8] bg-[#fffdf8]/84 px-3 text-xs font-medium text-[#5d6b4d] shadow-none hover:bg-[#d4e4c1]/65 hover:text-[#3e4a32]" |
| 778 | > |
| 779 | {parsingDocument ? ( |
| 780 | <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> |
| 781 | ) : ( |
| 782 | <FileText className="mr-1.5 h-3.5 w-3.5" /> |
| 783 | )} |
| 784 | {parsingDocument |
| 785 | ? t('home.processingReference') |
| 786 | : t('home.uploadReference')} |
| 787 | </Button> |
| 788 | </span> |
| 789 | </TooltipTrigger> |
| 790 | <TooltipContent side="bottom" align="start"> |
| 791 | {t('home.uploadReferenceTooltip', { |
| 792 | maxSize: MAX_DOCUMENT_SIZE_MB, |
| 793 | imageMaxSize: MAX_IMAGE_SIZE_MB |
| 794 | })} |
| 795 | </TooltipContent> |
| 796 | </Tooltip> |
| 797 | )} |
| 798 | {attachedReferenceFile && !pendingImageReference && ( |
| 799 | <Tooltip> |
| 800 | <TooltipTrigger asChild> |
| 801 | <span> |
| 802 | <ModelSplitButton |
| 803 | modelAction={modelAction} |
| 804 | ariaLabel={t('home.analyzeReference')} |
| 805 | label={t('home.analyzeReference')} |
| 806 | loadingLabel={t('home.analyzingReference')} |
| 807 | loading={parsingDocument} |
| 808 | disabled={!attachedReferenceFile} |
| 809 | icon={Sparkles} |
| 810 | tone="primary" |
| 811 | dropdownAlign="end" |
| 812 | className="box-border h-8 rounded-lg border-0 bg-[#8fbc8f] shadow-[0_6px_14px_rgba(113,134,95,0.15)]" |
| 813 | mainClassName="h-full bg-transparent px-2.5 text-xs text-[#3e4a32] shadow-none hover:bg-white/10 hover:text-[#3e4a32] hover:shadow-none" |
| 814 | triggerClassName="h-full w-8 px-0 text-[#3e4a32] hover:text-[#3e4a32]" |
| 815 | onRun={handleAnalyzeReference} |
| 816 | /> |
| 817 | </span> |
| 818 | </TooltipTrigger> |
| 819 | <TooltipContent side="top" align="start" className="max-w-xs"> |
| 820 | {t('home.analyzeReferenceTooltip')} |
| 821 | </TooltipContent> |
| 822 | </Tooltip> |
| 823 | )} |
| 824 | </div> |
| 825 | </TooltipProvider> |
| 826 | </div> |
| 827 | </div> |
| 828 | |
| 829 | <div className="mt-auto flex pt-1"> |
| 830 | <ModelSplitButton |
| 831 | modelAction={modelAction} |
| 832 | ariaLabel={t('home.createAndStart')} |
| 833 | label={t('home.createAndStart')} |
| 834 | loadingLabel={t('home.creating')} |
| 835 | loading={submitting || loading} |
| 836 | disabled={!requiredReady || parsingDocument} |
| 837 | icon={Sparkles} |
| 838 | tone="primary" |
| 839 | className="w-full sm:w-auto" |
| 840 | mainClassName="min-w-0 flex-1 h-10 px-4 sm:flex-none sm:min-w-[176px]" |
| 841 | onRun={handleSubmit} |
| 842 | /> |
| 843 | </div> |
| 844 | </main> |
| 845 | |
| 846 | <aside |
| 847 | data-session-create-settings |
| 848 | className="min-w-0 bg-transparent p-5 lg:border-l lg:border-[#ded8cb] lg:p-6" |
| 849 | > |
| 850 | <div className="space-y-6"> |
| 851 | <section> |
| 852 | <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_88px] lg:grid-cols-[minmax(0,1fr)_82px]"> |
| 853 | <div> |
| 854 | <label className="mb-2 block">{t('home.style')}</label> |
| 855 | <StyleSelect |
| 856 | value={selectedStyleId} |
| 857 | onChange={setSelectedStyleId} |
| 858 | options={styleOptions} |
| 859 | placeholder={t('home.stylePlaceholder')} |
| 860 | compact |
| 861 | className="h-8 border-[#c8d6ba] bg-[#fffdf8]/90 px-2.5 py-1.5 text-xs shadow-none" |
| 862 | dropdownAlign="end" |
| 863 | dropdownClassName="w-[min(700px,calc(100vw-3rem))]" |
| 864 | /> |
| 865 | </div> |
| 866 | |
| 867 | <div> |
| 868 | <label className="mb-2 block">{t('home.pageCount')}</label> |
| 869 | <Input |
| 870 | type="text" |
| 871 | inputMode="numeric" |
| 872 | pattern="[0-9]*" |
| 873 | placeholder={`${MIN_PAGE_COUNT}-${MAX_PAGE_COUNT}`} |
| 874 | value={pageCount} |
| 875 | required |
| 876 | onChange={(e) => { |
| 877 | const next = e.target.value |
| 878 | setAcceptedSourcePlan(undefined) |
| 879 | if (next === '') { |
| 880 | setPageCount('') |
| 881 | return |
| 882 | } |
| 883 | if (!/^\d+$/.test(next)) return |
| 884 | setPageCount(next) |
| 885 | }} |
| 886 | onBlur={() => { |
| 887 | setPageCount(String(resolvePageCount(pageCount))) |
| 888 | }} |
| 889 | className={settingsInputClass} |
| 890 | /> |
| 891 | </div> |
| 892 | </div> |
| 893 | <div className="mt-3"> |
| 894 | <label className="mb-2 block">{t('home.slideSize')}</label> |
| 895 | <Select |
| 896 | value={slideSizeId} |
| 897 | onValueChange={(value) => setSlideSizeId(value as SlideSizePresetId)} |
| 898 | > |
| 899 | <SelectTrigger className={settingsSelectTriggerClass}> |
| 900 | <SelectValue /> |
| 901 | </SelectTrigger> |
| 902 | <SelectContent className={compactSelectContentClass}> |
| 903 | {SLIDE_SIZE_PRESETS.map((preset) => ( |
| 904 | <SelectItem |
| 905 | key={preset.id} |
| 906 | value={preset.id} |
| 907 | className={compactSelectItemClass} |
| 908 | > |
| 909 | {preset.id === 'wide-16-9' |
| 910 | ? t('home.slideSizeWide') |
| 911 | : preset.id === 'vertical-9-16' |
| 912 | ? t('home.slideSizeVertical') |
| 913 | : preset.id === 'standard-4-3' |
| 914 | ? t('home.slideSizeStandard') |
| 915 | : preset.id === 'square-1-1' |
| 916 | ? t('home.slideSizeSquare') |
| 917 | : preset.id === 'vertical-3-4' |
| 918 | ? t('home.slideSizePortrait') |
| 919 | : t('home.slideSizeXiaohongshu')} |
| 920 | <span className="ml-2 text-[10px] text-[#8b927f]"> |
| 921 | {preset.width}×{preset.height} |
| 922 | </span> |
| 923 | </SelectItem> |
| 924 | ))} |
| 925 | </SelectContent> |
| 926 | </Select> |
| 927 | </div> |
| 928 | </section> |
| 929 | |
| 930 | <section> |
| 931 | <label className="mb-2 block">{t('home.fontScheme')}</label> |
| 932 | <div className="grid min-w-0 grid-cols-2 overflow-hidden rounded-lg border border-[#d8ccb5]/70 bg-white/75 shadow-[inset_0_1px_2px_rgba(73,61,44,0.04)]"> |
| 933 | <Select value={selectedTitleFontId} onValueChange={setSelectedTitleFontId}> |
| 934 | <SelectTrigger className="h-8 min-w-0 rounded-none border-0 border-r border-[#d8ccb5]/70 bg-transparent px-2.5 py-1.5 text-xs shadow-none focus:ring-1"> |
| 935 | <span className="min-w-0 flex-1 truncate text-left"> |
| 936 | <span className="mr-1.5 text-[10px] font-medium text-[#8b927f]"> |
| 937 | {t('home.fontPairTitle')} |
| 938 | </span> |
| 939 | <SelectValue placeholder={t('home.fontSchemeAuto')}> |
| 940 | {getSelectedFontLabel(selectedTitleFontId)} |
| 941 | </SelectValue> |
| 942 | </span> |
| 943 | </SelectTrigger> |
| 944 | <SelectContent className={compactSelectContentClass}> |
| 945 | <SelectItem value="auto" className={compactSelectItemClass}> |
| 946 | <span className="flex min-w-0 items-center gap-2"> |
| 947 | <span className="min-w-0 truncate">{t('home.fontSchemeAuto')}</span> |
| 948 | <span className="ml-auto shrink-0 text-[10px] text-[#8b927f]"> |
| 949 | {t('home.fontPairTitle')} |
| 950 | </span> |
| 951 | </span> |
| 952 | </SelectItem> |
| 953 | {availableTitleFonts.map((font) => |
| 954 | renderFontSelectItem(font, t('home.fontPairTitle')) |
| 955 | )} |
| 956 | </SelectContent> |
| 957 | </Select> |
| 958 | <Select value={selectedBodyFontId} onValueChange={setSelectedBodyFontId}> |
| 959 | <SelectTrigger className="h-8 min-w-0 rounded-none border-0 bg-transparent px-2.5 py-1.5 text-xs shadow-none focus:ring-1"> |
| 960 | <span className="min-w-0 flex-1 truncate text-left"> |
| 961 | <span className="mr-1.5 text-[10px] font-medium text-[#8b927f]"> |
| 962 | {t('home.fontPairBody')} |
| 963 | </span> |
| 964 | <SelectValue placeholder={t('home.fontSchemeAuto')}> |
| 965 | {getSelectedFontLabel(selectedBodyFontId)} |
| 966 | </SelectValue> |
| 967 | </span> |
| 968 | </SelectTrigger> |
| 969 | <SelectContent className={compactSelectContentClass}> |
| 970 | <SelectItem value="auto" className={compactSelectItemClass}> |
| 971 | <span className="flex min-w-0 items-center gap-2"> |
| 972 | <span className="min-w-0 truncate">{t('home.fontSchemeAuto')}</span> |
| 973 | <span className="ml-auto shrink-0 text-[10px] text-[#8b927f]"> |
| 974 | {t('home.fontPairBody')} |
| 975 | </span> |
| 976 | </span> |
| 977 | </SelectItem> |
| 978 | {availableBodyFonts.map((font) => |
| 979 | renderFontSelectItem(font, t('home.fontPairBody')) |
| 980 | )} |
| 981 | </SelectContent> |
| 982 | </Select> |
| 983 | </div> |
| 984 | <p className="mt-2 text-xs leading-5 text-[#7f8a70]">{fontSelectHint}</p> |
| 985 | </section> |
| 986 | |
| 987 | <section> |
| 988 | <label className="mb-2 flex items-center gap-2"> |
| 989 | <span>{t('home.animationPreferences')}</span> |
| 990 | <span className="text-[10px] font-medium text-[#8b927f]"> |
| 991 | {t('common.optional')} |
| 992 | </span> |
| 993 | </label> |
| 994 | <AnimationPreferenceChips |
| 995 | selectedIds={selectedAnimationPreferenceIds} |
| 996 | onChange={setSelectedAnimationPreferenceIds} |
| 997 | compact |
| 998 | /> |
| 999 | </section> |
| 1000 | </div> |
| 1001 | </aside> |
| 1002 | </CardContent> |
| 1003 | </Card> |
| 1004 | </div> |
| 1005 | |
| 1006 | <SessionCreateSuggestionDialog |
| 1007 | open={suggestionDialogOpen} |
| 1008 | onOpenChange={setSuggestionDialogOpen} |
| 1009 | attachedReferenceFile={attachedReferenceFile} |
| 1010 | suggestionDraft={suggestionDraft} |
| 1011 | setSuggestionDraft={setSuggestionDraft} |
| 1012 | applyTopicSuggestion={applyTopicSuggestion} |
| 1013 | setApplyTopicSuggestion={setApplyTopicSuggestion} |
| 1014 | applyPageCountSuggestion={applyPageCountSuggestion} |
| 1015 | setApplyPageCountSuggestion={setApplyPageCountSuggestion} |
| 1016 | applyBriefSuggestion={applyBriefSuggestion} |
| 1017 | setApplyBriefSuggestion={setApplyBriefSuggestion} |
| 1018 | onApplySelected={applyDocumentSuggestion} |
| 1019 | /> |
| 1020 | </div> |
| 1021 | ) |
| 1022 | } |
| 1023 |