You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

447 lines
22 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* 아리 — 일정(Calendar) · 주/월/일 뷰 */
const { useState, useEffect, useRef } = React;
const C = window.CalData;
const CAL = Object.fromEntries(C.cals.map((c) => [c.id, c]));
const P = {
spark: "M12 3l1.9 5.2L19 10l-5.1 1.8L12 17l-1.9-5.2L5 10l5.1-1.8z|M19 4v3|M5 17v3",
grid: "M3 3h7v7H3z|M14 3h7v7h-7z|M14 14h7v7h-7z|M3 14h7v7H3z",
inbox: "M22 12h-6l-2 3h-4l-2-3H2|M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",
route: "M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M9 16h6a3 3 0 0 0 3-3V11",
cal: "M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
check: "M9 11l3 3L22 4|M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
tick: "M20 6 9 17l-5-5",
mail: "M22 7 13.03 12.7a1.94 1.94 0 0 1-2.06 0L2 7|M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
brain: "M12 5a3 3 0 1 0-5.9.8A3 3 0 0 0 4 9.5 3 3 0 0 0 6 14a3 3 0 0 0 6 1zM12 5a3 3 0 1 1 5.9.8A3 3 0 0 1 20 9.5 3 3 0 0 1 18 14a3 3 0 0 1-6 1z|M12 5v14",
heart: "M19 14c1.5-1.5 3-3.4 3-5.5A4.5 4.5 0 0 0 12 5.5 4.5 4.5 0 0 0 2 8.5c0 2.1 1.5 4 3 5.5l7 7z",
search: "M21 21l-4.34-4.34|M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z",
bell: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9|M10.3 21a1.94 1.94 0 0 0 3.4 0",
sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z|M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4",
moon: "M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z",
plus: "M12 5v14M5 12h14",
x: "M18 6 6 18M6 6l12 12",
prev: "M15 18l-6-6 6-6",
next: "M9 18l6-6-6-6",
clock: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|M12 7v5l3 2",
pin: "M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z|M12 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
users: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|M23 21v-2a4 4 0 0 0-3-3.87|M16 3.13a4 4 0 0 1 0 7.75",
edit: "M12 20h9|M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z",
trash: "M3 6h18|M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",
wand: "M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8 19 13M17.8 6.2 19 5M3 21l9-9M12.2 6.2 11 5",
zap: "M13 2 3 14h9l-1 8 10-12h-9z",
send: "M22 2 11 13|M22 2 15 22l-4-9-9-4z",
target: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z|M12 12h.01",
};
function I({ d, w }) {
return (
<svg className="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={w || 1.9} strokeLinecap="round" strokeLinejoin="round">
{P[d].split("|").map((s, i) => <path key={i} d={s} />)}
</svg>
);
}
const MAIN = [
{ id: "dash", label: "대시보드", icon: "grid", href: "대시보드.html" },
{ id: "sbox", label: "인박스", icon: "inbox", href: "인박스.html" },
{ id: "appr", label: "결재함", icon: "spark", badge: "3", href: "결재함.html" },
{ id: "auto", label: "자동화", icon: "zap", href: "자동화.html" },
{ id: "journey", label: "여정", icon: "route", href: "여정.html" },
{ id: "cal", label: "일정", icon: "cal", href: "일정.html" },
{ id: "task", label: "작업", icon: "check", badge: "4", href: "작업.html" },
{ id: "mail", label: "메일", icon: "mail", href: "메일.html" },
{ id: "noti", label: "알림", icon: "bell", badge: "6", href: "알림.html" },
{ id: "research", label: "리서치", icon: "brain", href: "리서치.html" },
{ id: "trip", label: "여행", icon: "send", href: "여행.html" },
{ id: "life", label: "라이프", icon: "heart", href: "라이프.html" },
{ id: "wrap", label: "하루 마감", icon: "moon", href: "하루 마감.html" },
];
const VIEWS = [{ id: "week", label: "주" }, { id: "month", label: "월" }, { id: "day", label: "일" }];
const HOURS = [];
for (let h = 7; h <= 22; h++) HOURS.push(h);
const HSTART = 7, PXH = 52;
const toMin = (t) => { const [h, m] = t.split(":").map(Number); return h * 60 + m; };
const fmt = (t) => { const [h, m] = t.split(":").map(Number); const ap = h < 12 ? "오전" : "오후"; const hh = h % 12 === 0 ? 12 : h % 12; return `${ap} ${hh}:${String(m).padStart(2, "0")}`; };
const topOf = (t) => ((toMin(t) - HSTART * 60) / 60) * PXH;
const load = (k, fb) => { try { const v = localStorage.getItem("ariC." + k); return v === null ? fb : JSON.parse(v); } catch (e) { return fb; } };
const save = (k, v) => { try { localStorage.setItem("ariC." + k, JSON.stringify(v)); } catch (e) {} };
/* 회의 도우미 진입 버튼 레이블 */
const meetLabel = (m) =>
m.oneOnOne ? "1:1 어시스턴트 브리핑"
: m.phase === "upcoming" ? "사전 브리핑 보기"
: m.phase === "live" ? "실시간 기록 보기"
: "회의 노트 · 액션 보기";
/* ---------- 미니 달력 ---------- */
function MiniCal({ sel, onSel, eventsByDay }) {
const first = new Date(2026, 5, 1), off = first.getDay();
const cells = [];
for (let i = 0; i < 42; i++) {
const d = new Date(2026, 5, 1 - off + i);
cells.push({ d, inMonth: d.getMonth() === 5, n: d.getDate() });
}
return (
<div className="minical">
<div className="mc-head">
<span className="mc-title">2026 6</span>
<span className="mc-nav"><button><I d="prev" /></button><button><I d="next" /></button></span>
</div>
<div className="mc-grid">
{C.weekdays.map((w) => <div key={w} className="mc-wd">{w}</div>)}
{cells.map((c, i) => (
<button key={i}
className={"mc-day" + (c.inMonth ? "" : " out") + (c.inMonth && c.n === C.today ? " today" : "") + (c.inMonth && c.n === sel ? " sel" : "")}
onClick={() => c.inMonth && onSel(c.n)}>
{c.n}
{c.inMonth && eventsByDay[c.n] && <span className="evdot" />}
</button>
))}
</div>
</div>
);
}
/* ---------- 이벤트 블록 (주/일) ---------- */
function EvBlock({ ev, onClick }) {
const top = topOf(ev.start), h = ((toMin(ev.end) - toMin(ev.start)) / 60) * PXH;
const short = h < 38;
return (
<div className={"ev tint-" + CAL[ev.cal].tone + (ev.soon ? " soon" : "") + (short ? " short" : "")}
style={{ top: top + "px", height: Math.max(h - 3, 20) + "px" }}
onClick={(e) => { e.stopPropagation(); onClick(ev, e); }}>
<div className="et">{ev.title}</div>
<div className="etm">{ev.start}{ev.end}</div>
</div>
);
}
/* ---------- 집중 블록 (아리가 빈 시간에 배치한 작업) ---------- */
function FocusBlock({ b }) {
const top = topOf(b.start), h = ((toMin(b.end) - toMin(b.start)) / 60) * PXH;
const short = h < 40;
return (
<div className={"fblock " + b.type + (short ? " short" : "")}
style={{ top: top + "px", height: Math.max(h - 3, 22) + "px" }}>
<div className="fb-k"><I d="spark" />{b.tag}</div>
<div className="fbt">{b.title}</div>
</div>
);
}
/* ---------- 주 뷰 ---------- */
function WeekView({ events, focusBlocks, onOpen }) {
const nowTop = topOf("11:10"); // 오늘 6/8 가상 현재시각
return (
<div className="cpanel">
<div className="wk-head">
<div className="wk-corner" />
{C.week.map((day, i) => (
<div key={day} className={"wk-dh" + (day === C.today ? " today" : "") + (i === 0 || i === 6 ? " wknd" : "")}>
<div className="wd">{C.weekdays[i]}</div>
<div className="dn">{day}</div>
</div>
))}
</div>
<div className="wk-body">
<div className="wk-times">
{HOURS.map((h) => <div key={h} className="wk-thour"><span>{h}:00</span></div>)}
</div>
{C.week.map((day, i) => {
const dayEvents = events.filter((e) => e.day === day);
const dayFocus = focusBlocks.filter((b) => b.day === day);
return (
<div key={day} className={"wk-col" + (i === 0 || i === 6 ? " wknd" : "")}>
{HOURS.map((h) => <div key={h} className="wk-hr" />)}
{day === C.today && <div className="wk-now" style={{ top: nowTop + "px" }} />}
{dayFocus.map((b) => <FocusBlock key={b.id} b={b} />)}
{dayEvents.map((ev) => <EvBlock key={ev.id} ev={ev} onClick={onOpen} />)}
</div>
);
})}
</div>
</div>
);
}
/* ---------- 월 뷰 ---------- */
function MonthView({ events, onOpen, onSelDay }) {
const first = new Date(2026, 5, 1), off = first.getDay();
const byDay = {};
events.forEach((e) => { (byDay[e.day] = byDay[e.day] || []).push(e); });
Object.values(byDay).forEach((a) => a.sort((x, y) => toMin(x.start) - toMin(y.start)));
const cells = [];
for (let i = 0; i < 42; i++) {
const d = new Date(2026, 5, 1 - off + i);
cells.push({ d, inMonth: d.getMonth() === 5, n: d.getDate() });
}
return (
<div className="cpanel">
<div className="mo-head">
{C.weekdays.map((w, i) => <div key={w} className={"mo-wd" + (i === 0 || i === 6 ? " wknd" : "")}>{w}</div>)}
</div>
<div className="mo-grid">
{cells.map((c, i) => {
const evs = c.inMonth ? (byDay[c.n] || []) : [];
return (
<div key={i} className={"mo-cell" + (c.inMonth ? "" : " out") + (c.inMonth && c.n === C.today ? " today" : "")}
onClick={() => c.inMonth && onSelDay(c.n)}>
<div className="mo-dn">{c.n}</div>
{evs.slice(0, 3).map((ev) => (
<div key={ev.id} className={"mo-ev tint-" + CAL[ev.cal].tone} onClick={(e) => { e.stopPropagation(); onOpen(ev, e); }}>
<span className="pdot" /><span className="mt">{ev.start}</span><span className="mn">{ev.title}</span>
</div>
))}
{evs.length > 3 && <div className="mo-more">+{evs.length - 3} </div>}
</div>
);
})}
</div>
</div>
);
}
/* ---------- 일 뷰 ---------- */
function DayView({ events, focusBlocks, day, onOpen, onMeet }) {
const dayEvents = events.filter((e) => e.day === day).sort((a, b) => toMin(a.start) - toMin(b.start));
const dayFocus = focusBlocks.filter((b) => b.day === day).sort((a, b) => toMin(a.start) - toMin(b.start));
const idx = C.week.indexOf(day);
const wd = idx >= 0 ? C.weekdays[idx] : C.weekdays[new Date(2026, 5, day).getDay()];
const nowTop = topOf("11:10");
return (
<div className="day-wrap">
<div className="cpanel">
<div className="day-grid">
<div className="wk-times">{HOURS.map((h) => <div key={h} className="wk-thour"><span>{h}:00</span></div>)}</div>
<div className="day-col">
{HOURS.map((h) => <div key={h} className="wk-hr" />)}
{day === C.today && <div className="wk-now" style={{ top: nowTop + "px" }} />}
{dayFocus.map((b) => <FocusBlock key={b.id} b={b} />)}
{dayEvents.map((ev) => <EvBlock key={ev.id} ev={ev} onClick={onOpen} />)}
</div>
</div>
</div>
<div className="day-side">
{dayFocus.length > 0 && (
<div className="cpanel focus-card">
<div className="fc-head">
<div className="fc-ic"><I d="spark" /></div>
<div className="fc-h"><b>오늘의 집중 모드</b><span> </span></div>
</div>
<p className="fc-note">오전 미팅 사이엔 가벼운 일을, 오후 블록엔 워크를 넣었어요.</p>
{dayFocus.map((b) => (
<div className={"fc-item " + b.type} key={b.id}>
<div className="fci-time">{b.start}</div>
<div className="fci-body">
<div className="fci-top"><span className={"fci-tag " + b.type}>{b.tag}</span></div>
<div className="fci-title">{b.title}</div>
</div>
</div>
))}
</div>
)}
<div className="cpanel agenda">
<div className="ag-head">
<div className="ag-title">6 {day} {wd}요일</div>
<div className="ag-sub">일정 {dayEvents.length}{day === C.today ? " · 오늘" : ""}</div>
</div>
{dayEvents.length === 0 && <div className="ag-empty"> 날은 일정이 없어요.</div>}
{dayEvents.map((ev) => (
<div key={ev.id} className={"ag-item tint-" + CAL[ev.cal].tone} onClick={(e) => onOpen(ev, e)}>
<div className="ag-time">{ev.start}<small>{ev.end}</small></div>
<div className="ag-body">
<div className="ag-t">{ev.title} {ev.soon && <span className="ag-soon"></span>}</div>
<div className="ag-m">
<span className="epop-cat"><span className="pdot" style={{ width: 7, height: 7, borderRadius: "50%", background: "var(--evc)", display: "inline-block" }} /> {CAL[ev.cal].name}</span>
{ev.loc && <><span>·</span><span><I d="pin" /> {ev.loc}</span></>}
</div>
{ev.people && (
<div className="ag-people">
{ev.people.slice(0, 3).map((p, i) => <div className="pp" key={i} style={{ background: ["var(--coral)", "var(--blue)", "var(--violet)"][i] }}>{p[0]}</div>)}
<span className="pl">{ev.people.join(", ")}</span>
</div>
)}
{ev.note && <div className="ag-note">{ev.note}</div>}
{ev.actions && (
<div className="ag-actions">
<div className="aga-head"><I d="spark" />내가 맡기로 </div>
{ev.actions.map((a, i) => (
<div className="aga-row" key={i}><span className="aga-check"><I d="tick" w="3" /></span><span className="aga-text">{a.text}</span><span className="aga-who">{a.when}</span></div>
))}
</div>
)}
{C.meets[ev.id] && (
<button className="meet-open" onClick={(e) => { e.stopPropagation(); onMeet(ev); }}>
<I d="spark" />{meetLabel(C.meets[ev.id])}
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
/* ---------- 이벤트 팝오버 ---------- */
function EvPopover({ ev, pos, onClose, onMeet }) {
const cat = CAL[ev.cal];
const style = { tint: { "--evc": `var(--${cat.tone})` } };
let left = pos.x, top = pos.y;
if (left + 300 > window.innerWidth - 12) left = window.innerWidth - 312;
if (top + 280 > window.innerHeight - 12) top = window.innerHeight - 292;
return (
<>
<div className="dp-backdrop" onClick={onClose} />
<div className={"epop tint-" + cat.tone} style={{ left: left + "px", top: top + "px" }}>
<div className="epop-bar" />
<div className="epop-title">{ev.title}</div>
<div className="epop-row"><I d="clock" /> {fmt(ev.start)} {fmt(ev.end)}</div>
<div className="epop-row"><I d="cal" /> <span className="epop-cat"><span className="pdot" /> {cat.name}</span></div>
{ev.loc && <div className="epop-row"><I d="pin" /> {ev.loc}</div>}
{ev.people && <div className="epop-row"><I d="users" /> {ev.people.join(", ")}</div>}
{ev.note && <div className="epop-note">{ev.note}</div>}
{ev.actions && (
<div className="epop-actions">
<div className="epa-head"><I d="spark" />내가 맡기로 <span className="n">{ev.actions.length}</span></div>
{ev.actions.map((a, i) => (
<div className="epa-row" key={i}>
<span className="epa-check"><I d="tick" w="3" /></span>
<span className="epa-text">{a.text}</span>
<span className="epa-who">{a.when}</span>
</div>
))}
<button className="epa-add"><I d="check" /> 작업에 추가</button>
</div>
)}
{C.meets[ev.id] && (
<button className="meet-open" onClick={() => onMeet(ev)}>
<I d="spark" />{meetLabel(C.meets[ev.id])}
</button>
)}
<div className="epop-acts">
<button className="edit"><I d="edit" /> 편집</button>
<button className="del" aria-label="삭제"><I d="trash" /></button>
</div>
</div>
</>
);
}
/* ---------- 앱 ---------- */
function App() {
const [theme, setTheme] = useState(() => load("theme", "light"));
const [view, setView] = useState(() => load("view", "week"));
const [selDay, setSelDay] = useState(() => load("selDay", C.today));
const [calOn, setCalOn] = useState(() => load("calOn", Object.fromEntries(C.cals.map((c) => [c.id, true]))));
const [focusOn, setFocusOn] = useState(() => load("focusOn", true));
const [pop, setPop] = useState(null);
const [meet, setMeet] = useState(null);
const shellRef = useRef(null);
useEffect(() => { document.documentElement.setAttribute("data-theme", theme); save("theme", theme); }, [theme]);
useEffect(() => save("view", view), [view]);
useEffect(() => save("selDay", selDay), [selDay]);
useEffect(() => save("calOn", calOn), [calOn]);
useEffect(() => save("focusOn", focusOn), [focusOn]);
useEffect(() => { const el = shellRef.current; const id = requestAnimationFrame(() => el && el.classList.add("entered")); return () => cancelAnimationFrame(id); }, []);
const events = C.events.filter((e) => calOn[e.cal]);
const focusBlocks = focusOn ? C.focusBlocks : [];
const eventsByDay = {};
events.forEach((e) => { eventsByDay[e.day] = (eventsByDay[e.day] || 0) + 1; });
const countByCal = {};
C.cals.forEach((c) => { countByCal[c.id] = C.events.filter((e) => e.cal === c.id).length; });
const openEv = (ev, e) => { setPop({ ev, pos: { x: e.clientX + 8, y: e.clientY + 8 } }); };
const openMeet = (ev) => { setPop(null); setMeet(ev); };
const toggleCal = (id) => setCalOn((s) => ({ ...s, [id]: !s[id] }));
const goToday = () => { setSelDay(C.today); };
const title = view === "month" ? "2026년 6월"
: view === "day" ? `6월 ${selDay}`
: "6월 7일 13일";
return (
<div className="tpage" ref={shellRef}>
<header className="topbar">
<div className="brand">
<div className="brand-mark"><I d="spark" /></div>
<div className="brand-tag"><b>아리</b><span>AI LIFE OS</span></div>
</div>
<nav className="mainnav">
{MAIN.map((m) => (
<a key={m.id} href={m.href} className={m.id === "cal" ? "active" : ""}>
<I d={m.icon} />{m.label}{m.badge && <span className="badge">{m.badge}</span>}
</a>
))}
</nav>
<div className="top-actions">
<button className="t-btn" aria-label="검색"><I d="search" /></button>
<button className="t-btn dot" aria-label="알림"><I d="bell" /></button>
<button className="t-btn" aria-label="테마" onClick={() => setTheme(theme === "light" ? "dark" : "light")}><I d={theme === "light" ? "moon" : "sun"} /></button>
<div className="t-ava"></div>
</div>
</header>
<div className="twork">
<aside className="subnav">
<div className="sn-title">일정</div>
<button className="sn-new"><I d="plus" /> 일정</button>
<MiniCal sel={selDay} onSel={(n) => { setSelDay(n); if (view !== "day") setView("day"); }} eventsByDay={eventsByDay} />
<div className="sn-label"> 캘린더</div>
<div className="cal-list">
{C.cals.map((c) => (
<button key={c.id} className={"cal-toggle" + (calOn[c.id] ? "" : " off")} onClick={() => toggleCal(c.id)}>
<span className="cbx" style={{ background: `var(--${c.tone})` }}><I d="tick" w="3" /></span>
<span>{c.name}</span>
<span className="ct-n">{countByCal[c.id]}</span>
</button>
))}
</div>
<div className="sn-foot">
<div className="av"></div>
<div className="txt"><b>지우님</b><span>Pro </span></div>
</div>
</aside>
<main className="cmain">
<div className="chead">
<button className="ch-arrow" aria-label="이전"><I d="prev" /></button>
<button className="ch-arrow" aria-label="다음"><I d="next" /></button>
<h1 className="ch-title">{title}</h1>
<button className="ch-today" onClick={goToday}>오늘</button>
<span className="ch-spacer" />
<button className={"ch-focus" + (focusOn ? " on" : "")} onClick={() => setFocusOn((v) => !v)}>
<I d="spark" />집중 블록
</button>
<div className="view-seg">
{VIEWS.map((v) => (
<button key={v.id} className={view === v.id ? "on" : ""} onClick={() => setView(v.id)}>{v.label}</button>
))}
</div>
</div>
<div className="ai-strip">
<div className="ai-ic"><I d="spark" /></div>
<p>오늘 일정 <b>14:00 분기 전략 미팅</b> . , <b> 15:10 </b> . 16:00 1:1 15 .</p>
</div>
{view === "week" && <WeekView events={events} focusBlocks={focusBlocks} onOpen={openEv} />}
{view === "month" && <MonthView events={events} onOpen={openEv} onSelDay={(n) => { setSelDay(n); setView("day"); }} />}
{view === "day" && <DayView events={events} focusBlocks={focusBlocks} day={selDay} onOpen={openEv} onMeet={openMeet} />}
</main>
</div>
{pop && <EvPopover ev={pop.ev} pos={pop.pos} onClose={() => setPop(null)} onMeet={openMeet} />}
{meet && C.meets[meet.id] && (
<window.CalMeet.MeetDrawer ev={meet} meet={C.meets[meet.id]} calName={CAL[meet.cal].name} tone={CAL[meet.cal].tone} onClose={() => setMeet(null)} />
)}
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);