| 1 | import { useState, type ReactElement } from 'react' |
| 2 | import ReactMarkdown from 'react-markdown' |
| 3 | import { useT } from '@renderer/i18n' |
| 4 | import { useThinkingStore, useToastStore } from '@renderer/store' |
| 5 | import type { ThinkingStage } from '@shared/thinking' |
| 6 | import { |
| 7 | CheckCircle2, |
| 8 | FileText, |
| 9 | LayoutList, |
| 10 | Loader2, |
| 11 | Pencil, |
| 12 | Save, |
| 13 | Sparkles, |
| 14 | X |
| 15 | } from 'lucide-react' |
| 16 | import { Input, Textarea } from '../ui/Input' |
| 17 | |
| 18 | interface PageCard { |
| 19 | pageNumber: number |
| 20 | title: string |
| 21 | role: string |
| 22 | objective: string |
| 23 | summary: string |
| 24 | keyPoints: string[] |
| 25 | } |
| 26 | |
| 27 | interface PageCardDraft { |
| 28 | title: string |
| 29 | objective: string |
| 30 | summary: string |
| 31 | keyPoints: string |
| 32 | } |
| 33 | |
| 34 | interface ThinkingPageCardsProps { |
| 35 | thinkingMd: string |
| 36 | stage: ThinkingStage |
| 37 | onConfirmGenerate: () => void |
| 38 | loading: boolean |
| 39 | } |
| 40 | |
| 41 | function parsePageCards(thinkingMd: string): PageCard[] { |
| 42 | const matches: Array<{ pageNumber: number; title: string; index: number; length: number }> = [] |
| 43 | const regex = /^##\s*Page\s+(\d+)\s*:\s*(.+)$/gm |
| 44 | let match: RegExpExecArray | null |
| 45 | while ((match = regex.exec(thinkingMd)) !== null) { |
| 46 | matches.push({ |
| 47 | pageNumber: Number.parseInt(match[1], 10), |
| 48 | title: match[2].trim(), |
| 49 | index: match.index, |
| 50 | length: match[0].length |
| 51 | }) |
| 52 | } |
| 53 | return matches.map((item, index) => { |
| 54 | const next = matches[index + 1] |
| 55 | const contentStart = item.index + item.length |
| 56 | const contentEnd = next?.index ?? thinkingMd.length |
| 57 | const rawLines = thinkingMd |
| 58 | .slice(contentStart, contentEnd) |
| 59 | .split('\n') |
| 60 | .map((line) => line.trim()) |
| 61 | .filter(Boolean) |
| 62 | const roleLine = rawLines.find((line) => /^-\s*Role\s*:/i.test(line)) |
| 63 | const objectiveLine = rawLines.find((line) => /^-\s*Objective\s*:/i.test(line)) |
| 64 | const role = roleLine?.replace(/^-\s*Role\s*:\s*/i, '').trim() || '' |
| 65 | const objective = objectiveLine?.replace(/^-\s*Objective\s*:\s*/i, '').trim() || '' |
| 66 | const bodyLines = rawLines.filter((line) => line !== roleLine && line !== objectiveLine) |
| 67 | const keyPoints = bodyLines |
| 68 | .filter((line) => /^-\s+/.test(line)) |
| 69 | .map((line) => line.replace(/^-\s+/, '').trim()) |
| 70 | .filter(Boolean) |
| 71 | const summary = bodyLines |
| 72 | .filter((line) => !/^-\s+/.test(line)) |
| 73 | .join(' ') |
| 74 | .replace(/\s+/g, ' ') |
| 75 | .trim() |
| 76 | return { |
| 77 | pageNumber: item.pageNumber, |
| 78 | title: item.title, |
| 79 | role, |
| 80 | objective, |
| 81 | summary, |
| 82 | keyPoints |
| 83 | } |
| 84 | }) |
| 85 | } |
| 86 | |
| 87 | const STAGE_COLORS: Record<ThinkingStage, { bg: string; text: string; border: string }> = { |
| 88 | collect: { bg: 'bg-[#e8e0d0]', text: 'text-[#5d6b4d]', border: 'border-[#c8b89e]' }, |
| 89 | outline: { bg: 'bg-[#f5f1e8]', text: 'text-[#5d6b4d]', border: 'border-[#e0d8c8]' }, |
| 90 | draft: { bg: 'bg-[#e8e0d0]', text: 'text-[#5d6b4d]', border: 'border-[#c8b89e]' }, |
| 91 | refine: { bg: 'bg-[#f5f1e8]', text: 'text-[#5d6b4d]', border: 'border-[#e0d8c8]' }, |
| 92 | ready: { bg: 'bg-[#8fbc8f]', text: 'text-[#3e4a32]', border: 'border-[#8fbc8f]' } |
| 93 | } |
| 94 | |
| 95 | const STAGE_I18N_KEYS: Record<ThinkingStage, string> = { |
| 96 | collect: 'thinking.stageCollect', |
| 97 | outline: 'thinking.stageOutline', |
| 98 | draft: 'thinking.stageDraft', |
| 99 | refine: 'thinking.stageRefine', |
| 100 | ready: 'thinking.stageReady' |
| 101 | } |
| 102 | |
| 103 | export function ThinkingPageCards({ |
| 104 | thinkingMd, |
| 105 | stage, |
| 106 | onConfirmGenerate, |
| 107 | loading |
| 108 | }: ThinkingPageCardsProps): ReactElement { |
| 109 | const t = useT() |
| 110 | const updatePageOutline = useThinkingStore((state) => state.updatePageOutline) |
| 111 | const { success, error: toastError } = useToastStore() |
| 112 | const [viewMode, setViewMode] = useState<'outline' | 'document'>('outline') |
| 113 | const [editingPageNumber, setEditingPageNumber] = useState<number | null>(null) |
| 114 | const [savingPageNumber, setSavingPageNumber] = useState<number | null>(null) |
| 115 | const [draft, setDraft] = useState<PageCardDraft | null>(null) |
| 116 | const cards = parsePageCards(thinkingMd) |
| 117 | const colors = STAGE_COLORS[stage] |
| 118 | const canGenerate = cards.length > 0 && stage !== 'collect' |
| 119 | const hasDocument = thinkingMd.trim().length > 0 |
| 120 | const busy = loading || savingPageNumber !== null |
| 121 | |
| 122 | const startEditing = (card: PageCard): void => { |
| 123 | if (busy) return |
| 124 | setEditingPageNumber(card.pageNumber) |
| 125 | setDraft({ |
| 126 | title: card.title, |
| 127 | objective: card.objective, |
| 128 | summary: card.summary, |
| 129 | keyPoints: card.keyPoints.join('\n') |
| 130 | }) |
| 131 | } |
| 132 | |
| 133 | const cancelEditing = (): void => { |
| 134 | if (savingPageNumber !== null) return |
| 135 | setEditingPageNumber(null) |
| 136 | setDraft(null) |
| 137 | } |
| 138 | |
| 139 | const savePage = async (card: PageCard): Promise<void> => { |
| 140 | if (!draft || savingPageNumber !== null) return |
| 141 | const keyPoints = draft.keyPoints |
| 142 | .split('\n') |
| 143 | .map((point) => point.trim().replace(/^[-*]\s+/, '')) |
| 144 | .filter(Boolean) |
| 145 | if ( |
| 146 | !draft.title.trim() || |
| 147 | !draft.objective.trim() || |
| 148 | !draft.summary.trim() || |
| 149 | keyPoints.length === 0 |
| 150 | ) { |
| 151 | toastError(t('thinking.outlineRequired')) |
| 152 | return |
| 153 | } |
| 154 | |
| 155 | setSavingPageNumber(card.pageNumber) |
| 156 | try { |
| 157 | await updatePageOutline({ |
| 158 | pageNumber: card.pageNumber, |
| 159 | title: draft.title, |
| 160 | role: card.role, |
| 161 | objective: draft.objective, |
| 162 | summary: draft.summary, |
| 163 | keyPoints |
| 164 | }) |
| 165 | setEditingPageNumber(null) |
| 166 | setDraft(null) |
| 167 | success(t('thinking.outlineSaved')) |
| 168 | } catch (error) { |
| 169 | toastError(t('thinking.outlineSaveFailed'), { |
| 170 | description: error instanceof Error ? error.message : t('common.retryLater') |
| 171 | }) |
| 172 | } finally { |
| 173 | setSavingPageNumber(null) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | return ( |
| 178 | <div className="flex h-full min-h-0 flex-col"> |
| 179 | <div className="shrink-0 border-b border-[#c8d6ba] px-5 py-4"> |
| 180 | <div className="flex items-center justify-between gap-3"> |
| 181 | <div> |
| 182 | <h3 className="organic-serif text-[22px] font-semibold leading-none text-[#3e4a32]"> |
| 183 | {t('thinking.pageCardsTitle')} |
| 184 | </h3> |
| 185 | <p className="mt-1 text-[11px] text-[#5d6b4d]"> |
| 186 | {cards.length > 0 |
| 187 | ? t('thinking.pageCountLabel', { count: cards.length }) |
| 188 | : t('thinking.noPagesYet')} |
| 189 | </p> |
| 190 | </div> |
| 191 | <span |
| 192 | className={`rounded-full border px-3 py-1 text-[10px] font-semibold ${colors.bg} ${colors.text} ${colors.border}`} |
| 193 | > |
| 194 | {t(STAGE_I18N_KEYS[stage] as Parameters<typeof t>[0])} |
| 195 | </span> |
| 196 | </div> |
| 197 | <div className="mt-4 grid grid-cols-2 gap-1 rounded-full border border-[#c8d6ba] bg-[#e8e0d0]/70 p-1"> |
| 198 | <button |
| 199 | type="button" |
| 200 | onClick={() => setViewMode('outline')} |
| 201 | className={`flex h-8 items-center justify-center gap-1.5 rounded-full text-[11px] font-semibold transition-colors ${ |
| 202 | viewMode === 'outline' |
| 203 | ? 'bg-[#fffdf8] text-[#3e4a32] shadow-sm' |
| 204 | : 'text-[#5d6b4d] hover:bg-[#fffdf8]/60' |
| 205 | }`} |
| 206 | > |
| 207 | <LayoutList className="h-3.5 w-3.5" /> |
| 208 | {t('thinking.outlineView')} |
| 209 | </button> |
| 210 | <button |
| 211 | type="button" |
| 212 | onClick={() => setViewMode('document')} |
| 213 | className={`flex h-8 items-center justify-center gap-1.5 rounded-full text-[11px] font-semibold transition-colors ${ |
| 214 | viewMode === 'document' |
| 215 | ? 'bg-[#fffdf8] text-[#3e4a32] shadow-sm' |
| 216 | : 'text-[#5d6b4d] hover:bg-[#fffdf8]/60' |
| 217 | }`} |
| 218 | > |
| 219 | <FileText className="h-3.5 w-3.5" /> |
| 220 | thinking.md |
| 221 | </button> |
| 222 | </div> |
| 223 | </div> |
| 224 | |
| 225 | <div className="min-h-0 flex-1 overflow-y-auto px-3 py-4"> |
| 226 | {viewMode === 'document' ? ( |
| 227 | hasDocument ? ( |
| 228 | <div className="rounded-[2rem] border border-[#e0d8c8] bg-[#fffdf8] px-4 py-4 shadow-sm"> |
| 229 | <div className="mb-3 flex items-center gap-2 border-b border-[#e8e0d0] pb-2 text-[11px] font-semibold text-[#5d6b4d]"> |
| 230 | <FileText className="h-3.5 w-3.5" /> |
| 231 | <span>thinking.md</span> |
| 232 | </div> |
| 233 | <div className="thinking-md-preview break-words text-[#2f3329] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"> |
| 234 | <ReactMarkdown |
| 235 | components={{ |
| 236 | h1: ({ children }) => ( |
| 237 | <h1 className="mb-3 text-[18px] font-bold leading-tight text-[#26301f]"> |
| 238 | {children} |
| 239 | </h1> |
| 240 | ), |
| 241 | h2: ({ children }) => ( |
| 242 | <h2 className="mb-2 mt-4 border-t border-[#edf1e8] pt-3 text-[13px] font-bold leading-snug text-[#34422a]"> |
| 243 | {children} |
| 244 | </h2> |
| 245 | ), |
| 246 | h3: ({ children }) => ( |
| 247 | <h3 className="mb-1.5 mt-3 text-[12px] font-semibold text-[#3f4c36]"> |
| 248 | {children} |
| 249 | </h3> |
| 250 | ), |
| 251 | p: ({ children }) => ( |
| 252 | <p className="mb-2 whitespace-pre-wrap text-[12px] leading-relaxed text-[#4f5649]"> |
| 253 | {children} |
| 254 | </p> |
| 255 | ), |
| 256 | ul: ({ children }) => ( |
| 257 | <ul className="mb-2 list-disc space-y-1 pl-5 text-[12px] leading-relaxed marker:text-[#8b967e]"> |
| 258 | {children} |
| 259 | </ul> |
| 260 | ), |
| 261 | ol: ({ children }) => ( |
| 262 | <ol className="mb-2 list-decimal space-y-1 pl-5 text-[12px] leading-relaxed marker:text-[#8b967e]"> |
| 263 | {children} |
| 264 | </ol> |
| 265 | ), |
| 266 | li: ({ children }) => <li className="text-[#4f5649]">{children}</li>, |
| 267 | code: ({ children }) => ( |
| 268 | <code className="rounded bg-[#edf0e7] px-1 py-0.5 font-mono text-[11px] text-[#2f3329]"> |
| 269 | {children} |
| 270 | </code> |
| 271 | ), |
| 272 | pre: ({ children }) => ( |
| 273 | <pre className="mb-2 overflow-x-auto rounded-md bg-[#edf0e7] p-3 text-[11px] leading-relaxed text-[#2f3329]"> |
| 274 | {children} |
| 275 | </pre> |
| 276 | ), |
| 277 | blockquote: ({ children }) => ( |
| 278 | <blockquote className="mb-2 border-l-2 border-[#d7ddcf] pl-3 text-[12px] leading-relaxed text-[#6f7867]"> |
| 279 | {children} |
| 280 | </blockquote> |
| 281 | ) |
| 282 | }} |
| 283 | > |
| 284 | {thinkingMd} |
| 285 | </ReactMarkdown> |
| 286 | </div> |
| 287 | </div> |
| 288 | ) : ( |
| 289 | <div className="flex min-h-[260px] flex-col items-center justify-center rounded-[2rem] border border-dashed border-[#c8d6ba] bg-[#f5f1e8]/72 px-6 text-center"> |
| 290 | <div className="flex h-12 w-12 items-center justify-center rounded-[5%_95%_10%_90%/85%_15%_85%_15%] bg-[#8fbc8f] text-white"> |
| 291 | <FileText className="h-5 w-5" /> |
| 292 | </div> |
| 293 | <p className="mt-3 text-xs leading-relaxed text-[#7a806c]"> |
| 294 | {t('thinking.noDocumentYet')} |
| 295 | </p> |
| 296 | </div> |
| 297 | ) |
| 298 | ) : cards.length === 0 ? ( |
| 299 | <div className="flex min-h-[260px] flex-col items-center justify-center rounded-[2rem] border border-dashed border-[#c8d6ba] bg-[#f5f1e8]/72 px-6 text-center"> |
| 300 | <div className="flex h-12 w-12 items-center justify-center rounded-[5%_95%_10%_90%/85%_15%_85%_15%] bg-[#8fbc8f] text-white"> |
| 301 | <FileText className="h-5 w-5" /> |
| 302 | </div> |
| 303 | <p className="mt-3 text-xs leading-relaxed text-[#7a806c]"> |
| 304 | {t('thinking.noPagesYet')} |
| 305 | </p> |
| 306 | </div> |
| 307 | ) : ( |
| 308 | <div className="space-y-2.5"> |
| 309 | {cards.map((card) => ( |
| 310 | <div |
| 311 | key={card.pageNumber} |
| 312 | className="group rounded-[1.5rem] border border-[#c8d6ba] bg-[#f5f1e8] px-3 py-3 shadow-sm transition-colors hover:border-[#8fbc8f]" |
| 313 | > |
| 314 | <div className="flex items-start gap-3"> |
| 315 | <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#8fbc8f] text-[11px] font-bold text-[#3e4a32]"> |
| 316 | {card.pageNumber} |
| 317 | </span> |
| 318 | <div className="min-w-0 flex-1"> |
| 319 | {editingPageNumber === card.pageNumber && draft ? ( |
| 320 | <div className="space-y-2.5"> |
| 321 | <div className="flex items-center justify-between gap-2"> |
| 322 | <span className="rounded-full border border-[#c8d6ba] bg-[#fffdf8] px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.04em] text-[#5d6b4d]"> |
| 323 | {card.role} |
| 324 | </span> |
| 325 | <div className="flex items-center gap-1"> |
| 326 | <button |
| 327 | type="button" |
| 328 | onClick={cancelEditing} |
| 329 | disabled={savingPageNumber !== null} |
| 330 | className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[#7a806c] transition-colors hover:bg-[#e8e0d0] hover:text-[#3e4a32] disabled:opacity-40" |
| 331 | title={t('common.cancel')} |
| 332 | > |
| 333 | <X className="h-3.5 w-3.5" /> |
| 334 | </button> |
| 335 | <button |
| 336 | type="button" |
| 337 | onClick={() => void savePage(card)} |
| 338 | disabled={savingPageNumber !== null} |
| 339 | className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#3e4a32] text-white transition-colors hover:bg-[#5d6b4d] disabled:opacity-50" |
| 340 | title={t('thinking.saveOutline')} |
| 341 | > |
| 342 | {savingPageNumber === card.pageNumber ? ( |
| 343 | <Loader2 className="h-3.5 w-3.5 animate-spin" /> |
| 344 | ) : ( |
| 345 | <Save className="h-3.5 w-3.5" /> |
| 346 | )} |
| 347 | </button> |
| 348 | </div> |
| 349 | </div> |
| 350 | <label className="block"> |
| 351 | <span className="mb-1 block text-[10px] font-semibold text-[#5d6b4d]"> |
| 352 | {t('thinking.outlineTitle')} |
| 353 | </span> |
| 354 | <Input |
| 355 | value={draft.title} |
| 356 | onChange={(event) => |
| 357 | setDraft((current) => |
| 358 | current ? { ...current, title: event.target.value } : current |
| 359 | ) |
| 360 | } |
| 361 | className="h-8 rounded-lg border-[#c8d6ba] bg-[#fffdf8] px-2.5 text-[12px]" |
| 362 | /> |
| 363 | </label> |
| 364 | <label className="block"> |
| 365 | <span className="mb-1 block text-[10px] font-semibold text-[#5d6b4d]"> |
| 366 | {t('thinking.outlineObjective')} |
| 367 | </span> |
| 368 | <Textarea |
| 369 | value={draft.objective} |
| 370 | onChange={(event) => |
| 371 | setDraft((current) => |
| 372 | current ? { ...current, objective: event.target.value } : current |
| 373 | ) |
| 374 | } |
| 375 | rows={2} |
| 376 | className="min-h-14 resize-y rounded-lg border-[#c8d6ba] bg-[#fffdf8] px-2.5 py-2 text-[12px]" |
| 377 | /> |
| 378 | </label> |
| 379 | <label className="block"> |
| 380 | <span className="mb-1 block text-[10px] font-semibold text-[#5d6b4d]"> |
| 381 | {t('thinking.outlineSummary')} |
| 382 | </span> |
| 383 | <Textarea |
| 384 | value={draft.summary} |
| 385 | onChange={(event) => |
| 386 | setDraft((current) => |
| 387 | current ? { ...current, summary: event.target.value } : current |
| 388 | ) |
| 389 | } |
| 390 | rows={3} |
| 391 | className="min-h-20 resize-y rounded-lg border-[#c8d6ba] bg-[#fffdf8] px-2.5 py-2 text-[12px]" |
| 392 | /> |
| 393 | </label> |
| 394 | <label className="block"> |
| 395 | <span className="mb-1 block text-[10px] font-semibold text-[#5d6b4d]"> |
| 396 | {t('thinking.outlineKeyPoints')} |
| 397 | </span> |
| 398 | <Textarea |
| 399 | value={draft.keyPoints} |
| 400 | onChange={(event) => |
| 401 | setDraft((current) => |
| 402 | current ? { ...current, keyPoints: event.target.value } : current |
| 403 | ) |
| 404 | } |
| 405 | rows={4} |
| 406 | placeholder={t('thinking.outlineKeyPointsHint')} |
| 407 | className="min-h-24 resize-y rounded-lg border-[#c8d6ba] bg-[#fffdf8] px-2.5 py-2 text-[12px]" |
| 408 | /> |
| 409 | </label> |
| 410 | </div> |
| 411 | ) : ( |
| 412 | <> |
| 413 | <div className="flex min-w-0 items-start justify-between gap-2"> |
| 414 | <div className="line-clamp-2 min-w-0 text-[13px] font-semibold leading-snug text-[#2f3329]"> |
| 415 | {card.title} |
| 416 | </div> |
| 417 | <div className="flex shrink-0 items-center gap-1"> |
| 418 | <span className="rounded-full border border-[#c8d6ba] bg-[#fffdf8] px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.04em] text-[#5d6b4d]"> |
| 419 | {card.role} |
| 420 | </span> |
| 421 | <button |
| 422 | type="button" |
| 423 | onClick={() => startEditing(card)} |
| 424 | disabled={busy || editingPageNumber !== null} |
| 425 | className="inline-flex h-6 w-6 items-center justify-center rounded-full text-[#7a806c] opacity-70 transition-colors hover:bg-[#e8e0d0] hover:text-[#3e4a32] hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30" |
| 426 | title={t('thinking.editOutline')} |
| 427 | > |
| 428 | <Pencil className="h-3 w-3" /> |
| 429 | </button> |
| 430 | </div> |
| 431 | </div> |
| 432 | <p className="mt-1.5 text-[11px] font-medium leading-relaxed text-[#4f6340]"> |
| 433 | {card.objective} |
| 434 | </p> |
| 435 | {card.summary ? ( |
| 436 | <p className="mt-1.5 line-clamp-3 text-[11px] leading-relaxed text-[#747968]"> |
| 437 | {card.summary} |
| 438 | </p> |
| 439 | ) : null} |
| 440 | {card.keyPoints.length > 0 && ( |
| 441 | <ul className="mt-2 space-y-1 text-[11px] leading-relaxed text-[#747968]"> |
| 442 | {card.keyPoints.slice(0, 3).map((point, pointIndex) => ( |
| 443 | <li key={pointIndex} className="flex gap-1.5"> |
| 444 | <span className="mt-[0.55em] h-1 w-1 shrink-0 rounded-full bg-[#8fbc8f]" /> |
| 445 | <span className="line-clamp-2">{point}</span> |
| 446 | </li> |
| 447 | ))} |
| 448 | </ul> |
| 449 | )} |
| 450 | </> |
| 451 | )} |
| 452 | </div> |
| 453 | </div> |
| 454 | </div> |
| 455 | ))} |
| 456 | </div> |
| 457 | )} |
| 458 | </div> |
| 459 | |
| 460 | <div className="shrink-0 border-t border-[#c8d6ba] bg-[#d4e4c1] p-3"> |
| 461 | <button |
| 462 | type="button" |
| 463 | onClick={onConfirmGenerate} |
| 464 | disabled={busy || editingPageNumber !== null || !canGenerate} |
| 465 | className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-[#3e4a32] text-[13px] font-semibold text-white shadow-sm transition-colors hover:bg-[#5d6b4d] disabled:opacity-40 disabled:hover:bg-[#3e4a32]" |
| 466 | > |
| 467 | {busy ? ( |
| 468 | <Loader2 className="h-4 w-4 animate-spin" /> |
| 469 | ) : canGenerate ? ( |
| 470 | <Sparkles className="h-4 w-4" /> |
| 471 | ) : ( |
| 472 | <CheckCircle2 className="h-4 w-4" /> |
| 473 | )} |
| 474 | {busy ? t('thinking.thinking') : t('thinking.confirmAndGenerate')} |
| 475 | </button> |
| 476 | {!canGenerate && ( |
| 477 | <p className="mt-2 text-center text-[10px] text-[#7a806c]"> |
| 478 | {t('thinking.needMoreWork')} |
| 479 | </p> |
| 480 | )} |
| 481 | </div> |
| 482 | </div> |
| 483 | ) |
| 484 | } |
| 485 |