ba-cafe

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

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 }