diff --git a/app/HomeClient.tsx b/app/HomeClient.tsx index 4f4df5e..73dbea1 100644 --- a/app/HomeClient.tsx +++ b/app/HomeClient.tsx @@ -15,7 +15,7 @@ const TAB_LABELS: Record = { transit: '流日圖', } -export default function HomeClient() { +const HomeClient = () => { const [tab, setTab] = useState('personal') return ( @@ -49,3 +49,5 @@ export default function HomeClient() { ) } + +export default HomeClient diff --git a/app/about/AboutClient.tsx b/app/about/AboutClient.tsx index 9305f9a..0d4a554 100644 --- a/app/about/AboutClient.tsx +++ b/app/about/AboutClient.tsx @@ -1,6 +1,6 @@ 'use client' -export default function AboutClient() { +const AboutClient = () => { return (
@@ -56,3 +56,5 @@ export default function AboutClient() {
) } + +export default AboutClient diff --git a/app/about/page.tsx b/app/about/page.tsx index 6b0e2aa..d67278f 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -13,6 +13,8 @@ export const metadata: Metadata = { }, } -export default function AboutPage() { +const AboutPage = () => { return } + +export default AboutPage diff --git a/app/account/AccountClient.tsx b/app/account/AccountClient.tsx index d28adc5..2744c97 100644 --- a/app/account/AccountClient.tsx +++ b/app/account/AccountClient.tsx @@ -4,51 +4,28 @@ import { useUser, useClerk } from '@clerk/nextjs' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState, startTransition, useRef, Suspense } from 'react' import dynamic from 'next/dynamic' -import Image from 'next/image' import toast from 'react-hot-toast' import ChartView from '@/components/humanDesign/ChartView' -import BirthProfileManager from '@/components/humanDesign/BirthProfileManager' -import { computeHdResult } from '@/lib/computeHdResult' -import type { HdResult } from '@/lib/buildAiPrompt' -import { computeTransit, type TransitResult } from '@/lib/computeTransit' -import { CHANNEL_DEFS, calculateCentersAndChannels } from '@/lib/humanDesign' -import { toUtcDate, getOffsetFromTimezone } from '@/utils/ephemeris' import { LoadingSpinner } from '@/components/LoadingSpinner' import { ConfirmModal } from '@/components/ConfirmModal' -import { useCharts, type PersonMeta, type ChartMeta, type SavedChart } from '@/lib/useCharts' -import { useNotifications, type NotificationType } from '@/lib/useNotifications' +import { useCharts, type SavedChart } from '@/lib/useCharts' +import { kindOf, parseCompositeBirthInfo, type ChartTab } from '@/lib/chartRecompute' +import { useActiveChartRecompute } from './useActiveChartRecompute' +import ProfileSection from './ProfileSection' +import NotificationsSection from './NotificationsSection' const CompositeView = dynamic(() => import('@/components/humanDesign/CompositeView'), { ssr: false }) const TransitView = dynamic(() => import('@/components/humanDesign/TransitView'), { ssr: false }) type SidebarSection = 'profile' | 'humandesign' | 'notifications' -const NOTIFICATION_TYPE_CFG: Record = { - feature: { label: '新功能', color: 'var(--olive-text)' }, - bugfix: { label: '問題修正', color: 'var(--crimson)' }, - announcement: { label: '公告', color: 'var(--tan-text)' }, -} - -function formatNotificationDate(iso: string) { - const d = new Date(iso) - return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}` -} - const CHART_TABS = [ { id: 'personal', label: '個人' }, { id: 'composite', label: '合圖' }, { id: 'transit', label: '流日' }, ] as const -type ChartTab = typeof CHART_TABS[number]['id'] -function kindOf(chart: Pick): ChartTab { - if (chart.chartKind === 'composite' || chart.chartKind === 'transit') return chart.chartKind - // 舊格式合圖:網頁端存 type='合圖' 但沒有設 chartKind - if (chart.type === '合圖' || chart.birthDate?.includes('|')) return 'composite' - return 'personal' -} - -export default function AccountClient() { +const AccountClient = () => { return ( @@ -56,8 +33,10 @@ export default function AccountClient() { ) } -function AccountContent() { - const { isLoaded, isSignedIn, user } = useUser() +export default AccountClient + +const AccountContent = () => { + const { isLoaded, isSignedIn } = useUser() const { signOut } = useClerk() const router = useRouter() const searchParams = useSearchParams() @@ -71,11 +50,6 @@ function AccountContent() { return t === 'composite' || t === 'transit' || t === 'personal' ? t : 'personal' }) - const [chartResult, setChartResult] = useState(null) - const [chartComputing, setChartComputing] = useState(false) - const [compositeResults, setCompositeResults] = useState<{ a: HdResult; b: HdResult } | null>(null) - const [transitSnapshot, setTransitSnapshot] = useState(null) - useEffect(() => { if (isLoaded && !isSignedIn) router.replace('/') }, [isLoaded, isSignedIn, router]) @@ -85,13 +59,32 @@ function AccountContent() { renameChart, renamingId, deleteChart, deletingId, } = useCharts(activeSection === 'humandesign' && !!isSignedIn) - // 每次帶著 query string 導向 humandesign 分頁時強制重新整理(例如從 /create 存完圖表跳轉回來) + // 每次帶著 query string 導向 humandesign 分頁時強制重新整理(例如從 /create 存完圖表跳轉回來)。 + // URL 帶 tab 參數只會發生在剛存完圖表導頁回來這個情境(見 PersonalTab/CompositeTab/TransitTab 的 + // onSaved)。這時不能只是清掉 activeChartId 交給下面「保留原本選取」的 effect 處理——refetchCharts() + // 是非同步的,那個 effect 很可能在 refetch 真正完成前就先用「還沒更新的舊清單」跑一次,選到舊清單 + // 排序後的第一筆(也就是存檔前的最新一筆,不是剛存的那筆),之後「保留原本選取」的邏輯又會鎖住這個 + // 錯誤的選擇,即使真正的新清單later到位也不會再更新。改成直接等 refetchCharts() 這個 promise + // resolve 拿到的最新資料來決定要選哪一筆,不依賴 effect 執行順序這種不保證的時序。 useEffect(() => { - startTransition(() => { - const t = searchParams.get('tab') - if (t === 'composite' || t === 'transit' || t === 'personal') setChartTab(t) - if (activeSection === 'humandesign') refetchCharts() - }) + const t = searchParams.get('tab') + const isSaveRedirect = t === 'composite' || t === 'transit' || t === 'personal' + if (isSaveRedirect) { + startTransition(() => { + setChartTab(t) + setActiveChartId(null) + }) + } + if (activeSection !== 'humandesign') return + if (isSaveRedirect) { + refetchCharts().then(res => { + const fresh = res.data?.charts ?? [] + const filtered = fresh.filter(ch => kindOf(ch) === t) + startTransition(() => setActiveChartId(filtered[0]?.id ?? null)) + }) + } else { + refetchCharts() + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams]) @@ -105,97 +98,7 @@ function AccountContent() { }) }, [charts, chartTab]) - const { - notifications, isAdmin: isNotificationsAdmin, loading: notificationsLoading, - createNotification, creating: creatingNotif, - deleteNotification, deletingId: deletingNotifId, - } = useNotifications(activeSection === 'notifications') - - const [newNotifTitle, setNewNotifTitle] = useState('') - const [newNotifBody, setNewNotifBody] = useState('') - const [newNotifType, setNewNotifType] = useState('announcement') - - const handleCreateNotification = async () => { - if (creatingNotif) return - if (!newNotifTitle.trim() || !newNotifBody.trim()) { - toast.error('標題與內容為必填') - return - } - try { - await createNotification({ title: newNotifTitle.trim(), body: newNotifBody.trim(), type: newNotifType }) - setNewNotifTitle('') - setNewNotifBody('') - setNewNotifType('announcement') - toast.success('已新增通知') - } catch (err) { - console.error('[account] handleCreateNotification error:', err) - toast.error(err instanceof Error ? err.message : '新增失敗') - } - } - - const handleDeleteNotification = async (id: string) => { - if (deletingNotifId) return - try { - await deleteNotification(id) - } catch (err) { - console.error('[account] handleDeleteNotification error:', err) - toast.error('刪除失敗') - } - } - - const [editingName, setEditingName] = useState(false) - const [displayName, setDisplayName] = useState('') - const [nameSaving, setNameSaving] = useState(false) - const [avatarUploading, setAvatarUploading] = useState(false) - const avatarInputRef = useRef(null) - - const handleStartEditName = () => { - if (!user) return - setDisplayName(user.fullName?.trim() || user.username || '') - setEditingName(true) - window.umami?.track('account-edit-name') - } - - const handleSaveName = async () => { - if (nameSaving || !user) return - setNameSaving(true) - window.umami?.track('account-save-name') - try { - const trimmed = displayName.trim() - const spaceIdx = trimmed.indexOf(' ') - const firstName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx) - const lastName = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1) - await user.update({ firstName, lastName }) - setEditingName(false) - toast.success('名稱已更新') - } catch { - toast.error('儲存失敗') - } finally { - setNameSaving(false) - } - } - - const handleAvatarChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file || !user) return - if (file.size > 10 * 1024 * 1024) { - toast.error('圖片大小不能超過 10MB') - if (avatarInputRef.current) avatarInputRef.current.value = '' - return - } - setAvatarUploading(true) - window.umami?.track('account-avatar-upload') - try { - await user.setProfileImage({ file }) - toast.success('頭像已更新') - } catch (err) { - const msg = err instanceof Error ? err.message : '' - toast.error(msg || '頭像上傳失敗') - } finally { - setAvatarUploading(false) - if (avatarInputRef.current) avatarInputRef.current.value = '' - } - } + const { chartResult, chartComputing, compositeResults, transitSnapshot } = useActiveChartRecompute(charts, activeChartId) const [editingChartId, setEditingChartId] = useState(null) const [editingChartName, setEditingChartName] = useState('') @@ -222,10 +125,6 @@ function AccountContent() { const [confirmDeleteId, setConfirmDeleteId] = useState(null) const isDeletingRef = useRef(false) - const chartRequestIdRef = useRef(0) - - const [deletingAccount, setDeletingAccount] = useState(false) - const [confirmDeleteAccount, setConfirmDeleteAccount] = useState(false) const handleDeleteChart = async (id: string) => { if (isDeletingRef.current) return @@ -252,137 +151,6 @@ function AccountContent() { setActiveChartId(filtered[0]?.id ?? null) } - useEffect(() => { - if (!activeChartId) return - const chart = charts.find(c => c.id === activeChartId) - if (!chart) return - - startTransition(() => { - setChartResult(null) - setCompositeResults(null) - setTransitSnapshot(null) - setChartComputing(true) - }) - - const requestId = ++chartRequestIdRef.current - - const isComposite = kindOf(chart) === 'composite' - const isTransit = kindOf(chart) === 'transit' - - if (isTransit) { - const transitMeta = chart.meta?.transitMeta - if (transitMeta) { - computeHdResult(transitMeta.personalBirthDate, transitMeta.personalBirthTime, transitMeta.personalTimezone) - .then(personalResult => { - if (chartRequestIdRef.current !== requestId) return - const definedChannels = chart.channels - .map(id => CHANNEL_DEFS.find(ch => ch.id === id)) - .filter((ch): ch is typeof CHANNEL_DEFS[number] => !!ch) - setChartResult(personalResult) - setTransitSnapshot({ - planets: transitMeta.transitPlanets, - allGates: new Set(chart.gates), - definedCenterIds: new Set(chart.centers), - definedChannels, - computedAt: transitMeta.transitComputedAt, - }) - }) - .catch(err => { if (chartRequestIdRef.current === requestId) { console.error(err); toast.error('計算失敗') } }) - .finally(() => { if (chartRequestIdRef.current === requestId) setChartComputing(false) }) - } else if (chart.planets && chart.personalityGates && chart.designGates && chart.timezone) { - // 舊格式流日圖:缺少 meta.transitMeta,但本命閘門與流日計算當下的時刻都還在, - // 可用來精準重建(閘門/通道/中心/流日行星皆可重算,僅箭頭方向與變數顏色因 tone 遺失而無法還原)。 - const legacyPersonalAllGates = new Set([...chart.personalityGates, ...chart.designGates]) - const { definedCenterIds, definedChannels } = calculateCentersAndChannels(legacyPersonalAllGates) - const dummyGate = { gate: 0, line: 0, color: 1, tone: 1, base: 1, full: '' } - const legacyPersonal: HdResult = { - jd: 0, - designJd: 0, - utcTime: '', - designUtcTime: '', - planets: chart.planets.map(p => ({ - planetName: p.name, - black: { gate: p.blackGate, line: p.blackLine, color: 1, tone: 1, base: 1, full: `${p.blackGate}.${p.blackLine}` }, - red: { gate: p.redGate, line: p.redLine, color: 1, tone: 1, base: 1, full: `${p.redGate}.${p.redLine}` }, - display: '', - persLon: 0, - desLon: 0, - })), - profile: { - profile: chart.profile, - personalitySunLine: 0, - designSunLine: 0, - personalitySun: dummyGate, - designSun: dummyGate, - }, - type: chart.type as HdResult['type'], - authority: { name: chart.authority, tip: '' }, - definedCenterIds, - definedChannels, - allGates: legacyPersonalAllGates, - incarnationCross: { - crossType: 'RAC', crossBaseName: '', crossName: '', variant: 1, - conscious: '', unconscious: '', gatesLabel: '', persSunGate: 0, persSunLine: 0, - }, - variables: { - digestion: { label: '', description: '' }, - environment: { label: '', description: '' }, - perspective: { label: '', description: '' }, - motivation: { label: '', description: '' }, - }, - definition: { raw: chart.definition, label: chart.definition }, - } - - const legacyMoment = toUtcDate( - chart.birthDate, - chart.birthTime, - getOffsetFromTimezone(chart.timezone, new Date(`${chart.birthDate}T${chart.birthTime}:00`)), - ) - - computeTransit(legacyMoment) - .then(transit => { - if (chartRequestIdRef.current !== requestId) return - setChartResult(legacyPersonal) - setTransitSnapshot(transit) - }) - .catch(err => { if (chartRequestIdRef.current === requestId) { console.error(err); toast.error('計算失敗') } }) - .finally(() => { if (chartRequestIdRef.current === requestId) setChartComputing(false) }) - } else { - startTransition(() => setChartComputing(false)) - toast.error('這份流日圖是舊格式儲存,缺少完整資料,無法重新顯示') - } - } else if (isComposite) { - let dateA: string, timeA: string, tzA: string - let dateB: string, timeB: string, tzB: string - - if (chart.meta?.personA && chart.meta?.personB) { - // New format: individual fields in meta - const { personA, personB } = chart.meta as Required - dateA = personA.birthDate; timeA = personA.birthTime; tzA = personA.timezone - dateB = personB.birthDate; timeB = personB.birthTime; tzB = personB.timezone - } else { - // Old format: pipe-separated fields - ;[dateA, dateB] = chart.birthDate.split('|') - ;[timeA, timeB] = chart.birthTime.split('|') - ;[tzA, tzB] = (chart.timezone ?? 'UTC|UTC').split('|') - } - - Promise.all([ - computeHdResult(dateA, timeA, tzA), - computeHdResult(dateB, timeB, tzB), - ]) - .then(([a, b]) => { if (chartRequestIdRef.current === requestId) setCompositeResults({ a, b }) }) - .catch(err => { if (chartRequestIdRef.current === requestId) { console.error(err); toast.error('計算失敗') } }) - .finally(() => { if (chartRequestIdRef.current === requestId) setChartComputing(false) }) - } else { - const tz = chart.timezone ?? 'UTC' - computeHdResult(chart.birthDate, chart.birthTime, tz) - .then(r => { if (chartRequestIdRef.current === requestId) setChartResult(r) }) - .catch(err => { if (chartRequestIdRef.current === requestId) { console.error(err); toast.error('計算失敗') } }) - .finally(() => { if (chartRequestIdRef.current === requestId) setChartComputing(false) }) - } - }, [activeChartId, charts]) - if (!isLoaded || !isSignedIn) { return ( <> @@ -405,26 +173,6 @@ function AccountContent() { } } - const handleDeleteAccount = async () => { - if (deletingAccount) return - setDeletingAccount(true) - window.umami?.track('account-delete-account') - try { - const res = await fetch('/api/account/delete', { method: 'DELETE' }) - if (!res.ok) { - toast.error('刪除失敗,請稍後再試') - return - } - await signOut() - router.push('/') - } catch { - toast.error('刪除失敗,請稍後再試') - } finally { - setDeletingAccount(false) - setConfirmDeleteAccount(false) - } - } - const handleSectionClick = (section: SidebarSection) => { router.replace(`/account?section=${section}`, { scroll: false }) window.umami?.track('account-section-click', { section }) @@ -564,113 +312,7 @@ function AccountContent() { {/* ── Main content ── */}
- {/* Profile */} - {activeSection === 'profile' && ( -
-
-

- 個人資料 -

-
- -
- {/* Avatar */} -
- - -
- - {/* Name & email */} -
- {!editingName ? ( -
- - {user.fullName?.trim() || user.username || '—'} - - -
- ) : ( -
-
- setDisplayName(e.target.value)} - className="font-mono text-base tracking-[0.04em] border border-(--ink) bg-(--paper) text-(--ink) px-3 py-1.5 w-52 outline-none placeholder:text-(--ink-soft)" - /> -
-
- - -
-
- )} - - - {user.primaryEmailAddress?.emailAddress ?? '—'} - -
-
- - - -
- -
-
- )} + {/* Human Design */} {activeSection === 'humandesign' && ( @@ -679,6 +321,11 @@ function AccountContent() {

我的圖表

+ {activeChart && ( +

+ {activeChart.name ?? `${activeChart.birthCity} · ${activeChart.birthDate}`} +

+ )} {chartsLoading && ( @@ -759,25 +406,7 @@ function AccountContent() { )} {kindOf(activeChart) === 'composite' ? ( compositeResults && (() => { - let cityA: string, cityB: string - let dateA: string, dateB: string - let timeA: string, timeB: string - let tzA: string, tzB: string - - if (activeChart.meta?.personA && activeChart.meta?.personB) { - const pA = activeChart.meta.personA as PersonMeta - const pB = activeChart.meta.personB as PersonMeta - cityA = pA.birthCity; cityB = pB.birthCity - dateA = pA.birthDate; dateB = pB.birthDate - timeA = pA.birthTime; timeB = pB.birthTime - tzA = pA.timezone; tzB = pB.timezone - } else { - ;[cityA, cityB] = activeChart.birthCity.split('|') - ;[dateA, dateB] = activeChart.birthDate.split('|') - ;[timeA, timeB] = activeChart.birthTime.split('|') - ;[tzA, tzB] = (activeChart.timezone ?? 'UTC|UTC').split('|') - } - + const { dateA, timeA, cityA, tzA, dateB, timeB, cityB, tzB } = parseCompositeBirthInfo(activeChart) return ( )} - {/* Notifications */} - {activeSection === 'notifications' && ( -
-
-

- 通知 -

-
- - {isNotificationsAdmin && ( -
-
- 新增通知 -
- setNewNotifTitle(e.target.value)} - disabled={creatingNotif} - className="font-mono text-base tracking-[0.04em] border border-(--ink) bg-(--paper) text-(--ink) px-3 py-1.5 outline-none placeholder:text-(--ink-soft) disabled:opacity-50" - /> -