返回 oh-my-ppt
home.tsx
根目录 / src / renderer / src / pages / home.tsx
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
282 lines Plain Text