| 1 | import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react' |
| 2 | import { useNavigate } from 'react-router-dom' |
| 3 | import { useToastStore } from '../store' |
| 4 | import { useT } from '../i18n' |
| 5 | import { useModelAction } from '../hooks/useModelAction' |
| 6 | import { ipc } from '@renderer/lib/ipc' |
| 7 | import { |
| 8 | ArrowRight, |
| 9 | FileText, |
| 10 | FileUp, |
| 11 | Loader2, |
| 12 | MessageCircle, |
| 13 | Sparkles |
| 14 | } from 'lucide-react' |
| 15 | |
| 16 | const MAX_PPTX_SIZE_MB = 500 |
| 17 | const MAX_PPTX_SIZE_BYTES = MAX_PPTX_SIZE_MB * 1024 * 1024 |
| 18 | |
| 19 | export function HomePage(): ReactElement { |
| 20 | const navigate = useNavigate() |
| 21 | const { success, error, warning } = useToastStore() |
| 22 | const modelAction = useModelAction() |
| 23 | const { ensureModelActive } = modelAction |
| 24 | const t = useT() |
| 25 | const [importingPptx, setImportingPptx] = useState(false) |
| 26 | const [pptxImportProgress, setPptxImportProgress] = useState<string | null>(null) |
| 27 | const pptxInputRef = useRef<HTMLInputElement | null>(null) |
| 28 | |
| 29 | const ensureHomeModelReady = useCallback(async (): Promise<boolean> => { |
| 30 | return Boolean(await ensureModelActive()) |
| 31 | }, [ensureModelActive]) |
| 32 | |
| 33 | const handleQuickCreate = useCallback(async () => { |
| 34 | if (!(await ensureHomeModelReady())) return |
| 35 | navigate('/create/session') |
| 36 | }, [ensureHomeModelReady, navigate]) |
| 37 | |
| 38 | const handleExplore = useCallback(async () => { |
| 39 | navigate('/thinking') |
| 40 | }, [navigate]) |
| 41 | |
| 42 | const ensureUploadPrerequisites = useCallback(async (): Promise<boolean> => { |
| 43 | const validation = await ipc.validateUploadPrerequisites() |
| 44 | if (validation.ready) return true |
| 45 | warning(t('home.settingsRequiredTitle'), { |
| 46 | description: validation.message || t('home.settingsRequired'), |
| 47 | action: { |
| 48 | label: t('home.goToSettings'), |
| 49 | onClick: () => navigate('/settings') |
| 50 | } |
| 51 | }) |
| 52 | return false |
| 53 | }, [navigate, t, warning]) |
| 54 | |
| 55 | const handleImportPptxClick = useCallback(async (): Promise<void> => { |
| 56 | if (importingPptx) return |
| 57 | if (!(await ensureHomeModelReady())) return |
| 58 | if (!(await ensureUploadPrerequisites())) return |
| 59 | pptxInputRef.current?.click() |
| 60 | }, [ensureHomeModelReady, ensureUploadPrerequisites, importingPptx]) |
| 61 | |
| 62 | const handlePptxFilesSelected = useCallback( |
| 63 | async (files: FileList | null): Promise<void> => { |
| 64 | const selectedFiles = Array.from(files || []) |
| 65 | if (pptxInputRef.current) { |
| 66 | pptxInputRef.current.value = '' |
| 67 | } |
| 68 | if (selectedFiles.length === 0) return |
| 69 | if (selectedFiles.length > 1) { |
| 70 | error(t('home.pptxSingleOnlyTitle'), { |
| 71 | description: t('home.pptxSingleOnly') |
| 72 | }) |
| 73 | return |
| 74 | } |
| 75 | const selectedFile = selectedFiles[0] |
| 76 | if (!/\.pptx$/i.test(selectedFile.name)) { |
| 77 | error(t('home.unsupportedFileTitle'), { |
| 78 | description: t('home.unsupportedPptx') |
| 79 | }) |
| 80 | return |
| 81 | } |
| 82 | if (selectedFile.size > MAX_PPTX_SIZE_BYTES) { |
| 83 | error(t('home.pptxTooLargeTitle'), { |
| 84 | description: t('home.pptxTooLarge', { maxSize: MAX_PPTX_SIZE_MB }) |
| 85 | }) |
| 86 | return |
| 87 | } |
| 88 | const filePath = window.electron?.getPathForFile?.(selectedFile) || '' |
| 89 | if (!filePath) { |
| 90 | error(t('home.pptxPathFailedTitle'), { |
| 91 | description: t('home.pptxPathFailed') |
| 92 | }) |
| 93 | return |
| 94 | } |
| 95 | setImportingPptx(true) |
| 96 | setPptxImportProgress(t('home.pptxPreparing')) |
| 97 | try { |
| 98 | const modelConfigId = await ensureModelActive() |
| 99 | if (!modelConfigId) return |
| 100 | const result = await ipc.importPptx({ |
| 101 | filePath, |
| 102 | title: selectedFile.name.replace(/\.pptx$/i, ''), |
| 103 | styleId: null, |
| 104 | modelConfigId |
| 105 | }) |
| 106 | success(t('home.pptxImportDone'), { |
| 107 | description: |
| 108 | result.warnings.length > 0 |
| 109 | ? t('home.pptxImportedWithWarnings', { |
| 110 | pageCount: result.pageCount, |
| 111 | warningCount: result.warnings.length |
| 112 | }) |
| 113 | : t('home.pptxImported', { pageCount: result.pageCount }) |
| 114 | }) |
| 115 | navigate(`/sessions/${result.sessionId}`) |
| 116 | } catch (err) { |
| 117 | error(t('home.pptxImportFailed'), { |
| 118 | description: err instanceof Error ? err.message : t('common.retryLater') |
| 119 | }) |
| 120 | } finally { |
| 121 | setImportingPptx(false) |
| 122 | setPptxImportProgress(null) |
| 123 | } |
| 124 | }, |
| 125 | [ensureModelActive, error, navigate, success, t] |
| 126 | ) |
| 127 | |
| 128 | useEffect(() => { |
| 129 | return ipc.onPptxImportProgress((payload) => { |
| 130 | setPptxImportProgress(`${payload.label}${payload.progress ? ` · ${payload.progress}%` : ''}`) |
| 131 | }) |
| 132 | }, []) |
| 133 | |
| 134 | return ( |
| 135 | <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-9 text-[#3e4a32] lg:px-8"> |
| 136 | <section className="relative overflow-hidden border-b border-[#e0d8c8] pb-8"> |
| 137 | <div className="pointer-events-none absolute -right-10 -top-14 h-36 w-36 rounded-[38%_62%_44%_56%/55%_45%_55%_45%] bg-[#d4e4c1]/55" /> |
| 138 | <div className="pointer-events-none absolute bottom-3 right-28 h-16 w-28 rounded-[8%_92%_12%_88%/78%_22%_78%_22%] bg-[#c8b89e]/30" /> |
| 139 | |
| 140 | <div className="relative flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"> |
| 141 | <div className="max-w-2xl"> |
| 142 | <div className="inline-flex items-center gap-2 rounded-full bg-[#d4e4c1]/78 px-4 py-1.5 text-[11px] font-semibold text-[#3e4a32] shadow-[0_6px_14px_rgba(93,107,77,0.10)]"> |
| 143 | <Sparkles className="h-3.5 w-3.5 text-[#5d6b4d]" /> |
| 144 | {t('home.eyebrow')} |
| 145 | </div> |
| 146 | <h1 className="organic-serif mt-5 text-[32px] font-semibold leading-none text-[#3e4a32] sm:text-[40px]"> |
| 147 | {t('thinking.homeTitle')} |
| 148 | </h1> |
| 149 | <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-[#5d6b4d]"> |
| 150 | {t('thinking.homeDescription')} |
| 151 | </p> |
| 152 | </div> |
| 153 | </div> |
| 154 | </section> |
| 155 | |
| 156 | <section className="grid gap-6 lg:grid-cols-3"> |
| 157 | <button |
| 158 | type="button" |
| 159 | onClick={() => void handleQuickCreate()} |
| 160 | className="group relative flex min-h-[240px] flex-col overflow-hidden rounded-[2rem] border border-[#e0d8c8] bg-[#e8e0d0] p-7 text-left shadow-[0_14px_34px_rgba(86,73,54,0.12)] transition-colors hover:border-[#c8b89e] hover:bg-[#e5dccb] disabled:cursor-not-allowed disabled:opacity-65" |
| 161 | > |
| 162 | <div className="pointer-events-none absolute -right-12 -top-12 h-36 w-36 rounded-[30%_70%_70%_30%/30%_30%_70%_70%] bg-[#d4e4c1]/70 transition-transform group-hover:scale-110" /> |
| 163 | <div className="pointer-events-none absolute -bottom-14 left-10 h-28 w-40 rounded-[8%_92%_12%_88%/78%_22%_78%_22%] bg-[#c8b89e]/35" /> |
| 164 | |
| 165 | <div className="relative flex items-start justify-between gap-4"> |
| 166 | <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-[10%_90%_16%_84%/78%_22%_78%_22%] bg-[#8fbc8f] text-white shadow-[0_10px_22px_rgba(93,107,77,0.18)]"> |
| 167 | <FileText className="h-5 w-5" /> |
| 168 | </div> |
| 169 | <span className="rounded-full bg-[#fffdf8]/84 px-3 py-1.5 text-[11px] font-semibold text-[#5d6b4d] shadow-[0_6px_14px_rgba(86,73,54,0.08)]"> |
| 170 | {t('thinking.quickCreateBadge')} |
| 171 | </span> |
| 172 | </div> |
| 173 | |
| 174 | <div className="relative mt-7"> |
| 175 | <h2 className="organic-serif text-[30px] font-semibold leading-none text-[#3e4a32]"> |
| 176 | {t('thinking.quickCreate')} |
| 177 | </h2> |
| 178 | <p className="mt-3 max-w-[32rem] text-[14px] leading-relaxed text-[#5d6b4d]"> |
| 179 | {t('thinking.quickCreateDescription')} |
| 180 | </p> |
| 181 | </div> |
| 182 | |
| 183 | <div className="relative mt-auto pt-7"> |
| 184 | <span className="inline-flex h-11 items-center gap-2 rounded-full bg-[#5d6b4d] px-5 text-[13px] font-semibold text-white shadow-[0_10px_22px_rgba(93,107,77,0.22)] transition-colors group-hover:bg-[#3e4a32]"> |
| 185 | {t('thinking.startQuickCreate')} |
| 186 | <ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" /> |
| 187 | </span> |
| 188 | </div> |
| 189 | </button> |
| 190 | |
| 191 | <button |
| 192 | type="button" |
| 193 | onClick={() => void handleExplore()} |
| 194 | className="group relative flex min-h-[240px] flex-col overflow-hidden rounded-[2rem] border border-[#c8d6ba] bg-[#d4e4c1] p-7 text-left shadow-[0_14px_34px_rgba(86,73,54,0.12)] transition-colors hover:border-[#a9bd97] hover:bg-[#cedfb8]" |
| 195 | > |
| 196 | <div className="pointer-events-none absolute -bottom-12 -left-10 h-36 w-36 rounded-[30%_70%_70%_30%/30%_30%_70%_70%] bg-[#8fbc8f]/28 transition-transform group-hover:scale-110" /> |
| 197 | <div className="pointer-events-none absolute right-8 top-9 h-24 w-32 rounded-[8%_92%_12%_88%/78%_22%_78%_22%] bg-[#f5f1e8]/55" /> |
| 198 | |
| 199 | <div className="relative flex items-start justify-between gap-4"> |
| 200 | <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-[10%_90%_16%_84%/78%_22%_78%_22%] bg-[#5d6b4d] text-white shadow-[0_10px_22px_rgba(93,107,77,0.18)]"> |
| 201 | <MessageCircle className="h-5 w-5" /> |
| 202 | </div> |
| 203 | <span className="rounded-full bg-[#fffdf8]/84 px-3 py-1.5 text-[11px] font-semibold text-[#5d6b4d] shadow-[0_6px_14px_rgba(86,73,54,0.08)]"> |
| 204 | {t('thinking.exploreProjectBadge')} |
| 205 | </span> |
| 206 | </div> |
| 207 | |
| 208 | <div className="relative mt-7"> |
| 209 | <h2 className="organic-serif text-[30px] font-semibold leading-none text-[#3e4a32]"> |
| 210 | {t('thinking.exploreProject')} |
| 211 | </h2> |
| 212 | <p className="mt-3 max-w-[32rem] text-[14px] leading-relaxed text-[#5d6b4d]"> |
| 213 | {t('thinking.exploreProjectDescription')} |
| 214 | </p> |
| 215 | </div> |
| 216 | |
| 217 | <div className="relative mt-auto pt-7"> |
| 218 | <span className="inline-flex h-11 items-center gap-2 rounded-full bg-[#3e4a32] px-5 text-[13px] font-semibold text-white shadow-[0_10px_22px_rgba(62,74,50,0.20)] transition-colors group-hover:bg-[#5d6b4d]"> |
| 219 | {t('thinking.startExplore')} |
| 220 | <ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" /> |
| 221 | </span> |
| 222 | </div> |
| 223 | </button> |
| 224 | |
| 225 | <button |
| 226 | type="button" |
| 227 | onClick={() => void handleImportPptxClick()} |
| 228 | disabled={importingPptx} |
| 229 | className="group relative flex min-h-[240px] flex-col overflow-hidden rounded-[2rem] border border-[#d9cfbd] bg-[#f5f1e8] p-7 text-left shadow-[0_14px_34px_rgba(86,73,54,0.10)] transition-colors hover:border-[#c8b89e] hover:bg-[#efe7d8] disabled:cursor-not-allowed disabled:opacity-65" |
| 230 | > |
| 231 | <div className="pointer-events-none absolute -right-14 -bottom-12 h-36 w-36 rounded-[30%_70%_70%_30%/30%_30%_70%_70%] bg-[#c8b89e]/30 transition-transform group-hover:scale-110" /> |
| 232 | <div className="pointer-events-none absolute left-8 top-9 h-24 w-32 rounded-[8%_92%_12%_88%/78%_22%_78%_22%] bg-[#d4e4c1]/45" /> |
| 233 | |
| 234 | <div className="relative flex items-start justify-between gap-4"> |
| 235 | <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-[10%_90%_16%_84%/78%_22%_78%_22%] bg-[#b18f5e] text-white shadow-[0_10px_22px_rgba(86,73,54,0.16)]"> |
| 236 | <FileUp className="h-5 w-5" /> |
| 237 | </div> |
| 238 | <span className="rounded-full bg-[#fffdf8]/84 px-3 py-1.5 text-[11px] font-semibold text-[#7c6a4c] shadow-[0_6px_14px_rgba(86,73,54,0.08)]"> |
| 239 | PPTX |
| 240 | </span> |
| 241 | </div> |
| 242 | |
| 243 | <div className="relative mt-7"> |
| 244 | <h2 className="organic-serif text-[30px] font-semibold leading-none text-[#3e4a32]"> |
| 245 | {t('home.importPptx')} |
| 246 | </h2> |
| 247 | <p className="mt-3 max-w-[32rem] text-[14px] leading-relaxed text-[#5d6b4d]"> |
| 248 | {t('home.importPptxTooltip', { maxSize: MAX_PPTX_SIZE_MB })} |
| 249 | </p> |
| 250 | </div> |
| 251 | |
| 252 | <div className="relative mt-auto pt-7"> |
| 253 | <span className="inline-flex min-h-11 items-center gap-2 rounded-full bg-[#5d6b4d] px-5 py-2 text-[13px] font-semibold text-white shadow-[0_10px_22px_rgba(93,107,77,0.20)] transition-colors group-hover:bg-[#3e4a32]"> |
| 254 | {importingPptx ? ( |
| 255 | <> |
| 256 | <Loader2 className="h-3.5 w-3.5 animate-spin" /> |
| 257 | {pptxImportProgress || t('home.importingPptx')} |
| 258 | </> |
| 259 | ) : ( |
| 260 | <> |
| 261 | {t('home.importPptx')} |
| 262 | <ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" /> |
| 263 | </> |
| 264 | )} |
| 265 | </span> |
| 266 | </div> |
| 267 | </button> |
| 268 | </section> |
| 269 | |
| 270 | <input |
| 271 | ref={pptxInputRef} |
| 272 | type="file" |
| 273 | accept=".pptx" |
| 274 | multiple={false} |
| 275 | className="hidden" |
| 276 | onChange={(event) => void handlePptxFilesSelected(event.target.files)} |
| 277 | /> |
| 278 | |
| 279 | </div> |
| 280 | ) |
| 281 | } |
| 282 |