CIS Enhanced: FastAPI-App für LV-Plan, Studiengangs-Pläne und Namenslisten des FH-Burgenland-CIS
- Studentenplan (eigene/fremde Matrikelnummer) mit Wochenansicht, Semesterübersicht, iCal/CSV-Export - Studiengangs-Pläne nach StG/Semester/Verband mit Gruppen-Filter - Namenslisten mit Suche, Matrikel-Decode und CSV-Export - Studiennummer-Decoder auf der Startseite - Docker (Dockerfile, Compose) und Caddy-Beispiel für Deploy
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
/* CIS Enhanced - client logic */
|
||||
"use strict";
|
||||
|
||||
/* ================= helpers ================= */
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[c]));
|
||||
|
||||
function lvColor(lv) {
|
||||
let h = 0;
|
||||
for (const c of lv) h = (h * 31 + c.charCodeAt(0)) % 997;
|
||||
const hue = (h * 1.375) % 360;
|
||||
return `hsl(${hue}, 52%, 42%)`;
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toTimeString().slice(0, 5);
|
||||
}
|
||||
function fmtDate(d) {
|
||||
const months = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
||||
return `${String(d.getDate()).padStart(2, "0")}.${months[d.getMonth()]}`;
|
||||
}
|
||||
function parseISO(s) {
|
||||
const [date, time] = s.split("T");
|
||||
const [y, m, d] = date.split("-").map(Number);
|
||||
const [hh, mm, ss] = time.split(":").map(Number);
|
||||
return new Date(y, m - 1, d, hh, mm, ss || 0);
|
||||
}
|
||||
function addDays(d, n) {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + n);
|
||||
return r;
|
||||
}
|
||||
function sameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
/* ================= plan view ================= */
|
||||
const PX_PER_HOUR = 48;
|
||||
const DAY_START_H = 8;
|
||||
let plan = null;
|
||||
let weekStart = null;
|
||||
let gruppeFilter = "";
|
||||
|
||||
function visibleEvents() {
|
||||
if (!gruppeFilter) return plan.events;
|
||||
return plan.events.filter((e) => e.gruppe === gruppeFilter);
|
||||
}
|
||||
|
||||
function setGruppeFilter(v) {
|
||||
gruppeFilter = v;
|
||||
renderWeek();
|
||||
renderSemester();
|
||||
}
|
||||
|
||||
function initPlan() {
|
||||
plan = window.__PLAN_DATA__;
|
||||
plan.events = plan.events.map((e) => ({ ...e, s: parseISO(e.start), e2: parseISO(e.end) }));
|
||||
weekStart = parseISO(plan.initWeek + "T00:00:00");
|
||||
if (plan.kind === "stg") {
|
||||
const sel = $("#gruppe-filter");
|
||||
const groups = [...new Set(plan.events.map((e) => e.gruppe).filter(Boolean))].sort();
|
||||
if (groups.length > 1) {
|
||||
for (const g of groups) {
|
||||
sel.insertAdjacentHTML("beforeend", `<option value="${esc(g)}">${esc(g)}</option>`);
|
||||
}
|
||||
sel.style.display = "";
|
||||
}
|
||||
}
|
||||
renderWeek();
|
||||
renderSemester();
|
||||
if (plan.tab === "sem") switchTab("sem");
|
||||
}
|
||||
|
||||
function stundeOf(ev) {
|
||||
const mins = ev.s.getHours() * 60 + ev.s.getMinutes();
|
||||
let best = 1;
|
||||
plan.slots.forEach((slot, i) => {
|
||||
const [hh, mm] = slot.split(":").map(Number);
|
||||
if (hh * 60 + mm <= mins) best = i + 1;
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function weekEnd() {
|
||||
let endH = 18;
|
||||
for (const e of visibleEvents()) {
|
||||
const h = e.e2.getHours() + (e.e2.getMinutes() / 60);
|
||||
if (h > endH) endH = Math.ceil(h + 0.25);
|
||||
}
|
||||
return endH;
|
||||
}
|
||||
|
||||
function layoutDay(evs) {
|
||||
evs.sort((a, b) => (a.s - b.s) || (b.e2 - a.e2));
|
||||
const clusters = [];
|
||||
let cur = null, curEnd = null;
|
||||
for (const e of evs) {
|
||||
if (!cur || e.s >= curEnd) {
|
||||
cur = [];
|
||||
clusters.push(cur);
|
||||
curEnd = e.e2;
|
||||
} else {
|
||||
curEnd = Math.max(curEnd, e.e2);
|
||||
}
|
||||
cur.push(e);
|
||||
}
|
||||
for (const cluster of clusters) {
|
||||
const cols = [];
|
||||
for (const e of cluster) {
|
||||
let placed = false;
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
if (cols[i][cols[i].length - 1].e2 <= e.s) {
|
||||
cols[i].push(e);
|
||||
e._col = i;
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
e._col = cols.length;
|
||||
cols.push([e]);
|
||||
}
|
||||
}
|
||||
for (const e of cluster) e._cols = cols.length;
|
||||
}
|
||||
}
|
||||
|
||||
function renderWeek() {
|
||||
const grid = $("#week-grid");
|
||||
if (!grid) return;
|
||||
const endH = weekEnd();
|
||||
const height = (endH - DAY_START_H) * PX_PER_HOUR;
|
||||
const today = new Date();
|
||||
const monday = weekStart;
|
||||
const sunday = addDays(monday, 6);
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
||||
const names = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
let html = `<div class="wg-corner"></div>`;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = addDays(monday, i);
|
||||
const isToday = sameDay(d, today) ? "today" : "";
|
||||
const we = i >= 5 ? "weekend" : "";
|
||||
const monthLabel = (i === 0 || d.getDate() <= 7) ? ` ${MONTHS[d.getMonth()]}` : "";
|
||||
html += `<div class="wg-dayhead ${isToday} ${we}">${names[i]}<span class="d">${d.getDate()}</span>${monthLabel}</div>`;
|
||||
}
|
||||
html += `<div class="wg-time" style="height:${height}px">`;
|
||||
for (let h = DAY_START_H; h <= endH; h++) {
|
||||
const top = (h - DAY_START_H) * PX_PER_HOUR;
|
||||
html += `<span style="top:${top}px">${String(h).padStart(2, "0")}:00</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = addDays(monday, i);
|
||||
const we = i >= 5 ? "weekend" : "";
|
||||
html += `<div class="wg-col ${we}" style="height:${height}px" data-day="${i}">`;
|
||||
for (let m = 0; m < (endH - DAY_START_H) * 60; m += 30) {
|
||||
const top = (m / 60) * PX_PER_HOUR;
|
||||
if (m % 60 === 0 && m > 0) continue;
|
||||
html += `<div class="wg-slotline" style="top:${top}px"></div>`;
|
||||
}
|
||||
const dayEvents = visibleEvents().filter((ev) => ev.s >= d && ev.s < addDays(d, 1) && ev.s.getHours() >= DAY_START_H);
|
||||
layoutDay(dayEvents);
|
||||
for (const ev of dayEvents) {
|
||||
const top = ((ev.s.getHours() + ev.s.getMinutes() / 60) - DAY_START_H) * PX_PER_HOUR;
|
||||
const dur = (ev.e2 - ev.s) / 3600000;
|
||||
const hgt = Math.max(dur * PX_PER_HOUR, 14);
|
||||
const w = 100 / (ev._cols || 1);
|
||||
const l = (ev._col || 0) * w;
|
||||
const pay = { lv: ev.lv, lektor: ev.lektor, gruppe: ev.gruppe, ort: ev.ort,
|
||||
start: ev.start, end: ev.end, datum: ev.start.slice(0, 10) };
|
||||
html += `<button class="ev" style="top:${top + 1}px;height:${hgt - 2}px;background:${lvColor(ev.lv)};left:calc(${l}% + 1px);width:calc(${w}% - 3px);right:auto"
|
||||
onclick='openEvent(${JSON.stringify(pay).replace(/'/g, "'")})'>
|
||||
<span class="t">${esc(ev.lv)}</span>
|
||||
${hgt > 34 ? `<span class="sub">${esc(ev.ort)}${ev.gruppe ? " · " + esc(ev.gruppe) : ""}</span>` : ""}
|
||||
</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
grid.style.gridTemplateRows = `auto ${height}px`;
|
||||
grid.innerHTML = html;
|
||||
|
||||
$("#week-label").textContent = `KW ${isoWeek(monday)}`;
|
||||
$("#week-range").textContent = `${fmtDate(monday)} - ${fmtDate(sunday)} ${sunday.getFullYear()}`;
|
||||
renderLegend();
|
||||
}
|
||||
|
||||
function isoWeek(d) {
|
||||
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
const dayNum = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
const lvs = [...new Set(visibleEvents().map((e) => e.lv))];
|
||||
$("#legend").innerHTML = lvs.map((lv) =>
|
||||
`<span class="item"><span class="dot" style="background:${lvColor(lv)}"></span>${esc(lv)}</span>`).join("");
|
||||
}
|
||||
|
||||
function renderSemester() {
|
||||
const byLv = {};
|
||||
for (const e of visibleEvents()) {
|
||||
if (!byLv[e.lv]) byLv[e.lv] = { lv: e.lv, lektors: new Set(), gruppens: new Set(), orte: new Set(), n: 0, hours: 0, first: e.s, last: e.s };
|
||||
const g = byLv[e.lv];
|
||||
g.lektors.add(e.lektor);
|
||||
if (e.gruppe) g.gruppens.add(e.gruppe);
|
||||
if (e.ort) g.orte.add(e.ort);
|
||||
g.n++;
|
||||
g.hours += (e.e2 - e.s) / 3600000;
|
||||
if (e.s < g.first) g.first = e.s;
|
||||
if (e.s > g.last) g.last = e.s;
|
||||
}
|
||||
const rows = Object.values(byLv).sort((a, b) => a.lv.localeCompare(b.lv));
|
||||
$("#sem-lv").innerHTML = `
|
||||
<h3>Lehrveranstaltungen</h3>
|
||||
${rows.length ? `<table class="data">
|
||||
<thead><tr><th>LV</th><th>LektorIn</th><th>Gruppe</th><th>Orte</th>
|
||||
<th class="num">Termine</th><th class="num">Std.</th><th>Zeitraum</th></tr></thead>
|
||||
<tbody>
|
||||
${rows.map((g) => `<tr>
|
||||
<td><span class="dot" style="display:inline-block;width:10px;height:10px;border-radius:3px;background:${lvColor(g.lv)};margin-right:6px"></span><b>${esc(g.lv)}</b></td>
|
||||
<td>${esc([...g.lektors].join(", "))}</td>
|
||||
<td>${esc([...g.gruppens].join(", ")) || "-"}</td>
|
||||
<td>${esc([...g.orte].join(", ")) || "-"}</td>
|
||||
<td class="num">${g.n}</td>
|
||||
<td class="num">${g.hours.toFixed(1)}</td>
|
||||
<td>${fmtDate(g.first)} - ${fmtDate(g.last)} ${g.last.getFullYear()}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody></table>` : `<div class="empty">Keine Termine in diesem Semester.</div>`}`;
|
||||
|
||||
const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
$("#sem-events").innerHTML = `
|
||||
<table class="data">
|
||||
<thead><tr><th>Datum</th><th>KW</th><th>Zeit</th><th>LV</th><th>LektorIn</th><th>Gruppe</th><th>Ort</th></tr></thead>
|
||||
<tbody>
|
||||
${visibleEvents().map((e) => `<tr>
|
||||
<td>${fmtDate(e.s)} ${e.s.getFullYear()}</td>
|
||||
<td>${names[e.s.getDay()]} / ${isoWeek(e.s)}</td>
|
||||
<td>${fmtTime(e.s)} - ${fmtTime(e.e2)}</td>
|
||||
<td><b>${esc(e.lv)}</b></td>
|
||||
<td>${esc(e.lektor)}</td>
|
||||
<td>${esc(e.gruppe) || "-"}</td>
|
||||
<td>${esc(e.ort)}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function shiftWeek(n) {
|
||||
weekStart = addDays(weekStart, n * 7);
|
||||
renderWeek();
|
||||
}
|
||||
function goToday() {
|
||||
const t = new Date();
|
||||
weekStart = addDays(t, -((t.getDay() + 6) % 7));
|
||||
renderWeek();
|
||||
}
|
||||
function switchTab(which) {
|
||||
$("#tab-week").classList.toggle("active", which === "week");
|
||||
$("#tab-sem").classList.toggle("active", which === "sem");
|
||||
$("#pane-week").classList.toggle("active", which === "week");
|
||||
$("#pane-sem").classList.toggle("active", which === "sem");
|
||||
}
|
||||
|
||||
/* ---------- detail modal ---------- */
|
||||
async function openEvent(ev) {
|
||||
const modal = $("#detail-modal");
|
||||
modal.classList.add("open");
|
||||
const body = $("#detail-body");
|
||||
body.innerHTML = `<p style="color:var(--muted)">Lade Details vom CIS...</p>`;
|
||||
const stunde = stundeOf({ s: parseISO(ev.start) });
|
||||
const params = new URLSearchParams({
|
||||
kind: plan.kind, datum: ev.datum, stunde, ort: ev.ort,
|
||||
});
|
||||
if (plan.kind === "student") {
|
||||
params.set("uid", plan.uid);
|
||||
} else {
|
||||
params.set("kz", plan.kz);
|
||||
params.set("sem", plan.sem);
|
||||
params.set("ver", plan.ver);
|
||||
params.set("grp", plan.grp);
|
||||
}
|
||||
try {
|
||||
const r = await fetch("/api/detail?" + params.toString());
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(j.error);
|
||||
const rows = j.rows || {};
|
||||
body.innerHTML = `
|
||||
<table>
|
||||
<tr><td>Datum / Stunde</td><td>${esc(ev.datum)} / Stunde ${esc(stunde)}</td></tr>
|
||||
<tr><td>LV-Code</td><td><b>${esc(ev.lv)}</b></td></tr>
|
||||
<tr><td>Zeit</td><td>${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}</td></tr>
|
||||
${rows["LektorIn"] ? `<tr><td>LektorIn</td><td>${esc(rows["LektorIn"])}</td></tr>` : ""}
|
||||
${rows["Ort"] ? `<tr><td>Ort</td><td>${esc(rows["Ort"])}</td></tr>` : ""}
|
||||
${rows["Lehrfach"] ? `<tr><td>Lehrfach</td><td>${esc(rows["Lehrfach"])}</td></tr>` : ""}
|
||||
${rows["Bezeichnung"] ? `<tr><td>Bezeichnung</td><td>${esc(rows["Bezeichnung"])}</td></tr>` : ""}
|
||||
${rows["Verband"] ? `<tr><td>Gruppe</td><td>${esc(rows["Verband"])}</td></tr>` : ""}
|
||||
${rows["Info"] ? `<tr><td>Info</td><td>${esc(rows["Info"])}</td></tr>` : ""}
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
body.innerHTML = `<p style="color:var(--red)">Details konnten nicht geladen werden: ${esc(e.message)}</p>
|
||||
<table>
|
||||
<tr><td>Datum</td><td>${esc(ev.datum)}</td></tr>
|
||||
<tr><td>Zeit</td><td>${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}</td></tr>
|
||||
<tr><td>LV</td><td><b>${esc(ev.lv)}</b></td></tr>
|
||||
<tr><td>LektorIn</td><td>${esc(ev.lektor)}</td></tr>
|
||||
<tr><td>Gruppe</td><td>${esc(ev.gruppe)}</td></tr>
|
||||
<tr><td>Ort</td><td>${esc(ev.ort)}</td></tr>
|
||||
</table>`;
|
||||
}
|
||||
}
|
||||
function closeDetail() {
|
||||
$("#detail-modal").classList.remove("open");
|
||||
}
|
||||
|
||||
/* ---------- CSV ---------- */
|
||||
function downloadCSV() {
|
||||
const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
let csv;
|
||||
if (window.__PLAN_DATA__) {
|
||||
csv = "Datum;Wochentag;Beginn;Ende;LV;Lektor;Gruppe;Ort\n" + plan.events.map((e) =>
|
||||
[e.start.slice(0, 10), names[e.s.getDay()], fmtTime(e.s), fmtTime(e.e2),
|
||||
e.lv, e.lektor, e.gruppe, e.ort].map((x) => `"${String(x).replace(/"/g, '""')}"`).join(";")
|
||||
).join("\n");
|
||||
} else if (window.__LIST_DATA__) {
|
||||
csv = "Nr;Nachname;Vorname;Geschlecht;Matrikelnummer;Mail\n" + window.__LIST_DATA__.map((s, i) =>
|
||||
[i + 1, s.nachname, s.vorname, s.geschlecht, s.matrikel, s.mail].map((x) => `"${String(x).replace(/"/g, '""')}"`).join(";")
|
||||
).join("\n");
|
||||
} else return;
|
||||
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = (window.__PLAN_DATA__ ? `lvplan_${window.__PLAN_DATA__.semester}` : `namensliste`) + ".csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
/* ================= Namensliste ================= */
|
||||
function filterList() {
|
||||
const q = ($("#list-search").value || "").toLowerCase().trim();
|
||||
let n = 0;
|
||||
for (const tr of $("#list-table").querySelectorAll("tbody tr")) {
|
||||
const hay = (tr.dataset.search || "").toLowerCase();
|
||||
const show = !q || hay.includes(q);
|
||||
tr.style.display = show ? "" : "none";
|
||||
if (show) n++;
|
||||
}
|
||||
const c = $("#student-count");
|
||||
if (c) c.textContent = q ? `${n}` : window.__LIST_COUNT__ || n;
|
||||
}
|
||||
|
||||
/* ================= Matrikel decoder (home) ================= */
|
||||
function decodeMat() {
|
||||
const m = $("#decode-input").value.trim();
|
||||
const box = $("#decode-result");
|
||||
if (!/^\d{9,10}$/.test(m)) {
|
||||
box.classList.remove("show");
|
||||
return;
|
||||
}
|
||||
const stgKz = m.slice(-6, -3);
|
||||
const nr = m.slice(-3);
|
||||
const year = m.slice(0, 2);
|
||||
const code = m.slice(2, 4);
|
||||
let start = `20${year}`;
|
||||
if (code === "10") start = `WS 20${year}`;
|
||||
else if (code === "20") start = `SS 20${year}`;
|
||||
const stg = (window.__STGS__ || []).find((s) => s.kz === stgKz);
|
||||
box.innerHTML = `
|
||||
<div class="kv">
|
||||
<div class="item" style="background:#fff"><div class="k">Startsemester</div><div class="v">${esc(start)}</div></div>
|
||||
<div class="item" style="background:#fff"><div class="k">Studiengang</div>
|
||||
<div class="v">${stg ? `${esc(stg.code)} - ${esc(stg.name)}` : `KZ ${esc(stgKz)}`}</div></div>
|
||||
<div class="item" style="background:#fff"><div class="k">Fortlaufnummer</div><div class="v">${esc(nr)}</div></div>
|
||||
<div class="item" style="background:#fff"><div class="k">Plan ansehen</div>
|
||||
<div class="v"><a class="btn btn-sm" href="/plan?uid=${esc(m)}" style="margin-top:2px">Studentenplan →</a></div></div>
|
||||
</div>`;
|
||||
box.classList.add("show");
|
||||
}
|
||||
|
||||
/* ================= Verband-Selects dynamisch ================= */
|
||||
async function reloadVerbands() {
|
||||
const sel = $("#ver-select");
|
||||
if (!sel) return;
|
||||
const kz = $("#stg-select").value;
|
||||
const sem = $("#sem-select").value;
|
||||
const current = sel.value;
|
||||
try {
|
||||
const r = await fetch(`/api/verbands?kz=${encodeURIComponent(kz)}&sem=${encodeURIComponent(sem)}`);
|
||||
if (!r.ok) return;
|
||||
const verbs = await r.json();
|
||||
if (!Array.isArray(verbs) || !verbs.length) return;
|
||||
const labels = { V: " (Vollzeit)", B: " (berufsbegl.)" };
|
||||
sel.innerHTML = verbs.map((v) =>
|
||||
`<option value="${esc(v)}"${v === current ? " selected" : ""}>${esc(v)}${labels[v] || ""}</option>`).join("");
|
||||
if (current && !verbs.includes(current)) sel.selectedIndex = 0;
|
||||
} catch (e) { /* Keep static options */ }
|
||||
}
|
||||
|
||||
/* ================= boot ================= */
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
if (window.__PLAN_DATA__) initPlan();
|
||||
if (window.__STGS__) decodeMat();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeDetail();
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
:root {
|
||||
--blue: #0a5c99;
|
||||
--blue-dark: #083f6b;
|
||||
--blue-light: #e8f1f9;
|
||||
--ink: #1c2733;
|
||||
--muted: #64748b;
|
||||
--line: #e2e8f0;
|
||||
--bg: #f5f7fa;
|
||||
--card: #ffffff;
|
||||
--green: #15803d;
|
||||
--red: #b91c1c;
|
||||
--radius: 10px;
|
||||
--shadow: 0 1px 3px rgba(16, 42, 67, .08), 0 4px 14px rgba(16, 42, 67, .06);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
a { color: var(--blue); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
.topbar { background: var(--blue-dark); color: #fff; position: sticky; top: 0; z-index: 50; }
|
||||
.topbar-inner { display: flex; align-items: center; justify-content: space-between; height: 56px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; color: #fff; }
|
||||
.brand:hover { text-decoration: none; }
|
||||
.brand-badge {
|
||||
background: #fff; color: var(--blue-dark); font-weight: 800;
|
||||
padding: 3px 8px; border-radius: 6px; font-size: 14px; letter-spacing: .5px;
|
||||
}
|
||||
.brand-text { font-weight: 700; font-size: 17px; }
|
||||
.brand-text small { font-weight: 400; opacity: .75; margin-left: 6px; font-size: 12px; }
|
||||
.topbar nav { display: flex; gap: 4px; }
|
||||
.topbar nav a {
|
||||
color: rgba(255,255,255,.85); padding: 7px 12px; border-radius: 8px; font-size: 14px;
|
||||
}
|
||||
.topbar nav a:hover { background: rgba(255,255,255,.12); text-decoration: none; }
|
||||
.topbar nav a.active { background: rgba(255,255,255,.18); color: #fff; font-weight: 600; }
|
||||
|
||||
main.container { padding-top: 24px; padding-bottom: 48px; min-height: calc(100vh - 130px); }
|
||||
|
||||
/* ---------- cards / panels ---------- */
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px;
|
||||
}
|
||||
.card + .card { margin-top: 16px; }
|
||||
.card h2 { margin: 0 0 12px; font-size: 17px; }
|
||||
.card h3 { margin: 16px 0 8px; font-size: 15px; }
|
||||
.page-title { font-size: 24px; margin: 4px 0 20px; font-weight: 700; }
|
||||
.page-sub { color: var(--muted); margin-top: -14px; margin-bottom: 20px; }
|
||||
|
||||
/* ---------- forms ---------- */
|
||||
.form-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
||||
.field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.field label { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
|
||||
.field input, .field select {
|
||||
padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
|
||||
font-size: 14px; background: #fff; min-width: 130px; color: var(--ink);
|
||||
}
|
||||
.field input:focus, .field select:focus { outline: 2px solid var(--blue-light); border-color: var(--blue); }
|
||||
.btn {
|
||||
display: inline-block; padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line);
|
||||
background: #fff; color: var(--ink); font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: var(--blue-light); text-decoration: none; border-color: var(--blue); }
|
||||
.btn-primary { background: var(--blue); border-color: var(--blue); color: #fff; }
|
||||
.btn-primary:hover { background: var(--blue-dark); }
|
||||
.btn-sm { padding: 5px 10px; font-size: 13px; }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
table.data { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
|
||||
table.data th {
|
||||
text-align: left; padding: 9px 10px; background: var(--blue-light);
|
||||
color: var(--blue-dark); font-size: 12px; text-transform: uppercase; letter-spacing: .4px;
|
||||
border-bottom: 2px solid var(--blue);
|
||||
}
|
||||
table.data td { padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||
table.data tr:hover td { background: #f8fafc; }
|
||||
table.data .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 9px; border-radius: 99px; font-size: 12px;
|
||||
font-weight: 600; background: var(--blue-light); color: var(--blue-dark);
|
||||
}
|
||||
.badge-muted { background: #eef2f7; color: var(--muted); }
|
||||
.count-pill {
|
||||
display: inline-block; background: var(--blue); color: #fff; border-radius: 99px;
|
||||
padding: 1px 10px; font-size: 13px; font-weight: 700; margin-left: 8px;
|
||||
}
|
||||
|
||||
/* ---------- week grid ---------- */
|
||||
.weekbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.weekbar .week-label { font-weight: 700; font-size: 17px; }
|
||||
.weekbar .week-range { color: var(--muted); font-size: 14px; }
|
||||
.weekbar .nav-btns { display: flex; gap: 6px; }
|
||||
.week-scroll { overflow-x: auto; }
|
||||
.week-grid {
|
||||
display: grid; min-width: 860px;
|
||||
grid-template-columns: 56px repeat(7, 1fr);
|
||||
border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.wg-corner { background: var(--blue-light); }
|
||||
.wg-dayhead {
|
||||
padding: 8px 4px; text-align: center; font-size: 12px; font-weight: 700;
|
||||
background: var(--blue-light); color: var(--blue-dark); border-left: 1px solid var(--line);
|
||||
}
|
||||
.wg-dayhead .d { display: block; font-size: 15px; }
|
||||
.wg-dayhead.today { background: var(--blue); color: #fff; }
|
||||
.wg-dayhead.weekend { background: #f1f5f9; color: var(--muted); }
|
||||
.wg-dayhead.today.weekend { background: var(--blue); color: #fff; }
|
||||
.wg-time {
|
||||
font-size: 11px; color: var(--muted); text-align: right; padding-right: 6px;
|
||||
border-top: 1px solid var(--line); height: 48px; position: relative;
|
||||
}
|
||||
.wg-time span { position: absolute; top: -7px; background: #fff; padding-right: 4px; }
|
||||
.wg-col { position: relative; border-left: 1px solid var(--line); border-top: 1px solid var(--line); }
|
||||
.wg-col.weekend { background: #fafbfc; }
|
||||
.wg-slotline { position: absolute; left: 0; right: 0; border-top: 1px dashed #eef1f5; }
|
||||
.ev {
|
||||
position: absolute; left: 2px; right: 2px; border-radius: 6px; color: #fff;
|
||||
font-size: 11.5px; padding: 3px 6px; overflow: hidden; cursor: pointer;
|
||||
border: none; text-align: left; line-height: 1.25; min-height: 14px;
|
||||
}
|
||||
.ev:hover { filter: brightness(1.12); z-index: 5; }
|
||||
.ev .t { font-weight: 700; display: block; }
|
||||
.ev .sub { opacity: .9; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* ---------- legend ---------- */
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.legend .item { display: flex; align-items: center; gap: 6px; font-size: 12.5px; }
|
||||
.legend .dot { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
.tabs { display: flex; gap: 4px; border-bottom: 2px solid var(--line); margin-bottom: 18px; }
|
||||
.tabs button {
|
||||
background: none; border: none; padding: 10px 16px; font-size: 14px; font-weight: 600;
|
||||
color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px;
|
||||
}
|
||||
.tabs button.active { color: var(--blue); border-bottom-color: var(--blue); }
|
||||
.tabpane { display: none; }
|
||||
.tabpane.active { display: block; }
|
||||
|
||||
/* ---------- misc ---------- */
|
||||
.empty { text-align: center; color: var(--muted); padding: 40px 0; }
|
||||
.kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }
|
||||
.kv .item { background: var(--blue-light); border-radius: 8px; padding: 10px 14px; }
|
||||
.kv .item .k { font-size: 11px; text-transform: uppercase; color: var(--blue-dark); font-weight: 700; letter-spacing: .4px; }
|
||||
.kv .item .v { font-size: 15px; font-weight: 600; }
|
||||
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 13px; }
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
.modal-back {
|
||||
position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100;
|
||||
display: none; align-items: center; justify-content: center; padding: 20px;
|
||||
}
|
||||
.modal-back.open { display: flex; }
|
||||
.modal {
|
||||
background: #fff; border-radius: 12px; max-width: 520px; width: 100%;
|
||||
padding: 22px; box-shadow: 0 20px 50px rgba(0,0,0,.25);
|
||||
}
|
||||
.modal h3 { margin: 0 0 12px; font-size: 18px; }
|
||||
.modal .close { float: right; border: none; background: none; font-size: 22px; cursor: pointer; color: var(--muted); }
|
||||
.modal table { width: 100%; font-size: 14px; border-collapse: collapse; }
|
||||
.modal table td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
.modal table td:first-child { color: var(--muted); width: 35%; }
|
||||
|
||||
/* ---------- home ---------- */
|
||||
.hero { margin-bottom: 24px; }
|
||||
.hero h1 { font-size: 28px; margin: 0 0 6px; }
|
||||
.hero p { color: var(--muted); margin: 0; max-width: 720px; }
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
|
||||
.tile {
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); padding: 22px; display: block; color: var(--ink);
|
||||
transition: transform .08s ease, border-color .08s ease;
|
||||
}
|
||||
.tile:hover { text-decoration: none; border-color: var(--blue); transform: translateY(-2px); }
|
||||
.tile .icon { font-size: 26px; margin-bottom: 10px; }
|
||||
.tile h3 { margin: 0 0 6px; font-size: 17px; color: var(--blue-dark); }
|
||||
.tile p { margin: 0; color: var(--muted); font-size: 13.5px; }
|
||||
|
||||
.decoder-result { margin-top: 14px; display: none; }
|
||||
.decoder-result.show { display: block; }
|
||||
|
||||
.footer { border-top: 1px solid var(--line); padding: 16px 0; color: var(--muted); font-size: 13px; background: #fff; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar-inner { flex-direction: column; height: auto; padding: 8px 0; gap: 6px; }
|
||||
.topbar nav { flex-wrap: wrap; justify-content: center; }
|
||||
.form-row { flex-direction: column; align-items: stretch; }
|
||||
.field input, .field select { min-width: 0; width: 100%; }
|
||||
}
|
||||
Reference in New Issue
Block a user