import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { initializeApp, getApps } from 'firebase/app'; import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from 'firebase/auth'; import { getFirestore, doc, setDoc, addDoc, updateDoc, deleteDoc, collection, onSnapshot } from 'firebase/firestore'; // ── NEIS 설정 ── const NEIS_URL = 'https://open.neis.go.kr/hub'; const ATPT_CODE = 'J10'; const SCHUL_CODE = '7531375'; const NEIS_KEY = '4b9aa5aa974e4963ab8714cfb9850e41'; const PERIODS = [ { label:'1교시', start:'08:30', end:'09:20' }, { label:'2교시', start:'09:30', end:'10:20' }, { label:'3교시', start:'10:30', end:'11:20' }, { label:'4교시', start:'11:30', end:'12:20' }, { label:'5교시', start:'13:10', end:'14:00' }, { label:'6교시', start:'14:10', end:'15:00' }, { label:'7교시', start:'15:10', end:'16:00' }, ]; // 타임아웃을 지원하는 안전한 fetch 래퍼 const fetchWithTimeout = async (url, options = {}, timeout = 10000) => { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...options, signal: controller.signal }); clearTimeout(id); return response; } catch (error) { clearTimeout(id); throw error; } }; async function fetchNEIS(endpoint, params) { const qs = Object.entries({ KEY: NEIS_KEY, Type: 'json', pIndex: '1', pSize: '100', ...params }) .map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&'); const url = `${NEIS_URL}/${endpoint}?${qs}`; const attempts = [ url, `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`, ]; for (const u of attempts) { try { const res = await fetchWithTimeout(u, {}, 10000); if (!res.ok) continue; const data = await res.json(); if (data) return data; } catch (e) { console.warn('NEIS fetch 실패, 다음 시도:', e.message); } } throw new Error('NEIS 연결 실패'); } function parseMealMenu(raw) { return raw.replace(/\([^)]*\)/g, '').replace(//gi, ',') .split(',').map(s => s.trim()).filter(Boolean).join(', '); } function getKSTDateStr(offset = 0) { const d = new Date(Date.now() + 9 * 3600000 + offset * 86400000); return `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,'0')}-${String(d.getUTCDate()).padStart(2,'0')}`; } function fmtDate(d) { return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function getCurrentPeriod() { const now = new Date(); const hhmm = now.getHours() * 60 + now.getMinutes(); for (let i = 0; i < PERIODS.length; i++) { const [sh, sm] = PERIODS[i].start.split(':').map(Number); const [eh, em] = PERIODS[i].end.split(':').map(Number); if (hhmm >= sh * 60 + sm && hhmm <= eh * 60 + em) return i; } return -1; } // ── 진동 피드백 ── function vibrate(type) { if (!navigator.vibrate) return; if (type === 'click') navigator.vibrate(20); if (type === 'success') navigator.vibrate([30,40,30]); if (type === 'delete') navigator.vibrate([80,50,80]); } // ── Neumorphic Icons ── const Icons = { Home: ({size=22}) => , Clock: ({size=22}) => , Meal: ({size=22}) => , Class: ({size=22}) => , Calendar: ({size=22}) => , Trash: () => , Plus: () => , Alert: () => , Refresh: () => , ChevL: () => , ChevR: () => , X: () => , }; // ── Firebase 연동 초기화 ── const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : { apiKey: "AIzaSyAP8WRXAjmPdmATfWKnCrCRQNH0ftbBV9w", authDomain: "duru108.firebaseapp.com", projectId: "duru108", storageBucket: "duru108.firebasestorage.app", messagingSenderId: "669255987956", appId: "1:669255987956:web:7479e73d084a2faafb24a1" }; const app = !getApps().length ? initializeApp(firebaseConfig) : getApps()[0]; const auth = getAuth(app); const db = getFirestore(app); const appId = typeof __app_id !== 'undefined' ? __app_id : 'duru-stable-v1'; // RULE 1: 지켜야 할 엄격한 수집 경로 정의 const getPubColRef = (collectionName) => collection(db, 'artifacts', appId, 'public', 'data', collectionName); const getPrivColRef = (userId, collectionName) => collection(db, 'artifacts', appId, 'users', userId, collectionName); export default function App() { const [user, setUser] = useState(null); const [activeTab, setActiveTab] = useState('home'); const [isLoading, setIsLoading] = useState(true); const [statusText, setStatusText] = useState('CONNECTING'); const [notiPerm, setNotiPerm] = useState('default'); const [isScrolled, setIsScrolled] = useState(false); const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768); const [announcements, setAnnouncements] = useState([]); const [schedules, setSchedules] = useState([]); const [materials, setMaterials] = useState([]); const [messages, setMessages] = useState([]); const [links, setLinks] = useState([]); const [planner, setPlanner] = useState([]); const [memos, setMemos] = useState({}); const [meals, setMeals] = useState({}); const [timetables, setTimetables] = useState({}); const [showModal, setShowModal] = useState(false); const [modalType, setModalType] = useState(''); const [modalTarget, setModalTarget] = useState(''); const [val1, setVal1] = useState(''); const [val2, setVal2] = useState(''); const [file, setFile] = useState(''); const [customDialog, setCustomDialog] = useState(null); const today = getKSTDateStr(); const [ttDate, setTtDate] = useState(today); const [mealDate, setMealDate] = useState(today); // ── 캘린더 상태 ── const initToday = new Date(); const [calYear, setCalYear] = useState(initToday.getFullYear()); const [calMonth, setCalMonth] = useState(initToday.getMonth()); // 0~11 const [selectedDate, setSelectedDate] = useState(null); const synced = useRef(new Set()); const [syncState, setSyncState] = useState({}); const [installPrompt, setInstallPrompt] = useState(null); const schedulesRef = useRef([]); const mealsRef = useRef({}); const memosRef = useRef({}); const timetablesRef = useRef({}); const firedRef = useRef({}); useEffect(() => { schedulesRef.current = schedules; }, [schedules]); useEffect(() => { mealsRef.current = meals; }, [meals]); useEffect(() => { memosRef.current = memos; }, [memos]); useEffect(() => { timetablesRef.current = timetables; }, [timetables]); // Google 폰트 강제 로드 및 화면 설정 useEffect(() => { const link = document.createElement('link'); link.href = "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700;900&display=swap"; link.rel = "stylesheet"; document.head.appendChild(link); const resizeHandler = () => setIsDesktop(window.innerWidth >= 768); const scrollHandler = () => setIsScrolled(window.scrollY > 40); window.addEventListener('resize', resizeHandler); window.addEventListener('scroll', scrollHandler); return () => { window.removeEventListener('resize', resizeHandler); window.removeEventListener('scroll', scrollHandler); }; }, []); const setTab = (tab) => { setActiveTab(tab); window.scrollTo({ top: 0, behavior: 'smooth' }); }; useEffect(() => { if ('Notification' in window) setNotiPerm(Notification.permission); const handler = (e) => { e.preventDefault(); setInstallPrompt(e); }; window.addEventListener('beforeinstallprompt', handler); return () => window.removeEventListener('beforeinstallprompt', handler); }, []); const fireNotif = (title, body) => { if (!('Notification' in window) || Notification.permission !== 'granted') return; const opts = { body, icon: './duru-icon-192.png', badge: './duru-icon-192.png' }; try { new Notification(title, opts); } catch (e) {} }; const NOTIF_SCHEDULE = [ { hhmm: '08:40', type: 'schedule' }, { hhmm: '09:25', type: 'memo', periodIdx: 1 }, { hhmm: '10:25', type: 'memo', periodIdx: 2 }, { hhmm: '11:25', type: 'memo', periodIdx: 3 }, { hhmm: '13:15', type: 'meal' }, { hhmm: '14:05', type: 'memo', periodIdx: 5 }, { hhmm: '15:05', type: 'memo', periodIdx: 6 }, ]; useEffect(() => { const tick = () => { if (!('Notification' in window) || Notification.permission !== 'granted') return; const todayKey = getKSTDateStr(); const now = new Date(); const hhmm = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; for (const n of NOTIF_SCHEDULE) { const key = `${todayKey}_${n.hhmm}`; if (hhmm !== n.hhmm || firedRef.current[key]) continue; firedRef.current[key] = true; if (n.type === 'schedule') { const list = schedulesRef.current.filter(s => s.date === todayKey); if (list.length) fireNotif('📅 오늘 학급 일정', list.map(s => s.title).join(', ')); } else if (n.type === 'meal') { const menu = mealsRef.current[todayKey]; if (menu) fireNotif('🍱 오늘 점심 급식', menu); } else if (n.type === 'memo') { const memo = memosRef.current[`${todayKey}_${n.periodIdx + 1}`]; if (memo) fireNotif(`📝 ${n.periodIdx + 1}교시 메모`, memo); } } }; const id = setInterval(tick, 30000); tick(); return () => clearInterval(id); }, []); // ── RULE 3: 선인증 실행 & UI 바인딩 ── useEffect(() => { const initAuth = async () => { try { if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) { await signInWithCustomToken(auth, __initial_auth_token); } else { await signInAnonymously(auth); } } catch (e) { console.error("Auth init error:", e); setStatusText('ERROR'); } finally { setIsLoading(false); } }; initAuth(); const unsubscribe = onAuthStateChanged(auth, (u) => { setUser(u); if (u) setStatusText('LIVE'); }); return () => unsubscribe(); }, []); // ── 실시간 데이터 구독 ── useEffect(() => { if (!user) return; // RULE 2: 클라이언트 측 정렬/필터링을 위해 단순 수집 후 클라이언트 연산 수행 const unsubAnnouncements = onSnapshot(getPubColRef('announcements'), (s) => { setAnnouncements(s.docs.map(d => ({ id: d.id, ...d.data() })).sort((a,b) => b.at - a.at)); }, (err) => console.error("Announcements error", err)); const unsubSchedules = onSnapshot(getPubColRef('schedules'), (s) => { setSchedules(s.docs.map(d => ({ id: d.id, ...d.data() })).sort((a,b) => new Date(a.date) - new Date(b.date))); }, (err) => console.error("Schedules error", err)); const unsubMaterials = onSnapshot(getPubColRef('materials'), (s) => { setMaterials(s.docs.map(d => ({ id: d.id, ...d.data() })).sort((a,b) => b.at - a.at)); }, (err) => console.error("Materials error", err)); const unsubMessages = onSnapshot(getPubColRef('messages'), (s) => { setMessages(s.docs.map(d => ({ id: d.id, ...d.data() })).sort((a,b) => b.at - a.at)); }, (err) => console.error("Messages error", err)); const unsubLinks = onSnapshot(getPubColRef('links'), (s) => { setLinks(s.docs.map(d => ({ id: d.id, ...d.data() })).sort((a,b) => a.at - b.at)); }, (err) => console.error("Links error", err)); const unsubMemos = onSnapshot(getPubColRef('memos'), (s) => { const t = {}; s.docs.forEach(d => t[d.id] = d.data().content); setMemos(t); }, (err) => console.error("Memos error", err)); const unsubMeals = onSnapshot(getPubColRef('meals'), (s) => { const t = {}; s.docs.forEach(d => t[d.id] = d.data().menu); setMeals(t); }, (err) => console.error("Meals error", err)); const unsubTimetables = onSnapshot(getPubColRef('timetables'), (s) => { const t = {}; s.docs.forEach(d => t[d.id] = d.data().periods); setTimetables(t); }, (err) => console.error("Timetables error", err)); const unsubPlanner = onSnapshot(getPrivColRef(user.uid, 'planner'), (s) => { setPlanner(s.docs.map(d => ({ id: d.id, ...d.data() })).sort((a,b) => b.at - a.at)); }, (err) => console.error("Planner error", err)); return () => { unsubAnnouncements(); unsubSchedules(); unsubMaterials(); unsubMessages(); unsubLinks(); unsubMemos(); unsubMeals(); unsubTimetables(); unsubPlanner(); }; }, [user]); const syncMeal = useCallback(async (dateStr) => { if (!user) return; if (synced.current.has('meal_'+dateStr)) return; synced.current.add('meal_'+dateStr); try { const data = await fetchNEIS('mealServiceDietInfo', { ATPT_OFCDC_SC_CODE: ATPT_CODE, SD_SCHUL_CODE: SCHUL_CODE, MMEAL_SC_CODE: '2', MLSV_YMD: dateStr.replace(/-/g,'') }); const rows = data?.mealServiceDietInfo?.[1]?.row; if (rows?.length) { const docRef = doc(db, 'artifacts', appId, 'public', 'data', 'meals', dateStr); await setDoc(docRef, { menu: parseMealMenu(rows[0].DDISH_NM), at: Date.now() }); } } catch(e) { console.error('급식 조회 실패:', e.message); } }, [user]); const syncTimetable = useCallback(async (dateStr) => { if (!user) return; if (synced.current.has('tt_'+dateStr)) return; synced.current.add('tt_'+dateStr); setSyncState(p => ({...p, [dateStr]: 'loading'})); try { const d = new Date(dateStr + 'T00:00:00'); const dow = d.getDay() || 7; const mon = new Date(d); mon.setDate(d.getDate() - dow + 1); const fri = new Date(d); fri.setDate(d.getDate() - dow + 5); const fmt = x => `${x.getFullYear()}${String(x.getMonth()+1).padStart(2,'0')}${String(x.getDate()).padStart(2,'0')}`; const data = await fetchNEIS('hisTimetable', { ATPT_OFCDC_SC_CODE: ATPT_CODE, SD_SCHUL_CODE: SCHUL_CODE, GRADE: '1', CLASS_NM: '8', TI_FROM_YMD: fmt(mon), TI_TO_YMD: fmt(fri) }); const rows = data?.hisTimetable?.[1]?.row; if (rows?.length) { const map = {}; rows.forEach(item => { const ymd = item.ALL_TI_YMD; const ds = `${ymd.slice(0,4)}-${ymd.slice(4,6)}-${ymd.slice(6,8)}`; const idx = parseInt(item.PERIO) - 1; const sub = item.ITRT_CNTNT.replace(/\d+$/, '').trim(); if (!map[ds]) map[ds] = Array(7).fill('-'); if (idx >= 0 && idx < 7) map[ds][idx] = sub; }); await Promise.all(Object.entries(map).map(([ds, periods]) => { const docRef = doc(db, 'artifacts', appId, 'public', 'data', 'timetables', ds); return setDoc(docRef, { periods, at: Date.now() }); })); setSyncState(p => ({...p, [dateStr]: 'done'})); } else { setSyncState(p => ({...p, [dateStr]: 'done'})); } } catch(e) { console.error('시간표 조회 실패:', e.message); setSyncState(p => ({...p, [dateStr]: 'error'})); } }, [user]); useEffect(() => { if (!user) return; syncMeal(mealDate); }, [mealDate, syncMeal, user]); useEffect(() => { if (!user) return; syncTimetable(ttDate); }, [ttDate, syncTimetable, user]); useEffect(() => { if (statusText !== 'LIVE') return; syncMeal(today); syncTimetable(today); }, [statusText, today, syncMeal, syncTimetable]); useEffect(() => { if (!selectedDate || !user) return; syncMeal(selectedDate); syncTimetable(selectedDate); }, [selectedDate, syncMeal, syncTimetable, user]); useEffect(() => { if (activeTab !== 'cal' || statusText !== 'LIVE') return; const last = new Date(calYear, calMonth + 1, 0).getDate(); for (let day = 1; day <= last; day++) { const d = new Date(calYear, calMonth, day); const dow = d.getDay(); if (dow === 0 || dow === 6) continue; const ds = fmtDate(d); syncMeal(ds); syncTimetable(ds); } }, [activeTab, calYear, calMonth, statusText, syncMeal, syncTimetable]); const forceResync = (dateStr) => { synced.current.delete('tt_'+dateStr); setSyncState(p => ({...p, [dateStr]: undefined})); syncTimetable(dateStr); vibrate('success'); }; const handleDelete = (col, id, isPrivate=false) => { vibrate('delete'); setCustomDialog({ type: 'confirm', message: '이 데이터를 영구히 삭제할까요?', onConfirm: async () => { try { const ref = isPrivate ? doc(db, 'artifacts', appId, 'users', user.uid, col, id) : doc(db, 'artifacts', appId, 'public', 'data', col, id); await deleteDoc(ref); } catch(e) { console.error("삭제 중 오류 발생:", e); } setCustomDialog(null); } }); }; const openModal = (type, target='', presetVal2='') => { vibrate('click'); setModalType(type); setModalTarget(target); if (type==='MEMO') setVal1(memos[target]||''); else if (type==='MEAL') setVal1(meals[target]||''); else if (type==='TIME') setVal1((timetables[target]||[]).join(', ')); else { setVal1(''); } setVal2(presetVal2); setFile(''); setShowModal(true); }; const submitModal = async () => { if (!user) return; vibrate('success'); try { if (modalType==='NOTICE') { await addDoc(getPubColRef('announcements'), {title:val1, content:val2, image:file, at:Date.now()}); } else if (modalType==='PLAN') { await addDoc(getPrivColRef(user.uid, 'planner'), {text:val1, done:false, date:today, at:Date.now()}); } else if (modalType==='SCHED') { await addDoc(getPubColRef('schedules'), {title:val1, date:val2, at:Date.now()}); } else if (modalType==='FILE') { await addDoc(getPubColRef('materials'), {title:val1, subject:val2, file, at:Date.now()}); } else if (modalType==='MEMO') { const docRef = doc(db, 'artifacts', appId, 'public', 'data', 'memos', modalTarget); await setDoc(docRef, {content:val1, at:Date.now()}); } else if (modalType==='MEAL') { const docRef = doc(db, 'artifacts', appId, 'public', 'data', 'meals', modalTarget); await setDoc(docRef, {menu:val1, at:Date.now()}); } else if (modalType==='TIME') { const docRef = doc(db, 'artifacts', appId, 'public', 'data', 'timetables', modalTarget); await setDoc(docRef, {periods:val1.split(',').map(s=>s.trim()), at:Date.now()}); } else if (modalType==='MSG') { if(!val2.trim()) return; await addDoc(getPubColRef('messages'), {author:val1.trim()||'익명', content:val2.trim(), at:Date.now()}); } else if (modalType==='LINK') { if(!val1.trim() || !val2.trim()) return; await addDoc(getPubColRef('links'), {title:val1.trim(), url:val2.trim(), at:Date.now()}); } } catch(e) { console.error("제출 중 오류 발생:", e); } setShowModal(false); }; const dday = (ds) => { const [y1, m1, d1] = ds.split('-').map(Number); const [y2, m2, d2] = today.split('-').map(Number); const targetDate = new Date(y1, m1 - 1, d1); const currentDate = new Date(y2, m2 - 1, d2); const diffDays = Math.round((targetDate - currentDate) / 86400000); return diffDays === 0 ? 'D-Day' : diffDays > 0 ? `D-${diffDays}` : `D+${Math.abs(diffDays)}`; }; const nextSchedule = useMemo(() => { return schedules.find(s => s.date >= today); }, [schedules, today]); const todayTt = timetables[today] || []; const curPeriod = getCurrentPeriod(); const calCells = useMemo(() => { const first = new Date(calYear, calMonth, 1); const startDow = first.getDay(); const last = new Date(calYear, calMonth + 1, 0).getDate(); const cells = []; for (let i = startDow - 1; i >= 0; i--) { const d = new Date(calYear, calMonth, -i); cells.push({ date: d, current: false }); } for (let i = 1; i <= last; i++) { cells.push({ date: new Date(calYear, calMonth, i), current: true }); } while (cells.length < 42) { const idx = cells.length - startDow - last + 1; cells.push({ date: new Date(calYear, calMonth + 1, idx), current: false }); } return cells; }, [calYear, calMonth]); const goPrevMonth = () => { vibrate('click'); if (calMonth === 0) { setCalYear(y => y - 1); setCalMonth(11); } else setCalMonth(m => m - 1); }; const goNextMonth = () => { vibrate('click'); if (calMonth === 11) { setCalYear(y => y + 1); setCalMonth(0); } else setCalMonth(m => m + 1); }; const goToday = () => { vibrate('click'); const d = new Date(); setCalYear(d.getFullYear()); setCalMonth(d.getMonth()); }; if (isLoading) return (