page.tsx (13966B)
1 "use client"; 2 3 import { useState, useEffect, useCallback, useRef } from "react"; 4 import { supabase } from "@/hooks/useAuth"; 5 import OneSignalInit from "@/components/OneSignalInit"; 6 import Header from "@/components/Header"; 7 import TimerDashboard from "@/components/TimerDashboard"; 8 import BottomNavBar from "@/components/BottomNavBar"; 9 import HistoryCalendar from "@/components/HistoryCalendar"; 10 import BondDashboard from "@/components/BondDashboard"; 11 import Settings from "@/components/Settings"; 12 import SidePanel from "@/components/SidePanel"; 13 import NewFeatureDialog from "@/components/NewFeatureDialog"; 14 import { CALENDAR_LIMITS } from "@/lib/timeUtils"; 15 import { OVERLAY_CONTENTS } from "@/components/pages"; 16 17 type Tab = 'timer' | 'history' | 'bond'; 18 19 const Overlay = ({ contentKey, onClose }: { contentKey: string; onClose: () => void }) => { 20 const content = OVERLAY_CONTENTS[contentKey]; 21 if (!content) return null; 22 23 return ( 24 <div className="fixed inset-0 bg-(--background) z-100 flex justify-center items-start p-6"> 25 <div className="w-full max-w-4xl max-h-[93svh] mt-2 rounded-2xl shadow-xl border flex flex-col overflow-hidden"> 26 <div className="border-b border-dashed flex justify-between items-center z-20"> 27 <h1 className="text-2xl px-6 py-4 font-bold truncate mr-4"> 28 {content.title} 29 </h1> 30 <button 31 onClick={onClose} 32 className="btn-close px-6 py-4 cursor-pointer" 33 aria-label="Close" 34 > 35 <svg xmlns="http://www.w3.org/2000/svg" className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 36 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> 37 </svg> 38 </button> 39 </div> 40 <div className="flex-1 overflow-y-auto p-6 font-normal"> 41 {content.body} 42 </div> 43 <div className="h-2" /> 44 </div> 45 </div> 46 ); 47 }; 48 49 export default function Home() { 50 const [activeTab, setActiveTab] = useState<Tab>(() => { 51 if (typeof window !== 'undefined') { 52 const savedTab = localStorage.getItem('last_active_tab') as Tab | null; 53 const savedTimeStr = localStorage.getItem('last_active_tab_time'); 54 if (savedTab === 'timer' || savedTab === 'history' || savedTab === 'bond') { 55 if (savedTimeStr) { 56 const savedTime = parseInt(savedTimeStr, 10); 57 const now = Date.now(); 58 const TEN_MINUTES = 10 * 60 * 1000; 59 if (now - savedTime < TEN_MINUTES) { 60 return savedTab; 61 } 62 } 63 } 64 } 65 return 'timer'; 66 }); 67 const [session, setSession] = useState<any>(null); 68 const [timerHistory, setTimerHistory] = useState<number[]>([]); 69 const [calendarHistory, setCalendarHistory] = useState<number[]>([]); 70 const [calendarDate, setCalendarDate] = useState(() => new Date()); 71 const [ticket1Time, setTicket1Time] = useState<Date | null>(null); 72 const [ticket2Time, setTicket2Time] = useState<Date | null>(null); 73 const [relationshipHistory, setRelationshipHistory] = useState<any[]>([]); 74 const [isSyncing, setIsSyncing] = useState(false); 75 const [isDataLoaded, setIsDataLoaded] = useState(false); 76 const [isAuthChecking, setIsAuthChecking] = useState(true); 77 const [isSidePanelOpen, setIsSidePanelOpen] = useState(false); 78 const [overlayKey, setOverlayKey] = useState<string | null>(null); 79 80 const isInitialFetched = useRef(false); 81 82 useEffect(() => { 83 if (typeof window !== 'undefined' && activeTab) { 84 localStorage.setItem('last_active_tab', activeTab); 85 localStorage.setItem('last_active_tab_time', Date.now().toString()); 86 } 87 }, [activeTab]); 88 89 const fetchRelationshipHistory = useCallback(async () => { 90 const { data: { session: s } } = await supabase.auth.getSession(); 91 if (!s) return; 92 try { 93 const res = await fetch('/api/relationship', { 94 headers: { 'Authorization': `Bearer ${s.access_token}` } 95 }); 96 const data = await res.json(); 97 setRelationshipHistory(Array.isArray(data) ? data : []); 98 } catch (e) { console.error(e); } 99 }, []); 100 101 const handleSaveRelationship = async (level: number, date: string, charKey: string) => { 102 if (!session || isSyncing) return; 103 setIsSyncing(true); 104 try { 105 const res = await fetch('/api/relationship', { 106 method: 'POST', 107 headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}` }, 108 body: JSON.stringify({ char_key: charKey, bond_level: level, recorded_at: date }) 109 }); 110 111 if (!res.ok) { 112 const errorData = await res.json(); 113 alert(errorData.error || "登録に失敗しました。"); 114 return; 115 } 116 117 await fetchRelationshipHistory(); 118 } catch (e) { 119 console.error(e); 120 } finally { 121 setIsSyncing(false); 122 } 123 }; 124 125 const fetchMonthlyData = useCallback(async (year: number, month: number) => { 126 const { data: { session: s } } = await supabase.auth.getSession(); 127 if (!s?.user?.id) return []; 128 try { 129 const { data, error } = await supabase.rpc('get_taps_by_logical_month', { 130 target_user_id: s.user.id, target_year: year, target_month: month 131 }); 132 if (error) throw error; 133 return (data || []).map((t: any) => new Date(t.tap_time).getTime()); 134 } catch (e) { 135 console.error("RPC Error:", e); 136 return []; 137 } 138 }, []); 139 140 const handleMonthChange = useCallback(async (year: number, month: number) => { 141 const targetDate = new Date(year, month - 1, 1); 142 const minLimit = new Date(CALENDAR_LIMITS.MIN.getFullYear(), CALENDAR_LIMITS.MIN.getMonth(), 1); 143 const maxLimit = new Date(CALENDAR_LIMITS.MAX.getFullYear(), CALENDAR_LIMITS.MAX.getMonth(), 1); 144 if (targetDate < minLimit || targetDate > maxLimit) return; 145 setCalendarDate(targetDate); 146 const data = await fetchMonthlyData(year, month); 147 setCalendarHistory(data); 148 }, [fetchMonthlyData]); 149 150 const loadInitialData = useCallback(async () => { 151 if (isInitialFetched.current) return; 152 const { data: { session: s } } = await supabase.auth.getSession(); 153 if (!s?.user?.id) { setIsAuthChecking(false); return; } 154 isInitialFetched.current = true; 155 try { 156 const now = new Date(); 157 const jstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000); 158 let year = jstNow.getUTCFullYear(); 159 let month = jstNow.getUTCMonth() + 1; 160 if (jstNow.getUTCDate() === 1 && jstNow.getUTCHours() < 4) { 161 const prev = new Date(jstNow); prev.setUTCDate(0); 162 year = prev.getUTCFullYear(); month = prev.getUTCMonth() + 1; 163 } 164 setCalendarDate(new Date(year, month - 1, 1)); 165 const [profileRes, monthlyData] = await Promise.all([ 166 supabase.from('profiles').select('ticket1_time, ticket2_time').eq('id', s.user.id).single(), 167 fetchMonthlyData(year, month), 168 fetchRelationshipHistory() 169 ]); 170 if (profileRes.data) { 171 if (profileRes.data.ticket1_time) setTicket1Time(new Date(profileRes.data.ticket1_time)); 172 if (profileRes.data.ticket2_time) setTicket2Time(new Date(profileRes.data.ticket2_time)); 173 } 174 setCalendarHistory(monthlyData); 175 setTimerHistory(monthlyData); 176 setIsDataLoaded(true); 177 } finally { setIsAuthChecking(false); } 178 }, [fetchMonthlyData, fetchRelationshipHistory]); 179 180 useEffect(() => { 181 supabase.auth.getSession().then(({ data: { session: s } }) => { 182 setSession(s); 183 if (s) loadInitialData(); else setIsAuthChecking(false); 184 }); 185 const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, s) => { 186 setSession(s); 187 if (s) loadInitialData(); else { setIsAuthChecking(false); setIsDataLoaded(false); isInitialFetched.current = false; } 188 }); 189 return () => subscription.unsubscribe(); 190 }, [loadInitialData]); 191 192 useEffect(() => { 193 const RELOAD_THRESHOLD = 3; 194 const RELOAD_INTERVAL = 5000; 195 const now = Date.now(); 196 const lastReload = sessionStorage.getItem('last_reload_time'); 197 const reloadCount = parseInt(sessionStorage.getItem('reload_count') || '0'); 198 if (lastReload && now - parseInt(lastReload) < RELOAD_INTERVAL) { 199 const newCount = reloadCount + 1; 200 sessionStorage.setItem('reload_count', newCount.toString()); 201 if (newCount >= RELOAD_THRESHOLD) { 202 alert("短時間に連続してリロードされています。サーバ負荷軽減のため、しばらく時間を置いてから操作してください。"); 203 sessionStorage.setItem('reload_count', '0'); 204 } 205 } else { 206 sessionStorage.setItem('reload_count', '1'); 207 } 208 sessionStorage.setItem('last_reload_time', now.toString()); 209 }, []); 210 211 const handleTap = async () => { 212 if (!session || isSyncing) return; 213 setIsSyncing(true); 214 const now = new Date(); now.setMilliseconds(0); 215 const ms = now.getTime(); 216 setTimerHistory(prev => [...prev, ms]); 217 const tapJST = new Date(now.getTime() + 9 * 60 * 60 * 1000); 218 if (tapJST.getUTCFullYear() === calendarDate.getFullYear() && (tapJST.getUTCMonth() + 1) === (calendarDate.getMonth() + 1)) { 219 setCalendarHistory(prev => [...prev, ms]); 220 } 221 try { 222 const { data: { session: curS } } = await supabase.auth.getSession(); 223 if (curS) { 224 await fetch('/api/tap', { 225 method: 'POST', 226 headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${curS.access_token}` }, 227 body: JSON.stringify({ tapTime: now.toISOString() }) 228 }); 229 } 230 } finally { setIsSyncing(false); } 231 }; 232 233 const handleInvite = async (num: 1 | 2) => { 234 if (!session || isSyncing) return; 235 setIsSyncing(true); 236 const now = new Date(); now.setMilliseconds(0); 237 if (num === 1) setTicket1Time(now); else setTicket2Time(now); 238 try { 239 const { data: { session: curS } } = await supabase.auth.getSession(); 240 if (curS) { 241 await fetch('/api/tap', { 242 method: 'POST', 243 headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${curS.access_token}` }, 244 body: JSON.stringify({ [num === 1 ? 'ticket1Time' : 'ticket2Time']: now.toISOString() }) 245 }); 246 } 247 } finally { setIsSyncing(false); } 248 }; 249 250 if (isAuthChecking || (session && !isDataLoaded)) { 251 return <div className="flex justify-center items-center h-screen bg-background font-bold">Loading...</div>; 252 } 253 254 if (!session) { 255 return ( 256 <div className="flex flex-col items-center justify-center min-h-screen p-8 bg-background"> 257 <div className="timer-card text-center p-8 rounded-2xl shadow-lg max-w-sm w-full border border-muted"> 258 <h2 className="text-2xl font-bold mb-2">Welcome!</h2> 259 <p className="mb-8 text-muted-foreground text-sm">利用するにはログインしてください</p> 260 <button 261 onClick={() => supabase.auth.signInWithOAuth({ provider: 'discord', options: { redirectTo: window.location.origin } })} 262 className="w-full py-4 rounded-xl text-lg font-bold bg-[#5865F2] text-white shadow-md transition-all active:scale-95" 263 > 264 Discord Login 265 </button> 266 </div> 267 </div> 268 ); 269 } 270 271 const lastTapTime = timerHistory.length ? new Date(timerHistory[timerHistory.length-1]) : null; 272 273 return ( 274 <div className="bg-background h-screen flex flex-col"> 275 <OneSignalInit /> 276 <Header isLoggedIn={!!session} onMenuClick={() => setIsSidePanelOpen(true)} /> 277 <NewFeatureDialog /> 278 279 {overlayKey && <Overlay contentKey={overlayKey} onClose={() => setOverlayKey(null)} />} 280 281 <main className="flex-1 flex flex-col pt-16 pb-16"> 282 283 {/* スマホ表示 (幅1000px未満) */} 284 <div className="min-[1000px]:hidden flex-1 overflow-y-auto"> 285 {activeTab === 'timer' && ( 286 <TimerDashboard tapHistory={timerHistory} lastTapTime={lastTapTime} ticket1Time={ticket1Time} ticket2Time={ticket2Time} onTap={handleTap} onInvite={handleInvite} isSyncing={isSyncing} isDataLoaded={isDataLoaded} /> 287 )} 288 {activeTab === 'history' && ( 289 <div className="p-4"> 290 <HistoryCalendar tapHistory={calendarHistory} currentDate={calendarDate} onMonthChange={handleMonthChange} /> 291 </div> 292 )} 293 {activeTab === 'bond' && ( 294 <BondDashboard bondHistory={relationshipHistory} onSave={handleSaveRelationship} isSyncing={isSyncing} /> 295 )} 296 </div> 297 298 {/* PC表示 (幅1000px以上) */} 299 <div className="hidden min-[1000px]:flex flex-1 justify-center px-4 py-4 h-[calc(100vh-120px)] overflow-hidden"> 300 <div className="w-full h-full mx-auto flex items-stretch"> 301 302 {(activeTab === 'timer' || activeTab === 'history') && ( 303 <div className="grid grid-cols-2 gap-8 w-full h-full items-stretch animate-in fade-in duration-200"> 304 <TimerDashboard tapHistory={timerHistory} lastTapTime={lastTapTime} ticket1Time={ticket1Time} ticket2Time={ticket2Time} onTap={handleTap} onInvite={handleInvite} isSyncing={isSyncing} isDataLoaded={isDataLoaded} /> 305 <HistoryCalendar tapHistory={calendarHistory} currentDate={calendarDate} onMonthChange={handleMonthChange} /> 306 </div> 307 )} 308 309 {activeTab === 'bond' && ( 310 <div className="w-full h-full overflow-y-auto animate-in fade-in"> 311 <BondDashboard bondHistory={relationshipHistory} onSave={handleSaveRelationship} isSyncing={isSyncing} /> 312 </div> 313 )} 314 315 </div> 316 </div> 317 318 <BottomNavBar activeTab={activeTab} setActiveTab={setActiveTab} /> 319 320 <SidePanel isOpen={isSidePanelOpen} onClose={() => setIsSidePanelOpen(false)}> 321 <Settings onOpenContent={(key) => { setOverlayKey(key); setIsSidePanelOpen(false); }} /> 322 </SidePanel> 323 </main> 324 </div> 325 ); 326 }