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 (
고촌고 기판 부팅 중...
시간표 불러오는 중...
}급식 조회 중...
}등록된 바로가기가 없습니다.
}다가오는 학급 일정
{a.content}
{a.image &&등록된 공지사항이 없습니다.
}이 달에 등록된 일정이 없습니다.
}{PERIODS[idx].start} ~ {PERIODS[idx].end}
{memos[ttDate+'_'+(idx+1)] &&💡 {memos[ttDate+'_'+(idx+1)]}
}급식 정보가 없습니다.
}오늘 할 일을 추가하세요.
}{s.date} · {dday(s.date)}
일정이 없습니다.
}공유 자료가 없습니다.
}{m.content}
자유롭게 의견이나 건의사항을 남겨보세요!
}고촌고등학교 1학년 8반
DATE
{selDayName} · {selDate === today ? '오늘' : dday(selDate)}
등록된 일정이 없습니다.
}급식 정보가 없습니다.
}{customDialog.message}