diff --git a/apps/classbot/src/api/client.js b/apps/classbot/src/api/client.js
index 7325d9e..f93ad03 100644
--- a/apps/classbot/src/api/client.js
+++ b/apps/classbot/src/api/client.js
@@ -26,6 +26,7 @@ function readLocal() {
}
let localState = readLocal();
+if (!Array.isArray(localState.files)) localState.files = [];
function persistLocal() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(localState)); } catch { /* private mode */ }
@@ -37,11 +38,12 @@ function nextId(prefix) {
async function request(path, options = {}) {
const { body, headers, root = false, ...rest } = options;
+ const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
const response = await fetch(root ? path : resolveApiPath(path), {
credentials: "same-origin",
...rest,
- headers: body ? { "Content-Type": "application/json", ...headers } : headers,
- body: body && typeof body !== "string" ? JSON.stringify(body) : body,
+ headers: body && !isFormData ? { "Content-Type": "application/json", ...headers } : headers,
+ body: body && typeof body !== "string" && !isFormData ? JSON.stringify(body) : body,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
@@ -111,6 +113,13 @@ export const api = {
createMember(input, options) { return remoteOrLocal(() => request("/api/admin/members", { method: "POST", headers: idempotencyHeaders("member", input, options), body: input }), () => createItem("members", "member", { status: "invited", notification_enabled: true, daily_digest_enabled: true, ...input })); },
updateMember(id, patch) { return remoteOrLocal(() => request(`/api/admin/members/${id}`, { method: "PATCH", body: patch }), () => updateItem("members", id, patch)); },
inviteMember(id, options = {}) { return remoteOrLocal(() => request(`/api/admin/members/${id}/invite`, { method: "POST", headers: { "Idempotency-Key": options.idempotencyKey || createIdempotencyKey("invite") } }), () => ({ invite_url: `${location.origin}/join/demo-${id.slice(-4)}`, expires_at: new Date(Date.now() + 7 * 86_400_000).toISOString() })); },
+ memberTimetable(id, { weekday, date } = {}) {
+ const query = new URLSearchParams();
+ if (weekday != null) query.set("weekday", String(weekday));
+ if (date) query.set("date", date);
+ return request(`/api/admin/members/${id}/timetable${query.size ? `?${query.toString()}` : ""}`);
+ },
+ saveMemberTimetable(id, rows) { return request(`/api/admin/members/${id}/timetable`, { method: "PUT", body: { rows } }); },
timetable() { return remoteOrLocal(() => request("/api/admin/timetable"), () => ({ items: structuredClone(localState.timetable) })); },
saveTimetable(weekday, items) {
return remoteOrLocal(async () => {
@@ -131,6 +140,64 @@ export const api = {
updateNotice(id, patch) { return remoteOrLocal(() => request(`/api/admin/notices/${id}`, { method: "PATCH", body: patch }), () => updateItem("notices", id, patch)); },
deleteNotice(id) { return remoteOrLocal(() => request(`/api/admin/notices/${id}`, { method: "DELETE" }), () => { localState.notices = localState.notices.filter((item) => item.id !== id); persistLocal(); return { ok: true }; }); },
sendNotice(id, options = {}) { return remoteOrLocal(() => request(`/api/admin/notices/${id}/send`, { method: "POST", headers: { "Idempotency-Key": options.idempotencyKey || createIdempotencyKey("notice-send") } }), () => updateItem("notices", id, { status: "published", published_at: new Date().toISOString() })); },
+ files() { return remoteOrLocal(() => request("/api/admin/files"), () => ({ items: structuredClone(localState.files) })); },
+ uploadFile(input, options) {
+ const body = new FormData();
+ body.append("file", input.file);
+ body.append("alias", input.alias);
+ body.append("description", input.description || "");
+ body.append("member_id", input.member_id || "");
+ body.append("visibility", input.member_id ? "private" : "class");
+ return remoteOrLocal(
+ () => request("/api/admin/files", { method: "POST", headers: idempotencyHeaders("file", input, options), body }),
+ () => {
+ const now = new Date().toISOString();
+ const item = {
+ id: nextId("file"), alias: input.alias, description: input.description || "", member_id: input.member_id || null,
+ filename: input.file.name, original_name: input.file.name, mime_type: input.file.type || "application/octet-stream",
+ size_bytes: input.file.size, visibility: input.member_id ? "private" : "class",
+ download_url: URL.createObjectURL(input.file), created_at: now, updated_at: now,
+ };
+ localState.files.unshift(item);
+ persistLocal();
+ return { item: structuredClone(item) };
+ },
+ );
+ },
+ deleteFile(id) { return remoteOrLocal(() => request(`/api/admin/files/${id}`, { method: "DELETE" }), () => { localState.files = localState.files.filter((item) => item.id !== id); persistLocal(); return { ok: true }; }); },
+ fileDownloadUrl(item) {
+ const value = item?.admin_download_url || item?.download_url || item?.file_url;
+ if (value) return new URL(value, location.href).href;
+ return new URL(resolveApiPath(`/api/admin/files/${item.id}/download`), location.href).href;
+ },
+ fileShareUrl(item) {
+ const value = item?.share_url || item?.public_url || item?.url;
+ if (value) return new URL(value, location.href).href;
+ const token = item?.share_token || item?.public_token || item?.token;
+ if (token) return new URL(resolveApiPath(`/api/files/${encodeURIComponent(token)}`), location.href).href;
+ return this.fileDownloadUrl(item);
+ },
+ portalSession() { return request("/api/portal/session"); },
+ portalLogin({ display_name, invite_code }) { return request("/api/portal/login", { method: "POST", body: { display_name, invite_code } }); },
+ portalLogout() { return request("/api/portal/logout", { method: "POST" }); },
+ portalOverview(from, to) {
+ const query = new URLSearchParams({ from, to });
+ return request(`/api/portal/overview?${query.toString()}`);
+ },
+ portalFiles() { return request("/api/portal/files"); },
+ portalFileUrl(value) {
+ if (!value) return "";
+ const url = new URL(value, location.href);
+ if (url.origin === location.origin && url.pathname.startsWith("/api/")) {
+ const base = new URL(API_PREFIX, location.href);
+ url.protocol = base.protocol; url.host = base.host;
+ url.pathname = `${base.pathname}${url.pathname.slice(4)}`;
+ }
+ return url.href;
+ },
+ portalCreateEvent(input, options) { return request("/api/portal/events", { method: "POST", headers: idempotencyHeaders("portal-event", input, options), body: input }); },
+ portalUpdateEvent(id, patch) { return request(`/api/portal/events/${id}`, { method: "PATCH", body: patch }); },
+ portalDeleteEvent(id) { return request(`/api/portal/events/${id}`, { method: "DELETE" }); },
notifications() { return remoteOrLocal(() => request("/api/admin/notifications"), () => ({ items: structuredClone(localState.notifications) })); },
testNotification(options = {}) { return remoteOrLocal(() => request("/api/admin/notifications/test", { method: "POST", headers: { "Idempotency-Key": options.idempotencyKey || createIdempotencyKey("notification-test") } }), () => createItem("notifications", "notification", { kind: "test", status: "reserved", scheduled_for: new Date().toISOString(), payload: { title: "테스트 알림" } })); },
};
diff --git a/apps/classbot/src/components/AppShell.jsx b/apps/classbot/src/components/AppShell.jsx
index 4adc96b..e73cbbf 100644
--- a/apps/classbot/src/components/AppShell.jsx
+++ b/apps/classbot/src/components/AppShell.jsx
@@ -1,6 +1,6 @@
import {
Bell, CalendarDays, ChevronDown, ClipboardList, Clock3,
- LayoutDashboard, Megaphone, Plus, Settings, Users, X,
+ FolderOpen, LayoutDashboard, Megaphone, Plus, Settings, Users, X,
} from "lucide-react";
const navItems = [
@@ -8,13 +8,14 @@ const navItems = [
["events", "일정", CalendarDays],
["timetable", "시간표", ClipboardList],
["notices", "반 공지", Megaphone],
+ ["files", "자료실", FolderOpen],
["members", "구성원", Users],
["notifications", "알림 기록", Bell],
["settings", "설정", Settings],
];
export function Brand() {
- return
Quilo;
+ return
Quilo schedule;
}
export function Sidebar({ active, onNavigate, classroom, memberCount }) {
diff --git a/apps/classbot/src/pages/FilesPage.jsx b/apps/classbot/src/pages/FilesPage.jsx
new file mode 100644
index 0000000..5c286c9
--- /dev/null
+++ b/apps/classbot/src/pages/FilesPage.jsx
@@ -0,0 +1,113 @@
+import { useMemo, useRef, useState } from "react";
+import {
+ Copy, ExternalLink, FileImage, FileText, FolderOpen, LoaderCircle,
+ LockKeyhole, Trash2, Upload, Users, X,
+} from "lucide-react";
+import { dateLabel } from "../lib/format.js";
+
+const ACCEPTED_TYPES = "application/pdf,image/jpeg,image/png,image/webp,image/gif";
+const ACCEPTED_EXTENSIONS = /\.(pdf|png|jpe?g|webp|gif)$/i;
+
+function isAcceptedFile(file) {
+ return ["application/pdf", "image/jpeg", "image/png", "image/webp", "image/gif"].includes(file?.type) || ACCEPTED_EXTENSIONS.test(file?.name || "");
+}
+
+function originalName(item) {
+ return item.original_name || item.original_filename || item.file_name || item.filename || "파일";
+}
+
+function fileMime(item) {
+ return item.mime_type || item.content_type || "";
+}
+
+function fileSize(item) {
+ const value = Number(item.size_bytes ?? item.size ?? item.file_size ?? item.byte_size);
+ if (!Number.isFinite(value) || value < 0) return "용량 정보 없음";
+ if (value < 1024) return `${value} B`;
+ if (value < 1024 ** 2) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
+ return `${(value / 1024 ** 2).toFixed(value < 10 * 1024 ** 2 ? 1 : 0)} MB`;
+}
+
+export default function FilesPage({ files, members, busy, loading, error, onUpload, onDelete, onCopy, onRefresh, downloadUrl }) {
+ const inputRef = useRef(null);
+ const [file, setFile] = useState(null);
+ const [alias, setAlias] = useState("");
+ const [description, setDescription] = useState("");
+ const [memberId, setMemberId] = useState("");
+ const [fileError, setFileError] = useState("");
+ const targetOptions = useMemo(() => members.filter((member) => member.status !== "left"), [members]);
+ const memberNames = useMemo(() => new Map(members.map((member) => [member.id, member.display_name])), [members]);
+ const ordered = useMemo(() => [...files].sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)), [files]);
+
+ const chooseFile = (nextFile) => {
+ if (!nextFile) return;
+ if (!isAcceptedFile(nextFile)) {
+ setFileError("PDF, JPEG, PNG, WebP 또는 GIF 파일만 업로드할 수 있습니다.");
+ return;
+ }
+ setFile(nextFile);
+ setFileError("");
+ if (!alias.trim()) setAlias(nextFile.name.replace(/\.[^.]+$/, ""));
+ };
+
+ const reset = () => {
+ setFile(null); setAlias(""); setDescription(""); setMemberId(""); setFileError("");
+ if (inputRef.current) inputRef.current.value = "";
+ };
+
+ const submit = async (event) => {
+ event.preventDefault();
+ if (!file) { setFileError("업로드할 파일을 선택해 주세요."); return; }
+ if (!alias.trim() || busy) return;
+ const saved = await onUpload({ file, alias: alias.trim(), description: description.trim(), member_id: memberId || null });
+ if (saved !== false) reset();
+ };
+
+ return (
+
+
자료실
PDF와 이미지를 반 전체 또는 선택한 구성원에게 안전하게 공유합니다.
+
+
+
+
+ 공유 자료
{ordered.length}개
+ {loading && 자료 목록을 불러오는 중
}
+ {!loading && error && 자료 목록을 불러오지 못했습니다.{error}
}
+ {!loading && !error && ordered.map((item) => {
+ const isImage = fileMime(item).startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(originalName(item));
+ const targetName = item.member_id ? memberNames.get(item.member_id) || "구성원 정보 없음" : "반 전체";
+ const targetClass = item.member_id ? "private" : "classwide";
+ return
+ {isImage ? : }
+ {item.alias || item.title || originalName(item)}{originalName(item)} · {fileSize(item)}{item.description && {item.description}
}
+ {item.member_id ? : }{targetName}
+ {!item.member_id && }
+ ;
+ })}
+ {!loading && !error && !ordered.length && 공유된 자료가 없습니다.PDF나 이미지를 올려 학급 자료실을 시작해 보세요.
}
+
+
+ );
+}
diff --git a/apps/classbot/src/portal/PortalCalendar.jsx b/apps/classbot/src/portal/PortalCalendar.jsx
new file mode 100644
index 0000000..e11c0be
--- /dev/null
+++ b/apps/classbot/src/portal/PortalCalendar.jsx
@@ -0,0 +1,154 @@
+import {
+ AlertTriangle, CalendarClock, Check, ChevronLeft, ChevronRight, Clock3, Megaphone, Plus, RotateCw, X,
+} from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { api, createIdempotencyKey } from "../api/client.js";
+import { categoryLabels, fromLocalInput, toLocalInput } from "../lib/format.js";
+
+const weekLabels = ["일", "월", "화", "수", "목", "금", "토"];
+const categoryTone = { assessment: "assessment", assignment: "assignment", class: "class", schedule_change: "class" };
+
+function dateKey(date) {
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
+}
+
+function addDays(date, amount) {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);
+}
+
+function startOfWeek(date) {
+ return addDays(date, -date.getDay());
+}
+
+function rangeFor(view, cursor) {
+ if (view === "day") return { start: cursor, end: cursor, days: [cursor] };
+ const start = view === "week" ? startOfWeek(cursor) : startOfWeek(new Date(cursor.getFullYear(), cursor.getMonth(), 1));
+ const count = view === "week" ? 7 : 42;
+ const days = Array.from({ length: count }, (_, index) => addDays(start, index));
+ return { start, end: days[days.length - 1], days };
+}
+
+function shiftCursor(cursor, view, direction) {
+ if (view === "month") return new Date(cursor.getFullYear(), cursor.getMonth() + direction, 1);
+ return addDays(cursor, direction * (view === "week" ? 7 : 1));
+}
+
+function titleFor(view, cursor, range) {
+ if (view === "month") return `${cursor.getFullYear()}년 ${cursor.getMonth() + 1}월`;
+ if (view === "day") return new Intl.DateTimeFormat("ko-KR", { month: "long", day: "numeric", weekday: "long" }).format(cursor);
+ const start = new Intl.DateTimeFormat("ko-KR", { month: "long", day: "numeric" }).format(range.start);
+ const end = new Intl.DateTimeFormat("ko-KR", { month: "long", day: "numeric" }).format(range.end);
+ return `${start} – ${end}`;
+}
+
+function eventTime(event) {
+ const value = event.due_at || event.starts_at;
+ if (!value) return "";
+ return new Intl.DateTimeFormat("ko-KR", { hour: "numeric", minute: "2-digit" }).format(new Date(value));
+}
+
+function EventChip({ event, compact = false }) {
+ const tone = categoryTone[event.category] || "neutral";
+ return
{!compact && eventTime(event) && }{event.title};
+}
+
+function eventFormFor(date) {
+ const due = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 17, 0, 0, 0);
+ return { title: "", category: "assignment", subject: "", description: "", due_at: toLocalInput(due), scope: "self", request_key: createIdempotencyKey("portal-event") };
+}
+
+function PortalEventModal({ open, date, isAdmin, saving, error, onClose, onSave }) {
+ const [form, setForm] = useState(() => eventFormFor(date));
+ useEffect(() => { if (open) setForm(eventFormFor(date)); }, [open, date]);
+ if (!open) return null;
+ const set = (key, value) => setForm((current) => ({ ...current, [key]: value }));
+ return
;
+}
+
+export default function PortalCalendar({ onOverview }) {
+ const [view, setView] = useState("month");
+ const [cursor, setCursor] = useState(() => new Date());
+ const [data, setData] = useState({ member: null, classroom: null, timetable: [], events: [], notices: [] });
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [reloadKey, setReloadKey] = useState(0);
+ const [eventModalOpen, setEventModalOpen] = useState(false);
+ const [eventSaving, setEventSaving] = useState(false);
+ const [eventError, setEventError] = useState("");
+ const range = useMemo(() => rangeFor(view, cursor), [view, cursor]);
+ const from = dateKey(range.start);
+ const to = dateKey(range.end);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true); setError("");
+ api.portalOverview(from, to).then((result) => {
+ if (cancelled) return;
+ const next = { member: result.member || null, classroom: result.classroom || null, timetable: result.timetable || [], events: (result.events || []).filter((event) => event.status !== "cancelled"), notices: result.notices || [] };
+ setData(next); onOverview?.(next);
+ }).catch((err) => { if (!cancelled) setError(err.message || "일정을 불러오지 못했습니다."); }).finally(() => { if (!cancelled) setLoading(false); });
+ return () => { cancelled = true; };
+ }, [from, to, onOverview, reloadKey]);
+
+ const eventsByDay = useMemo(() => {
+ const grouped = new Map();
+ data.events.forEach((event) => {
+ const value = event.due_at || event.starts_at;
+ if (!value) return;
+ const key = dateKey(new Date(value));
+ grouped.set(key, [...(grouped.get(key) || []), event]);
+ });
+ grouped.forEach((events) => events.sort((a, b) => new Date(a.due_at || a.starts_at) - new Date(b.due_at || b.starts_at)));
+ return grouped;
+ }, [data.events]);
+
+ const todayKey = dateKey(new Date());
+ const latestNotice = [...data.notices].sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.published_at || b.created_at || 0) - new Date(a.published_at || a.created_at || 0))[0];
+ const selectedEvents = eventsByDay.get(dateKey(cursor)) || [];
+ const weekday = cursor.getDay();
+ const selectedTimetable = data.timetable.filter((item) => Number(item.weekday) === weekday).sort((a, b) => Number(a.period) - Number(b.period));
+ const saveEvent = async (input) => {
+ setEventSaving(true); setEventError("");
+ try { await api.portalCreateEvent(input); setEventModalOpen(false); setReloadKey((value) => value + 1); }
+ catch (err) { setEventError(err.message || "일정을 저장하지 못했습니다."); }
+ finally { setEventSaving(false); }
+ };
+
+ return (
+
+
+
+
{titleFor(view, cursor, range)}
+
+
+
+ {latestNotice && {latestNotice.title}{latestNotice.body}
}
+ {loading && 내 일정을 불러오는 중
}
+ {!loading && error && }
+
+ {!loading && !error && view === "month" && {weekLabels.map((label) => {label})}
{range.days.map((day) => {
+ const key = dateKey(day); const dayEvents = eventsByDay.get(key) || []; const outside = day.getMonth() !== cursor.getMonth();
+ return ;
+ })}
}
+
+ {!loading && !error && view === "week" && {range.days.map((day) => {
+ const key = dateKey(day); const dayEvents = eventsByDay.get(key) || [];
+ return
{dayEvents.map((event) => )}{!dayEvents.length && 일정 없음}
;
+ })}
}
+
+ {!loading && !error && view === "day" &&
+
오늘 시간표
{selectedTimetable.length}교시{selectedTimetable.map((item) =>
{item.period}교시{item.subject}{item.activity || item.memo || "수업"}
)}{!selectedTimetable.length &&
{weekday === 0 || weekday === 6 ? "주말에는 시간표가 없습니다." : "등록된 시간표가 없습니다."}
}
+
오늘 일정
{selectedEvents.length}개{selectedEvents.map((event) =>
{eventTime(event) || "종일"}{categoryLabels[event.category] || "일정"}{event.subject ? ` · ${event.subject}` : ""}{event.title}{event.description && {event.description}
})}{!selectedEvents.length &&
이날 예정된 일정이 없습니다.
}
+
}
+ { if (!eventSaving) setEventModalOpen(false); }} onSave={saveEvent} />
+
+ );
+}
diff --git a/apps/classbot/src/portal/PortalDrive.jsx b/apps/classbot/src/portal/PortalDrive.jsx
new file mode 100644
index 0000000..fcde6b7
--- /dev/null
+++ b/apps/classbot/src/portal/PortalDrive.jsx
@@ -0,0 +1,54 @@
+import { Download, ExternalLink, FileImage, FileText, FolderOpen, RotateCw, Search } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { api } from "../api/client.js";
+import { dateLabel } from "../lib/format.js";
+
+function fileName(item) {
+ return item.alias || item.title || item.filename || item.original_name || "학급 자료";
+}
+
+function originalName(item) {
+ return item.filename || item.original_name || item.file_name || "파일";
+}
+
+function fileSize(value) {
+ const size = Number(value);
+ if (!Number.isFinite(size) || size < 0) return "용량 정보 없음";
+ if (size < 1024) return `${size} B`;
+ if (size < 1024 ** 2) return `${(size / 1024).toFixed(size < 10 * 1024 ? 1 : 0)} KB`;
+ return `${(size / 1024 ** 2).toFixed(size < 10 * 1024 ** 2 ? 1 : 0)} MB`;
+}
+
+export default function PortalDrive() {
+ const [query, setQuery] = useState("");
+ const [files, setFiles] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const load = () => {
+ setLoading(true); setError("");
+ api.portalFiles().then((result) => setFiles(result.items || [])).catch((err) => setError(err.message || "자료를 불러오지 못했습니다.")).finally(() => setLoading(false));
+ };
+ useEffect(() => { load(); }, []);
+
+ const visible = useMemo(() => {
+ const needle = query.trim().toLocaleLowerCase("ko-KR");
+ return [...files].filter((item) => !needle || [fileName(item), originalName(item), item.description].some((value) => String(value || "").toLocaleLowerCase("ko-KR").includes(needle))).sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
+ }, [files, query]);
+
+ return (
+
+
+
+ 공유 자료
{visible.length}개
+ {loading && 공유 자료를 불러오는 중
}
+ {!loading && error && 자료를 불러오지 못했습니다.{error}
}
+ {!loading && !error && visible.map((item) => {
+ const isImage = String(item.mime_type || item.content_type || "").startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(originalName(item));
+ return {isImage ? : }{fileName(item)}{originalName(item)} · {fileSize(item.size_bytes ?? item.size)}{item.description && {item.description}
}{item.visibility === "private" || item.member_id ? "개인 자료" : "반 전체"}{item.open_url && 열기}{item.download_url && 다운로드};
+ })}
+ {!loading && !error && !visible.length && {query ? "검색 결과가 없습니다." : "공유된 자료가 없습니다."}{query ? "다른 검색어를 입력해 보세요." : "관리자가 자료를 공유하면 이곳에 표시됩니다."}
}
+
+
+ );
+}
diff --git a/apps/classbot/src/portal/PortalLogin.jsx b/apps/classbot/src/portal/PortalLogin.jsx
new file mode 100644
index 0000000..8979b6b
--- /dev/null
+++ b/apps/classbot/src/portal/PortalLogin.jsx
@@ -0,0 +1,26 @@
+import { AlertTriangle, ArrowRight, ShieldCheck, UserRoundCheck } from "lucide-react";
+import { useState } from "react";
+import { Brand } from "../components/AppShell.jsx";
+
+export default function PortalLogin({ busy, error, onLogin }) {
+ const [name, setName] = useState("");
+ const [inviteCode, setInviteCode] = useState("");
+ return (
+
+
+
+ );
+}
diff --git a/apps/classbot/src/portal/StudentPortal.jsx b/apps/classbot/src/portal/StudentPortal.jsx
new file mode 100644
index 0000000..f1a8be9
--- /dev/null
+++ b/apps/classbot/src/portal/StudentPortal.jsx
@@ -0,0 +1,28 @@
+import { CalendarDays, FolderOpen, LogOut, UserRound } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { Brand } from "../components/AppShell.jsx";
+import PortalCalendar from "./PortalCalendar.jsx";
+import PortalDrive from "./PortalDrive.jsx";
+
+export default function StudentPortal({ session, onLogout }) {
+ const [active, setActive] = useState("calendar");
+ const [identity, setIdentity] = useState({ member: session?.member || null, classroom: session?.classroom || null });
+ useEffect(() => setIdentity((current) => ({ member: session?.member || current.member, classroom: session?.classroom || current.classroom })), [session]);
+ const receiveOverview = useCallback((overview) => setIdentity({ member: overview.member || null, classroom: overview.classroom || null }), []);
+ const memberName = identity.member?.display_name || session?.member?.display_name || "학생";
+ const classroomName = identity.classroom?.name || session?.classroom?.name || "학급 포털";
+ const nav = [
+ ["calendar", "캘린더", CalendarDays],
+ ["drive", "드라이브", FolderOpen],
+ ];
+ return (
+
+
+
{active === "calendar" ? : }
+
+ );
+}
diff --git a/apps/classbot/src/styles.css b/apps/classbot/src/styles.css
index e72a4f3..f7a7372 100644
--- a/apps/classbot/src/styles.css
+++ b/apps/classbot/src/styles.css
@@ -16,6 +16,7 @@
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: #fff; }
+.visually-hidden { width: 1px !important; height: 1px !important; position: absolute !important; overflow: hidden !important; clip: rect(0 0 0 0) !important; white-space: nowrap !important; clip-path: inset(50%) !important; }
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid rgba(20, 87, 230, .22); outline-offset: 2px; }
@@ -148,6 +149,41 @@ h2 { font-size: 20px; line-height: 1.35; }
.notice-main time { color: #8a94a6; font-size: 11px; }
.send-notice { margin-right: 18px; white-space: nowrap; }
+.file-upload-panel { margin-bottom: 22px; }
+.file-upload-grid { display: grid; grid-template-columns: minmax(270px, .8fr) minmax(360px, 1.2fr); gap: 28px; padding: 24px; }
+.file-picker { min-width: 0; }
+.file-drop { min-height: 246px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 24px; color: var(--muted); text-align: center; border: 1px dashed #aebbd0; border-radius: 9px; background: #f9fbff; cursor: pointer; transition: border .16s, background .16s; }
+.file-drop:hover { border-color: var(--blue); background: var(--blue-soft); }
+.file-drop-icon { width: 48px; height: 48px; display: grid; place-items: center; margin-bottom: 4px; color: var(--blue); background: #fff; border: 1px solid #cfdaea; border-radius: 11px; }
+.file-drop strong { color: var(--text); font-size: 14px; }
+.file-drop small { font-size: 12px; }
+.selected-file { min-height: 54px; display: flex; align-items: center; gap: 10px; margin-top: 10px; padding: 8px 8px 8px 12px; border: 1px solid var(--line); border-radius: 8px; }
+.selected-file > span { min-width: 0; flex: 1; display: grid; gap: 3px; }
+.selected-file strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.selected-file small { color: var(--muted); font-size: 11px; }
+.file-error { margin: 9px 0 0; color: var(--red); font-size: 12px; }
+.file-fields { display: grid; align-content: start; gap: 16px; }
+.file-fields label { display: grid; gap: 7px; font-size: 13px; font-weight: 700; }
+.file-fields label > span { color: var(--red); }
+.file-fields input, .file-fields select, .file-fields textarea { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid #cbd5e3; border-radius: 8px; background: #fff; font-size: 13px; font-weight: 400; }
+.file-fields textarea { min-height: 74px; padding-top: 11px; resize: vertical; line-height: 1.45; }
+.file-fields .help-text { margin-top: -7px; }
+.file-submit { justify-self: end; min-width: 142px; }
+.file-list-panel { margin-top: 22px; }
+.file-loading { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 10px; color: var(--muted); font-size: 13px; }
+.file-row { min-height: 112px; display: grid; grid-template-columns: 48px minmax(0, 1fr) auto 132px; align-items: center; gap: 15px; padding: 16px 20px; border-bottom: 1px solid var(--line); }
+.file-row:last-child { border-bottom: 0; }
+.file-type-icon { width: 44px; height: 44px; display: grid; place-items: center; color: #d92d20; background: #fff0ee; border-radius: 10px; }
+.file-type-icon.image { color: #087443; background: #e7f8ef; }
+.file-copy { min-width: 0; display: grid; gap: 4px; }
+.file-copy > strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
+.file-copy > small, .file-copy time { color: var(--muted); font-size: 11px; }
+.file-copy p { overflow: hidden; margin: 1px 0; color: #344054; font-size: 12px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
+.file-visibility { max-width: 160px; display: inline-flex; align-items: center; gap: 6px; padding: 6px 9px; color: #175cd3; background: var(--blue-soft); border-radius: 7px; font-size: 11px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.file-visibility.private { color: #6941c6; background: #f0eaff; }
+.file-actions { display: flex; align-items: center; justify-content: flex-end; gap: 0; }
+.file-actions a { color: var(--text); text-decoration: none; }
+
.metric-strip { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 20px; border: 1px solid var(--line); border-radius: 9px; }
.metric-strip > div { min-height: 80px; display: flex; align-items: center; gap: 13px; padding: 14px 20px; border-right: 1px solid var(--line); }
.metric-strip > div:last-child { border-right: 0; }
@@ -218,6 +254,142 @@ h2 { font-size: 20px; line-height: 1.35; }
.danger-text { min-height: 42px; display: flex; align-items: center; justify-content: center; gap: 7px; border: 0; background: #fff; font-size: 13px; font-weight: 700; }
.notice-form-icon { width: 44px; height: 44px; display: grid; place-items: center; margin-bottom: -7px; color: var(--blue); background: var(--blue-soft); border-radius: 10px; }
+.portal-login-page { min-height: 100vh; display: grid; place-items: center; padding: 28px 16px; background: #f7f9fc; }
+.portal-login-card { width: min(440px, 100%); padding: 36px; border: 1px solid var(--line); border-radius: 15px; background: #fff; box-shadow: 0 18px 44px rgba(11, 27, 58, .08); }
+.portal-login-card .brand { justify-content: center; height: auto; padding: 0; }
+.portal-login-icon { width: 54px; height: 54px; display: grid; place-items: center; margin: 32px auto 16px; color: var(--blue); background: var(--blue-soft); border-radius: 14px; }
+.portal-login-card h1 { margin: 0; text-align: center; font-size: 27px; letter-spacing: -.7px; }
+.portal-login-card > p { margin: 8px 0 26px; color: var(--muted); text-align: center; font-size: 13px; line-height: 1.55; }
+.portal-login-card form, .portal-login-card form > label { display: grid; gap: 9px; }
+.portal-login-card form > label { font-size: 13px; font-weight: 700; }
+.portal-login-card input { height: 50px; padding: 0 13px; border: 1px solid var(--line); border-radius: 8px; }
+.portal-privacy { display: flex; align-items: flex-start; gap: 9px; margin: 8px 0 4px; padding: 12px; color: #344054; background: #f6f8fb; border-radius: 8px; }
+.portal-privacy svg { flex: 0 0 auto; color: var(--blue); }
+.portal-privacy p { margin: 0; font-size: 11px; line-height: 1.55; }
+.portal-admin-link { display: block; width: fit-content; margin: 22px auto 0; color: var(--muted); font-size: 12px; text-decoration: underline; text-underline-offset: 3px; }
+
+.portal-shell { min-height: 100vh; background: #fff; }
+.portal-header { height: 72px; position: sticky; inset: 0 0 auto; z-index: 30; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 20px; padding: 0 28px; background: rgba(255, 255, 255, .96); border-bottom: 1px solid var(--line); backdrop-filter: blur(12px); }
+.portal-header .brand { height: auto; padding: 0; font-size: 20px; }
+.portal-header .brand img { width: 32px; height: 32px; }
+.portal-primary-nav { align-self: stretch; display: flex; align-items: center; gap: 6px; }
+.portal-primary-nav button { min-width: 118px; height: 100%; position: relative; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 0 18px; color: var(--muted); background: transparent; border: 0; font-size: 14px; font-weight: 700; }
+.portal-primary-nav button:hover { color: var(--text); background: #f8faff; }
+.portal-primary-nav button.active { color: var(--blue); }
+.portal-primary-nav button.active::after { content: ""; height: 3px; position: absolute; inset: auto 16px -1px; background: var(--blue); border-radius: 3px 3px 0 0; }
+.portal-account { min-width: 0; justify-self: end; display: flex; align-items: center; gap: 8px; }
+.portal-account-copy { min-width: 0; display: grid; gap: 2px; text-align: right; }
+.portal-account-copy small { color: var(--muted); font-size: 10px; }
+.portal-account-copy strong { display: flex; align-items: center; justify-content: flex-end; gap: 5px; font-size: 13px; }
+.portal-main { max-width: 1320px; min-height: calc(100vh - 72px); margin: 0 auto; padding: 34px 30px 72px; }
+
+.portal-calendar-toolbar { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 20px; margin-bottom: 20px; }
+.portal-calendar-toolbar h1 { margin: 0; text-align: center; font-size: 25px; letter-spacing: -.6px; }
+.portal-date-navigation { display: flex; align-items: center; gap: 2px; }
+.portal-today-button { min-height: 36px; padding: 0 12px; color: #344054; background: #fff; border: 1px solid var(--line); border-radius: 7px; font-size: 12px; font-weight: 700; }
+.portal-view-switch { display: grid; grid-template-columns: repeat(3, 44px); padding: 3px; background: #eef2f7; border-radius: 8px; }
+.portal-view-switch button { min-height: 34px; color: var(--muted); background: transparent; border: 0; border-radius: 6px; font-size: 12px; font-weight: 700; }
+.portal-view-switch button.active { color: var(--text); background: #fff; box-shadow: 0 1px 3px rgba(11, 27, 58, .12); }
+.portal-calendar-actions { display: flex; align-items: center; gap: 10px; }
+.portal-add-event { min-height: 40px; padding-inline: 14px; white-space: nowrap; }
+.portal-notice-strip { min-height: 44px; display: grid; grid-template-columns: auto auto 1fr; align-items: center; gap: 9px; margin-bottom: 16px; padding: 9px 13px; color: #344054; background: #fff9eb; border: 1px solid #fee4a8; border-radius: 8px; font-size: 12px; }
+.portal-notice-strip svg { color: #b54708; }
+.portal-notice-strip span { min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
+.portal-calendar-state { min-height: 460px; display: flex; align-items: center; justify-content: center; gap: 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 9px; font-size: 13px; }
+.portal-calendar-state.error { flex-direction: column; text-align: center; }
+.portal-calendar-state.error strong { color: var(--text); }
+.portal-calendar-state.error p { margin: 0; }
+
+.portal-month-calendar { overflow: hidden; }
+.portal-month-weekdays { display: grid; grid-template-columns: repeat(7, 1fr); min-height: 42px; align-items: center; color: var(--muted); background: #f8fafc; border-bottom: 1px solid var(--line); text-align: center; font-size: 11px; font-weight: 700; }
+.portal-month-weekdays span:first-child { color: #d92d20; }
+.portal-month-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
+.portal-month-day { min-width: 0; min-height: 126px; display: flex; flex-direction: column; gap: 7px; padding: 9px; overflow: hidden; background: #fff; border: 0; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); text-align: left; }
+.portal-month-day:nth-child(7n) { border-right: 0; }
+.portal-month-day:nth-last-child(-n + 7) { border-bottom: 0; }
+.portal-month-day:hover { background: #fafcff; }
+.portal-month-day.outside { background: #fbfcfe; }
+.portal-day-number { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 50%; color: #344054; font-size: 12px; font-weight: 700; }
+.portal-month-day.outside .portal-day-number { color: #a4adbb; }
+.portal-month-day.today .portal-day-number { color: #fff; background: var(--blue); }
+.portal-cell-events { min-width: 0; display: grid; gap: 4px; }
+.portal-cell-events > small { padding-left: 5px; color: var(--muted); font-size: 9px; }
+.portal-event-chip { min-width: 0; min-height: 29px; display: flex; align-items: center; gap: 6px; padding: 5px 7px; color: #475467; background: #f2f4f7; border-radius: 6px; font-size: 10px; }
+.portal-event-chip i { width: 6px; height: 6px; flex: 0 0 auto; background: #667085; border-radius: 50%; }
+.portal-event-chip time { flex: 0 0 auto; color: inherit; font-size: 9px; }
+.portal-event-chip strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.portal-event-chip.assessment { color: #b42318; background: #fff0ee; }
+.portal-event-chip.assessment i { background: #f04438; }
+.portal-event-chip.assignment { color: #175cd3; background: #edf3ff; }
+.portal-event-chip.assignment i { background: #2e6fe8; }
+.portal-event-chip.class { color: #067647; background: #e9f8f0; }
+.portal-event-chip.class i { background: #12b76a; }
+.portal-event-chip.compact { min-height: 24px; padding-block: 3px; }
+
+.portal-week-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--line); border-radius: 9px; }
+.portal-week-day { min-width: 0; min-height: 480px; border-right: 1px solid var(--line); background: #fff; }
+.portal-week-day:last-child { border-right: 0; }
+.portal-week-day.today { background: #fbfdff; }
+.portal-week-heading { width: 100%; min-height: 72px; display: grid; place-items: center; gap: 2px; padding: 8px; color: var(--muted); background: transparent; border: 0; border-bottom: 1px solid var(--line); }
+.portal-week-heading span { font-size: 10px; }
+.portal-week-heading strong { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; color: var(--text); font-size: 14px; }
+.portal-week-day.today .portal-week-heading strong { color: #fff; background: var(--blue); }
+.portal-week-events { display: grid; gap: 7px; padding: 9px 7px; }
+.portal-week-events > small { margin-top: 12px; color: #a0a8b5; text-align: center; font-size: 9px; }
+
+.portal-day-layout { display: grid; grid-template-columns: .85fr 1.15fr; gap: 20px; }
+.portal-day-panel { min-width: 0; }
+.portal-timetable-row { min-height: 68px; display: grid; grid-template-columns: 64px minmax(90px, .7fr) 1fr; align-items: center; gap: 14px; padding: 10px 18px; border-bottom: 1px solid var(--line); }
+.portal-timetable-row:last-child { border-bottom: 0; }
+.portal-timetable-row > span { color: var(--blue); font-size: 11px; font-weight: 700; }
+.portal-timetable-row strong { font-size: 13px; }
+.portal-timetable-row small { color: var(--muted); font-size: 11px; }
+.portal-day-event { min-height: 82px; display: grid; grid-template-columns: 74px 1fr; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--line); border-left: 3px solid #98a2b3; }
+.portal-day-event:last-child { border-bottom: 0; }
+.portal-day-event.assessment { border-left-color: #f04438; }
+.portal-day-event.assignment { border-left-color: #2e6fe8; }
+.portal-day-event.class { border-left-color: #12b76a; }
+.portal-event-time { color: #344054; font-size: 11px; font-weight: 700; }
+.portal-day-event > span:last-child { min-width: 0; display: grid; align-content: start; gap: 4px; }
+.portal-day-event small { color: var(--muted); font-size: 10px; }
+.portal-day-event strong { font-size: 13px; }
+.portal-day-event p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
+.portal-small-empty { min-height: 190px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; color: var(--muted); text-align: center; font-size: 12px; }
+
+.portal-modal-layer { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 20px; }
+.portal-modal-scrim { position: absolute; inset: 0; border: 0; background: rgba(11, 27, 58, .38); }
+.portal-event-modal { width: min(520px, 100%); max-height: calc(100vh - 40px); position: relative; z-index: 1; display: flex; flex-direction: column; overflow: hidden; background: #fff; border-radius: 13px; box-shadow: 0 24px 70px rgba(11, 27, 58, .24); animation: page-in .18s ease; }
+.portal-event-modal > header { min-height: 66px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px 0 24px; border-bottom: 1px solid var(--line); }
+.portal-event-modal > header h2 { margin: 0; }
+.portal-event-modal form { display: grid; gap: 18px; padding: 22px 24px 24px; overflow-y: auto; }
+.portal-event-modal form > label, .portal-modal-row label { display: grid; gap: 7px; font-size: 12px; font-weight: 700; }
+.portal-event-modal label > span { color: var(--red); }
+.portal-event-modal input, .portal-event-modal select, .portal-event-modal textarea { width: 100%; min-height: 44px; padding: 0 11px; border: 1px solid #cbd5e3; border-radius: 8px; background: #fff; font-size: 13px; font-weight: 400; }
+.portal-event-modal textarea { padding-top: 10px; resize: vertical; line-height: 1.45; }
+.portal-modal-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.portal-personal-scope { display: grid; gap: 3px; padding: 11px 12px; color: #175cd3; background: var(--blue-soft); border-radius: 8px; }
+.portal-personal-scope strong { font-size: 12px; }
+.portal-personal-scope span { font-size: 10px; }
+.portal-event-modal footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
+
+.portal-page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
+.portal-page-heading h1 { margin: 0; font-size: 30px; letter-spacing: -.8px; }
+.portal-page-heading p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
+.portal-drive-search { width: min(330px, 100%); min-height: 44px; display: flex; align-items: center; gap: 8px; padding: 0 12px; border: 1px solid var(--line); border-radius: 8px; }
+.portal-drive-search input { min-width: 0; width: 100%; border: 0; outline: 0; font-size: 13px; }
+.portal-drive-row { min-height: 108px; display: grid; grid-template-columns: 48px minmax(0, 1fr) auto auto; align-items: center; gap: 15px; padding: 15px 18px; border-bottom: 1px solid var(--line); }
+.portal-drive-row:last-child { border-bottom: 0; }
+.portal-file-icon { width: 44px; height: 44px; display: grid; place-items: center; color: #d92d20; background: #fff0ee; border-radius: 10px; }
+.portal-file-icon.image { color: #087443; background: #e7f8ef; }
+.portal-file-copy { min-width: 0; display: grid; gap: 3px; }
+.portal-file-copy strong, .portal-file-copy p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.portal-file-copy strong { font-size: 14px; }
+.portal-file-copy small, .portal-file-copy time { color: var(--muted); font-size: 10px; }
+.portal-file-copy p { margin: 2px 0; color: #344054; font-size: 11px; }
+.portal-file-scope { padding: 5px 8px; color: #475467; background: #f2f4f7; border-radius: 6px; font-size: 10px; font-weight: 700; white-space: nowrap; }
+.portal-drive-actions { display: flex; gap: 8px; }
+.portal-drive-actions a { min-height: 38px; padding-inline: 13px; text-decoration: none; }
+
.toast { position: fixed; z-index: 100; left: 50%; bottom: 28px; min-height: 48px; display: flex; align-items: center; gap: 9px; transform: translateX(-50%); padding: 0 18px; color: white; background: #13233f; border-radius: 9px; font-size: 13px; font-weight: 650; }
.bottom-nav { display: none; }
.app-loading, .login-page { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 13px; color: var(--muted); }
@@ -258,6 +430,9 @@ h2 { font-size: 20px; line-height: 1.35; }
.compact-table .table-head, .compact-table .table-row { min-width: 720px; }
.notification-head, .notification-row { grid-template-columns: 140px 110px minmax(150px, 1fr) 120px; }
.notification-head span:last-child, .notification-row .failure-copy { display: none; }
+ .portal-main { padding-inline: 20px; }
+ .portal-drive-row { grid-template-columns: 48px minmax(0, 1fr) auto; }
+ .portal-drive-actions { grid-column: 2 / 4; justify-content: flex-end; }
}
@media (max-width: 760px) {
@@ -279,7 +454,7 @@ h2 { font-size: 20px; line-height: 1.35; }
.page-heading.split .outline-button { min-width: 44px; padding: 0 12px; }
.timetable-edit { font-size: 0; }
.home-grid { gap: 27px; margin-top: 30px; }
- .bottom-nav { height: 69px; position: fixed; inset: auto 0 0; z-index: 40; display: grid; grid-template-columns: repeat(7, 1fr); background: white; border-top: 1px solid var(--line); }
+ .bottom-nav { height: 69px; position: fixed; inset: auto 0 0; z-index: 40; display: grid; grid-template-columns: repeat(8, 1fr); background: white; border-top: 1px solid var(--line); }
.bottom-nav button { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; color: #748095; background: white; border: 0; font-size: 9px; }
.bottom-nav button.active { color: var(--blue); font-weight: 700; }
.drawer { width: 100%; box-shadow: none; }
@@ -301,6 +476,13 @@ h2 { font-size: 20px; line-height: 1.35; }
.notice-row { position: relative; display: block; }
.notice-main { width: 100%; grid-template-columns: 42px 1fr 20px; padding: 16px; }
.send-notice { margin: 0 16px 16px 74px; }
+ .file-upload-grid { grid-template-columns: 1fr; gap: 20px; padding: 16px; }
+ .file-drop { min-height: 174px; }
+ .file-submit { width: 100%; justify-self: stretch; }
+ .file-row { grid-template-columns: 44px minmax(0, 1fr) auto; gap: 10px 12px; padding: 14px; }
+ .file-type-icon { grid-row: 1 / 3; align-self: start; }
+ .file-visibility { grid-column: 2; justify-self: start; max-width: 100%; }
+ .file-actions { grid-column: 3; grid-row: 1 / 3; align-self: center; flex-direction: column; }
.metric-strip { grid-template-columns: 1fr; }
.metric-strip > div { min-height: 64px; border-right: 0; border-bottom: 1px solid var(--line); }
.metric-strip > div:last-child { border-bottom: 0; }
@@ -321,6 +503,57 @@ h2 { font-size: 20px; line-height: 1.35; }
.security-card { align-items: flex-start; flex-wrap: wrap; }
.security-card .outline-button { width: 100%; }
.toast { width: calc(100% - 32px); bottom: 84px; justify-content: center; }
+ .portal-login-card { padding: 30px 22px; }
+ .portal-login-card .brand { font-size: 20px; }
+ .portal-shell { padding-bottom: 70px; }
+ .portal-header { height: 64px; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 0 14px; backdrop-filter: none; }
+ .portal-header .brand { min-width: 0; gap: 8px; font-size: 15px; }
+ .portal-header .brand img { width: 30px; height: 30px; }
+ .portal-header .brand span { overflow: hidden; text-overflow: ellipsis; }
+ .portal-account-copy small { display: none; }
+ .portal-account-copy strong { max-width: 104px; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+ .portal-account-copy strong svg { display: none; }
+ .portal-primary-nav { height: 70px; position: fixed; inset: auto 0 0; z-index: 45; display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; background: #fff; border-top: 1px solid var(--line); }
+ .portal-primary-nav button { min-width: 0; height: 70px; flex-direction: column; gap: 4px; padding: 0; font-size: 10px; }
+ .portal-primary-nav button.active::after { inset: -1px 24px auto; }
+ .portal-main { min-height: calc(100vh - 64px); padding: 20px 14px 34px; }
+ .portal-calendar-toolbar { grid-template-columns: auto 1fr; gap: 10px; }
+ .portal-date-navigation { justify-self: start; }
+ .portal-date-navigation .icon-button { width: 36px; height: 36px; }
+ .portal-calendar-toolbar h1 { grid-column: 1 / 3; grid-row: 2; text-align: left; font-size: 22px; }
+ .portal-calendar-actions { justify-self: end; gap: 6px; }
+ .portal-view-switch { grid-template-columns: repeat(3, 38px); }
+ .portal-add-event { min-width: 40px; width: 40px; padding: 0; font-size: 0; }
+ .portal-notice-strip { grid-template-columns: auto 1fr; }
+ .portal-notice-strip span { grid-column: 2; }
+ .portal-month-weekdays { min-height: 34px; }
+ .portal-month-day { min-height: 82px; gap: 4px; padding: 4px; }
+ .portal-day-number { width: 22px; height: 22px; font-size: 10px; }
+ .portal-event-chip.compact { min-height: 19px; gap: 3px; padding: 2px 3px; border-radius: 4px; font-size: 8px; }
+ .portal-event-chip.compact i { width: 4px; height: 4px; }
+ .portal-month-day .portal-event-chip:nth-child(n + 3) { display: none; }
+ .portal-cell-events > small { padding-left: 2px; font-size: 8px; }
+ .portal-week-grid { grid-template-columns: 1fr; }
+ .portal-week-day { min-height: 104px; display: grid; grid-template-columns: 58px minmax(0, 1fr); border-right: 0; border-bottom: 1px solid var(--line); }
+ .portal-week-day:last-child { border-bottom: 0; }
+ .portal-week-heading { min-height: 100%; border-right: 1px solid var(--line); border-bottom: 0; }
+ .portal-week-events { align-content: center; }
+ .portal-day-layout { grid-template-columns: 1fr; }
+ .portal-timetable-row { grid-template-columns: 54px 90px 1fr; padding-inline: 12px; }
+ .portal-day-event { grid-template-columns: 58px 1fr; padding-inline: 12px; }
+ .portal-calendar-state { min-height: 380px; }
+ .portal-page-heading { align-items: stretch; flex-direction: column; gap: 16px; margin-bottom: 18px; }
+ .portal-page-heading h1 { font-size: 27px; }
+ .portal-drive-search { width: 100%; }
+ .portal-drive-row { grid-template-columns: 44px minmax(0, 1fr); gap: 9px 12px; padding: 14px; }
+ .portal-file-scope { grid-column: 2; justify-self: start; }
+ .portal-drive-actions { grid-column: 2; justify-content: flex-start; flex-wrap: wrap; }
+ .portal-drive-actions a { min-height: 36px; padding-inline: 11px; font-size: 11px; }
+ .portal-modal-layer { padding: 0; }
+ .portal-event-modal { width: 100%; height: 100%; max-height: none; border-radius: 0; }
+ .portal-event-modal > header { min-height: 64px; }
+ .portal-event-modal form { padding: 20px; }
+ .portal-modal-row { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {