chore: some repo cleanup
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.gemini
|
||||
.worktrees/
|
||||
docs/
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
# Handoff: Tutormanager — Anwesenheit & Notizen
|
||||
|
||||
## Overview
|
||||
|
||||
Tutormanager is an attendance + per-student notes tool for tutorien. The flagship use case is the FP-Tutorium (~19 students, Thursdays). Tutors run a short check-in routine at the start of each Projektstunde: open a slot, project a check-in URL on the beamer, watch students fill the seat map from their phones/laptops, lock the slot, and use the rest of the hour with a live seat plan that lets them jot per-student observations as they go.
|
||||
|
||||
The full architecture is described in `SPEC.md` (your existing approved spec). This handoff covers **only the frontend**. The HTML prototypes in this folder are **design references** — pixel-accurate mocks of the intended look and behaviour. The task is to **recreate them in SvelteKit** (`adapter-static`, SPA) per the approved stack, talking to the Rust/Axum backend defined in the spec.
|
||||
|
||||
## About the Design Files
|
||||
|
||||
The files in this bundle are design references created with React + inline JSX in static HTML. Don't ship them. Re-implement the same screens as Svelte components in the SvelteKit app, using your own component split and your own state stores. The HTML/CSS choices (paper background, ruled notebook, oxblood accent, Source Serif 4 + Inter + JetBrains Mono + Caveat) should transfer 1:1.
|
||||
|
||||
Open `Tutormanager.html` in a browser to view the canvas with all screens. `styles.css` holds the design tokens.
|
||||
|
||||
## Fidelity
|
||||
|
||||
**High-fidelity.** Colors, typography, spacing, and component states are final. Recreate pixel-perfectly in Svelte using the design tokens listed below.
|
||||
|
||||
---
|
||||
|
||||
## Design Tokens
|
||||
|
||||
All in `styles.css` as CSS custom properties. Bring them in verbatim (e.g. as a `app.css` imported in `+layout.svelte`).
|
||||
|
||||
### Colors
|
||||
|
||||
| Token | Value | Usage |
|
||||
|---|---|---|
|
||||
| `--paper` | `#f4efe6` | App background |
|
||||
| `--paper-2` | `#ebe4d6` | Subtle alt rows, avatar bg |
|
||||
| `--paper-3` | `#ded4c0` | Slightly stronger paper |
|
||||
| `--rule` | `#c9bfa9` | Borders, dividers |
|
||||
| `--rule-soft` | `#d9d0bb` | Softer dividers |
|
||||
| `--ink` | `#1f1b16` | Primary text, primary buttons |
|
||||
| `--ink-2` | `#3a342b` | Body text |
|
||||
| `--ink-3` | `#6b6356` | Secondary / small |
|
||||
| `--ink-4` | `#968b7a` | Tertiary, placeholders |
|
||||
| `--accent` | `#8a2c1f` | Oxblood — accents, "absent", lock, stamps |
|
||||
| `--accent-soft` | `#c66a5b` | — |
|
||||
| `--highlight` | `#f1d36a` | Highlighter yellow |
|
||||
| `--highlight-soft` | `#f5e3a4` | Marker underline fill |
|
||||
| `--green` | `#4a6b3a` | "anwesend" / present |
|
||||
| `--red` | `#8a2c1f` | "fehlt" / locked |
|
||||
| `--amber` | `#b07d2a` | "offen" / open slot |
|
||||
|
||||
Card surface is `#fbf7ee` (slightly lighter than paper). Seat map background is `#f7f1e3`. Tables in seat map are `#e8dec5` with `var(--ink-2)` border.
|
||||
|
||||
### Typography
|
||||
|
||||
| Token | Stack |
|
||||
|---|---|
|
||||
| `--serif` | `"Source Serif 4", "Source Serif Pro", "EB Garamond", Georgia, serif` |
|
||||
| `--sans` | `"Inter", "Helvetica Neue", Helvetica, Arial, sans-serif` |
|
||||
| `--mono` | `"JetBrains Mono", "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace` |
|
||||
| Marginalia | `"Caveat"` (used only for handwritten notes — see `.handwritten`) |
|
||||
|
||||
Load via Google Fonts (single `<link>`):
|
||||
```
|
||||
Source Serif 4 (300–700, italic), Inter (400/500/600), JetBrains Mono (400/500/600), Caveat (400/500/600)
|
||||
```
|
||||
|
||||
Type scale (utility classes in `styles.css`):
|
||||
|
||||
- `.h1` — Source Serif 4, 44/1.05, weight 500, letter-spacing -0.02em
|
||||
- `.h2` — Source Serif 4, 28/1.15, weight 500, letter-spacing -0.01em
|
||||
- `.h3` — Source Serif 4, 20/1.2, weight 500
|
||||
- `.eyebrow` — JetBrains Mono, 11px, uppercase, 0.14em tracking, `--ink-3`
|
||||
- `.body` — Inter, 14/1.5
|
||||
- `.small` — Inter, 12, `--ink-3`
|
||||
- `.tiny` — Inter, 11, `--ink-3`
|
||||
|
||||
### Spacing
|
||||
|
||||
8px-aligned grid. Common values seen in the design: 4, 6, 8, 10, 12, 14, 16, 18, 22, 24, 28, 32, 36.
|
||||
|
||||
### Radii
|
||||
|
||||
- 3–4px — most cards, inputs, buttons (paper feel — keep tight)
|
||||
- 999px — pills
|
||||
- 50% — avatars, seat circles
|
||||
|
||||
### Shadows
|
||||
|
||||
Avoid heavy shadows. Use a `1px 0 rgba(0,0,0,.03)` underline on cards. The seat-map container uses a subtle inset border instead of a box shadow.
|
||||
|
||||
### Special textures
|
||||
|
||||
- `.paper-bg` — paper grain via inline SVG noise + 2 radial highlights. Applied to app root and large surfaces.
|
||||
- `.ruled` — repeating-linear-gradient producing 28px-spaced horizontal rules. Used inside the note textarea so handwritten text sits on lines.
|
||||
- `.marker` — gradient that paints `--highlight-soft` over the lower 32% of inline text — looks like a hand-drawn highlighter stroke. Use sparingly on h1/h2 keywords (one word per heading).
|
||||
- `.stamp` — rotated -4°, oxblood border + tint, monospace caps. Used for "PRÄSENT".
|
||||
- `.handwritten` — Caveat in oxblood, 18px. For marginalia / sketch annotations only.
|
||||
- `UnderlineStroke` — a tiny SVG hand-drawn underline beneath section titles. See `shared.jsx`.
|
||||
|
||||
---
|
||||
|
||||
## Information Architecture
|
||||
|
||||
```
|
||||
/ → redirect to /admin or /admin/login
|
||||
/admin/login → Login (anonymous)
|
||||
/admin → Dashboard (slots overview)
|
||||
/admin/live/:slotId → Hero · live seat map + notes panel
|
||||
/admin/attendance → Per-student / per-week matrix + export
|
||||
/admin/rooms → Layout editor (rooms list / canvas / props)
|
||||
/admin/rooms/:roomId → Same, with active room
|
||||
/admin/students → Students CRUD
|
||||
/s/:code → Student check-in (mobile + desktop responsive)
|
||||
```
|
||||
|
||||
The student check-in screen MUST be responsive: phone gets a single-column compact layout; ≥ 900px viewport gets the two-column laptop layout (map left, side panel right). Both are in this bundle.
|
||||
|
||||
---
|
||||
|
||||
## Screens
|
||||
|
||||
### 1. Login (`/admin/login`)
|
||||
|
||||
Centered 420px-wide card on `.paper-bg`. Brand wordmark "Tutor·manager" with oxblood `·`. Card title is `.h2` "Willkommen zurück" with an `UnderlineStroke` (110px wide). Two stacked inputs (E-Mail, Passwort), full-width primary button "Anmelden". Below the card a small Caveat marginalia "~ Donnerstags ab 14 Uhr ~".
|
||||
|
||||
### 2. Tutor shell
|
||||
|
||||
Two columns: 220px sidebar + main content.
|
||||
|
||||
**Sidebar** (`background: rgba(0,0,0,0.015)`, right border `--rule`):
|
||||
- Brand wordmark + version
|
||||
- "Kurs" card (course name + semester + weekday)
|
||||
- Nav list: Dashboard / Live · Sitzplan / Anwesenheit / Räume / Studierende. Active item: 4px oxblood dot + `rgba(31,27,22,0.08)` background.
|
||||
- Bottom: avatar circle + tutor name + email.
|
||||
|
||||
### 3. Dashboard (`/admin`)
|
||||
|
||||
- Header: eyebrow "Dashboard", `.h1` "Diese Woche, **Woche 04**" (the week number gets `.marker`), one-line subtitle. Right side: "Export" (ghost) + "Neuer Slot" (primary) buttons.
|
||||
- 4 stat cards in a row: "Anwesend gerade 14 / 19", "Ø Anwesenheit 84%", "Bonus vergeben 186 Punkte", "Offene Notizen 7". Big numbers in serif 32px, accent color when meaningful.
|
||||
- Slots table: columns Woche / Datum / Zeit / Raum / Status / Code / Eingecheckt / Aktionen. Status pills (open / closed / locked). Eingecheckt cell shows "n / total" + a 48×4px progress bar. Per-row primary action depends on status (Sperren / Öffnen / Anzeigen).
|
||||
|
||||
### 4. Live seat map + notes (HERO — `/admin/live/:slotId`)
|
||||
|
||||
Two-column grid: `1fr 380px`.
|
||||
|
||||
**Left column — seat map:**
|
||||
- Header row: "Sitzplan" (`.h3` + UnderlineStroke 70px) on the left; legend dots on the right (anwesend / frei / ausgewählt).
|
||||
- The seat map itself (see "Seat map component" below) at scale 0.85.
|
||||
- Tally row at bottom: 3 stat blocks (Anwesend `n/total`, Fehlt n, Bonus heute "+n Punkte") + spacer + ghost button "Manuell eintragen".
|
||||
|
||||
**Right column — roster + note editor (a card):**
|
||||
- Top: "Studierende" with present/absent count.
|
||||
- Scrollable roster (max-height ~220px). Each row: 22px ink avatar circle, name, oxblood dot if has note, monospace check-in time (or "—" for absent). Selected row: `rgba(31,27,22,0.06)` bg + 3px ink left border. Absent rows: 0.55 opacity, dashed avatar border, strikethrough name.
|
||||
- Bottom — note editor on `#fbf7ee`:
|
||||
- 32px ink avatar + selected student name (`.h3`) + "Sitzplatz {seat} · seit {time}" + "Präsent" stamp on the right.
|
||||
- "Notiz · Woche 04" eyebrow.
|
||||
- Textarea with `.ruled` background, Source Serif 4 15/28px, no border. Placeholder "Beobachtungen für diese Woche…".
|
||||
- Quick-tag chips below: "+ aktiv beteiligt", "+ stille:r Kämpfer:in", "+ verstanden ✓", "+ nochmal aufgreifen", "+ Rückfrage offen", "+ elegante Lösung".
|
||||
- Footer: "Auto-gespeichert · 14:23" left, "Notizen vergangener Wochen ↗" link right.
|
||||
|
||||
Page header above the grid:
|
||||
- Left side: eyebrow "Tutor:innen-Ansicht · Live", title "Woche 04 · **Donnerstag, 30. April 2026**" (date with `.marker`), subtitle line: course / room / time.
|
||||
- Right side: monospace check-in code "K7QJ-MX2P" with the URL beneath, status pill (offen), Kopieren / Sperren buttons.
|
||||
|
||||
### 5. Anwesenheit (`/admin/attendance`)
|
||||
|
||||
- Header `.h1` "Kursmatrix · **SS 2026**". Right: 3 ghost export buttons (CSV / Markdown / SQLite Backup).
|
||||
- Card with tab bar: "Pro Studierende:r" (active) / "Pro Woche" / "Notizen".
|
||||
- Matrix table: # / Studierende:r / W01 / W02 / W03 / W04 / Anwesend / Bonus / arrow. Present cell is a 22px square with green check on `rgba(74,107,58,0.14)`. Absent: em-dash. Bonus column shows a single 24px oxblood circled check (no number — only "got bonus or not").
|
||||
|
||||
### 6. Räume — Layout-Editor (`/admin/rooms/:roomId`)
|
||||
|
||||
Three-column grid: 210px / 1fr / 240px.
|
||||
|
||||
- **Left**: rooms list (selectable, oxblood left border on active), "+ Neuer Raum" button at bottom.
|
||||
- **Center** — toolbar + canvas + status bar:
|
||||
- Toolbar: 6 tool buttons (Auswählen / Sitz / Tisch / Tür / Fenster / Lücke), each with a small monospace glyph + label. Active tool is filled ink. Right side: zoom controls (− 78% +), "Raster: 24px".
|
||||
- Canvas: `#efe6d2` background with rulers along top (px ticks) and left, a centred seat map at scale 0.78. Selected element shows a rotating dashed oxblood ring + 4 small white drag-handle squares. Two Caveat marginalia annotations.
|
||||
- Bottom status bar: element counts, auto-save timestamp.
|
||||
- **Right**: stacked cards.
|
||||
- "Auswahl" card: shows kind + id ("Sitz T2-3"), then editable fields (Bezeichnung, Tisch, X / Y in px, ∅ / Rotation). Below: "Aktionen" — Duplizieren (⌘D), An Tisch ausrichten, Löschen (⌫, oxblood text).
|
||||
- "Ebenen" card: tree-like list of layers — walls, 4 tables with their seats, podium, beamer, door, window. Selected layer (T2 group) gets oxblood text + tinted bg.
|
||||
|
||||
### 7. Studierende (`/admin/students`)
|
||||
|
||||
Header with title + search input + "CSV importieren" + "Hinzufügen". Card with table: # / Name (avatar + name) / Anwesend (n/4) / Bonus (single circled check or em-dash) / Notizen (oxblood dot + count) / Letzte Sitzung / arrow.
|
||||
|
||||
### 8. Student check-in — phone (`/s/:code`, viewport < 900px)
|
||||
|
||||
Four states (each on `.paper-bg`, ~360×760 viewport):
|
||||
|
||||
1. **Name picker**: eyebrow "Check-in · K7QJ-MX2P", title "Wer bist du?", search input, scrollable name list (avatar + name, divider lines). Footer hint "Nicht in der Liste? Sprich die Tutor:in an."
|
||||
2. **Seat picker**: "Hallo, Carla 👋" eyebrow, title "Wähle deinen Sitz", scaled seat map (0.46), legend, info card "Du kannst deinen Sitz wechseln, solange der Slot offen ist."
|
||||
3. **Confirmed**: title "Du sitzt auf **T1-3**" (T1-3 with `.marker`), "Präsent" stamp top-right, scaled map showing only own seat highlighted oxblood, status card with "OFFEN" pill + lock-time hint, ghost "Sitz wechseln" button.
|
||||
4. **Locked / read-only**: title "Anwesenheit erfasst", scaled map (own seat still oxblood), info card with lock icon + "Check-in für diesen Slot ist geschlossen. Bonus wurde gutgeschrieben.", footer with next session date.
|
||||
|
||||
### 9. Student check-in — laptop (`/s/:code`, viewport ≥ 900px)
|
||||
|
||||
Three states:
|
||||
|
||||
1. **Name picker**: centered 560px column. Header eyebrow + .h1 "Wer bist du?" + subtitle. Card with autofocused search and a 2-column grid of name buttons. Footer keyboard-shortcut tip.
|
||||
2. **Seat picker**: two-column `1fr 360px`. Left: large seat map (scale 0.78) + legend below. Right side panel (subtle bg, left border): "Sitzung" block (course / date+time / room), "Eingecheckt als" with avatar + "wechseln" link, "Hinweise" bullet list, bottom yellow-tinted card "Bonus +3 Punkte sobald du einen Sitz wählst."
|
||||
3. **Confirmed (with season)**: same two-column. Left: header "Du sitzt auf **T1-3**" + stamp, large map with own seat highlighted, two ghost buttons (Sitz wechseln / Drucken). Right side: "Slot-Status" with open pill, "Deine Saison" — 4-row weekly history list (W01–W04, date, seat code, green check or em-dash; current week in oxblood), oxblood-tinted card "Bonus gesamt 9 / 12 Punkte · 3 von 4 Tutorien besucht".
|
||||
|
||||
---
|
||||
|
||||
## Components to build
|
||||
|
||||
| Svelte component | Notes |
|
||||
|---|---|
|
||||
| `<StatusPill status="open" \| "closed" \| "locked" \| "present" \| "absent" />` | See `.pill` and `.pill.{state}` in `styles.css`. |
|
||||
| `<UnderlineStroke width={110} color="..." />` | Inline SVG path — see `shared.jsx`. |
|
||||
| `<Stamp>Präsent</Stamp>` | `.stamp` class. |
|
||||
| `<Eyebrow>` / `<H1>` / `<H2>` / `<H3>` | Or just utility classes. |
|
||||
| `<Tally label value total suffix accent />` | Stat block in serif. |
|
||||
| `<StatCard label value suffix hint accent />` | Card variant for the dashboard. |
|
||||
| `<Field label suffix mono />` | Labelled input with optional unit suffix in mono. |
|
||||
| `<TutorShell active>` | Sidebar + main slot. |
|
||||
| `<SeatMap variant="tutor" \| "student" \| "student-self" assignments selectedStudent ownSeat onSeatClick scale />` | The big one — see below. |
|
||||
| `<NoteEditor studentId weekNr />` | Roster + ruled textarea + tag chips. |
|
||||
| `<MarkerText>` | Inline span with `.marker`. |
|
||||
|
||||
### `<SeatMap>` — the central component
|
||||
|
||||
Top-down floor plan, absolutely-positioned layers inside a fixed-size design space (760×460 px) that gets transform-scaled by the `scale` prop.
|
||||
|
||||
**Static structural layers** (drawn in this order):
|
||||
1. Inner ruled grid (24px graph paper, very low alpha).
|
||||
2. Walls — 2px ink-2 border rounded 2px.
|
||||
3. Window — 6×220px tall blue-tinted strip on the left wall with a horizontal split line. Mono "Fenster" label rotated -90°.
|
||||
4. Door — gap in bottom wall (70×4px) covered with paper colour, an SVG door arc (`stroke-dasharray="2 2"`), and a "Tür" label.
|
||||
5. Beamer — small black bar at top.
|
||||
6. Podium / Pult — 190×38 ruled rectangle, mono caps "Pult · Tutor:in".
|
||||
7. Tables — 4 rectangles (200×70px) at fixed coords. Filled `#e8dec5` with ink-2 border + italic serif label "T1"–"T4" centred at 0.35 alpha.
|
||||
|
||||
**Seats** — generated from tables: 5 per table.
|
||||
- Top edge (y = table.y − 22): 2 seats at x = table.x + table.w*{0.28, 0.72}.
|
||||
- Bottom edge (y = table.y + table.h + 22): 2 seats, same x ratios.
|
||||
- Head (short edge, x = table.x + table.w + 26): 1 seat at table.y + table.h/2.
|
||||
|
||||
Seat = 36px circle button, 1.5px border. Per `variant`:
|
||||
|
||||
- **tutor**: occupied seat shows ink avatar with student initials in white. Selected: filled ink + 3px highlight-soft outer ring. Hover: `#e0d4b6` fill. Free: paper bg + ink-4 border.
|
||||
- **student**: occupied = `#d6cdb5` (no name shown — privacy). Free = paper bg + ink-2 border. Own seat (after pick) = oxblood fill, white star ★ glyph.
|
||||
- **student-self** (read-only): same colours, no clicks, own seat = oxblood + ★, others greyed if occupied.
|
||||
|
||||
Compass mark in bottom-right (mono "N" + small SVG arrow).
|
||||
|
||||
The layout JSON the backend stores must round-trip these elements: `[{id, label, x, y, width, height, type}]` with `type ∈ "seat" | "table" | "gap" | "door" | "window" | "wall" | "beamer" | "podium"`. The component takes that JSON as input — don't hardcode the room.
|
||||
|
||||
---
|
||||
|
||||
## Interactions & state
|
||||
|
||||
- **Slot status transitions** (tutor): `closed → open → locked`. Opening generates the 8-char code (server-side per spec). Closed/locked rows show no code. The UI must atomically reflect "open ⇒ has code".
|
||||
- **Seat click (tutor live view)**: selects that student in the roster; note editor scrolls/loads. Double-click could open the per-student timeline.
|
||||
- **Note editor**: debounced auto-save (~600ms) → PUT note. Show "Auto-gespeichert · HH:MM" timestamp on success.
|
||||
- **Quick tag chips**: on click, append the tag (with leading newline if note non-empty) to the note text, then save.
|
||||
- **Student seat pick**: optimistic UI; on HTTP 409 ("seat taken"), revert and toast "Platz schon vergeben — bitte einen anderen wählen."
|
||||
- **Seat change**: while `open`, click another free seat → confirmation toast → previous seat goes free immediately.
|
||||
- **Locked**: pointer-events: none on seats; only own seat remains oxblood-highlighted.
|
||||
- **Live polling**: poll the slot every 5–8s while open to refresh assignments and check-in count.
|
||||
- **Marker animation**: the selection ring in the room editor uses `@keyframes spin 12s linear infinite`. Disable in `prefers-reduced-motion`.
|
||||
|
||||
---
|
||||
|
||||
## State management
|
||||
|
||||
Suggest one Svelte store per domain:
|
||||
|
||||
- `auth` — JWT token, current tutor.
|
||||
- `course` — currently selected course (and tutors' available courses).
|
||||
- `slots` — slots for the active course/week.
|
||||
- `liveSlot` — assignments + notes for the slot currently shown in the live view; reactive on poll.
|
||||
- `room` — layout JSON of the room being edited; also handles selection + tool state for the editor.
|
||||
|
||||
The check-in page is its own minimal app state: `{ code, slot, room, ownSeat, ownStudentId }`, no auth store.
|
||||
|
||||
---
|
||||
|
||||
## Copy
|
||||
|
||||
All UI is in **German**. Specific strings used in the design:
|
||||
|
||||
- "Anwesend", "Fehlt", "Bonus heute", "Eingecheckt", "Sitzplan", "Studierende", "Notiz · Woche {n}".
|
||||
- "Manuell eintragen", "Sperren", "Öffnen", "Anzeigen", "Kopieren", "Speichern".
|
||||
- "Wer bist du?", "Wähle deinen Sitz", "Du sitzt auf {seat}", "Anwesenheit erfasst".
|
||||
- "Nicht in der Liste? Sprich die Tutor:in an.", "Du kannst deinen Sitz wechseln, solange der Slot offen ist."
|
||||
- Status pill labels: OFFEN / GESCHL. / GESPERRT / ANWESEND / FEHLT.
|
||||
- Stamp: PRÄSENT.
|
||||
|
||||
The sample course is "Funktionale Programmierung", semester "SS 2026", room "BC2 1.103", weekday "Donnerstag", tutorin "Lina Puchstein". Replace with real data from the backend.
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
- **Fonts**: Source Serif 4, Inter, JetBrains Mono, Caveat — all from Google Fonts.
|
||||
- **Icons**: tiny inline SVGs in `shared.jsx` (`Icon.check`, `Icon.x`, `Icon.lock`, `Icon.open`, `Icon.copy`, `Icon.edit`, `Icon.download`, `Icon.arrow`, `Icon.search`, `Icon.plus`). Re-use them as Svelte components or swap to Lucide if you prefer — they're trivial.
|
||||
- **Paper grain**: inline SVG noise filter in `.paper-bg`. Copy verbatim.
|
||||
- **No image assets**.
|
||||
|
||||
---
|
||||
|
||||
## Files in this bundle
|
||||
|
||||
| File | What's in it |
|
||||
|---|---|
|
||||
| `Tutormanager.html` | Entry point — design canvas hosting all artboards. Open in a browser. |
|
||||
| `styles.css` | All design tokens + utility classes. Copy into the SvelteKit app. |
|
||||
| `shared.jsx` | Sample course + students + room layout + `StatusPill`, `UnderlineStroke`, `Icon.*` |
|
||||
| `seatmap.jsx` | `<SeatMap>` and the `<TutorLiveView>` hero — main reference. |
|
||||
| `admin.jsx` | Tutor shell, Dashboard, Anwesenheit-Matrix, Studierende, Login. |
|
||||
| `rooms.jsx` | Layout editor with toolbar, canvas, properties + layers panel. |
|
||||
| `student.jsx` | Phone variants of the four student states. |
|
||||
| `student-desktop.jsx` | Laptop variant of the three student states. |
|
||||
| `design-canvas.jsx`, `browser-window.jsx`, `ios-frame.jsx` | Presentation chrome — for the design canvas only, do not port. |
|
||||
|
||||
The design canvas grouping (sections / artboards) is purely a presentation device. The actual app has the route structure listed under "Information Architecture".
|
||||
|
||||
---
|
||||
|
||||
## Implementation tips
|
||||
|
||||
- Start with `styles.css` and the type/colour tokens. Wire fonts.
|
||||
- Build `<SeatMap>` early — it's the centrepiece and the only non-trivial component.
|
||||
- The room editor's drag-and-drop is real work — the spec says layouts are stored as JSON. A DnD library (`svelte-dnd-action`) is fine; snap to a 24px grid. The visual shown is the *finished* state with one seat selected.
|
||||
- Do **not** add visual flourishes (more icons, gradients, hero images). The look comes from restraint + the paper texture + the marker/stamp accents.
|
||||
- Respect `prefers-reduced-motion` — disable the room-editor selection-ring spin and any hover scale.
|
||||
- The handwritten Caveat marginalia is a flavour element. Keep them sparse — at most one per screen.
|
||||
@@ -1,192 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Tutormanager — Anwesenheit & Notizen</title>
|
||||
<meta name="viewport" content="width=1440" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,300..700;1,8..60,300..700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&family=Caveat:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #e6dfcf; }
|
||||
body { font-family: var(--sans); color: var(--ink); -webkit-font-smoothing: antialiased; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="browser-window.jsx"></script>
|
||||
<script type="text/babel" src="ios-frame.jsx"></script>
|
||||
<script type="text/babel" src="shared.jsx"></script>
|
||||
<script type="text/babel" src="seatmap.jsx"></script>
|
||||
<script type="text/babel" src="admin.jsx"></script>
|
||||
<script type="text/babel" src="rooms.jsx"></script>
|
||||
<script type="text/babel" src="student.jsx"></script>
|
||||
<script type="text/babel" src="student-desktop.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
function App() {
|
||||
const [adminTab, setAdminTab] = React.useState("live");
|
||||
|
||||
// Browser-window-wrapped tutor screen (the supporting prototype)
|
||||
const TutorBrowser = ({ children, url = "tutor.puchstein.dev/admin", width = 1180, height = 760 }) => (
|
||||
<ChromeWindow
|
||||
tabs={[{ title: "Tutormanager · Admin" }]}
|
||||
activeIndex={0}
|
||||
url={url}
|
||||
width={width}
|
||||
height={height}>
|
||||
<div style={{ width: "100%", height: "100%", background: "var(--paper)", overflow: "hidden" }}>
|
||||
{children}
|
||||
</div>
|
||||
</ChromeWindow>
|
||||
);
|
||||
|
||||
// iOS-wrapped student screen
|
||||
const StudentPhone = ({ children, time = "14:03", title = "tutor.puchstein.dev" }) => (
|
||||
<IOSDevice width={360} height={760}>
|
||||
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", background: "var(--paper)" }}>
|
||||
<IOSStatusBar time={time} />
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</IOSDevice>
|
||||
);
|
||||
|
||||
return (
|
||||
<DesignCanvas>
|
||||
{/* ── HERO: Tutor live seat map + notes ────────────────────────── */}
|
||||
<DCSection id="hero" title="Tutor:innen Live-Ansicht — Sitzplan & Notizen"
|
||||
subtitle="Die Hauptansicht donnerstags um 14 Uhr: Wer ist da, wo sitzen sie, was fällt mir auf?">
|
||||
|
||||
<DCArtboard id="hero-split" label="A · Geteilt (empfohlen)" width={1320} height={820}>
|
||||
<TutorBrowser width={1320} height={820}>
|
||||
<TutorShell active="live" onNav={() => {}}>
|
||||
<TutorLiveView variant="split" />
|
||||
</TutorShell>
|
||||
</TutorBrowser>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="hero-focus" label="B · Notiz im Fokus" width={1320} height={820}>
|
||||
<TutorBrowser width={1320} height={820}>
|
||||
<TutorShell active="live" onNav={() => {}}>
|
||||
<TutorLiveView variant="split" />
|
||||
</TutorShell>
|
||||
</TutorBrowser>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
{/* ── Tutor supporting screens ─────────────────────────────────── */}
|
||||
<DCSection id="admin" title="Tutor:innen-Panel — übrige Bereiche"
|
||||
subtitle="Dashboard, Anwesenheit, Räume, Studierende.">
|
||||
|
||||
<DCArtboard id="dashboard" label="Dashboard · Slots-Übersicht" width={1180} height={760}>
|
||||
<TutorBrowser>
|
||||
<TutorShell active="dashboard" onNav={() => {}}>
|
||||
<Dashboard />
|
||||
</TutorShell>
|
||||
</TutorBrowser>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="attendance" label="Anwesenheit · Kursmatrix" width={1180} height={760}>
|
||||
<TutorBrowser url="tutor.puchstein.dev/admin/attendance">
|
||||
<TutorShell active="attendance" onNav={() => {}}>
|
||||
<AttendanceMatrix />
|
||||
</TutorShell>
|
||||
</TutorBrowser>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="rooms" label="Räume · Layout-Editor" width={1280} height={820}>
|
||||
<TutorBrowser url="tutor.puchstein.dev/admin/rooms" width={1280} height={820}>
|
||||
<TutorShell active="rooms" onNav={() => {}}>
|
||||
<RoomsScreenV2 />
|
||||
</TutorShell>
|
||||
</TutorBrowser>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="students" label="Studierende · Verwaltung" width={1180} height={760}>
|
||||
<TutorBrowser url="tutor.puchstein.dev/admin/students">
|
||||
<TutorShell active="students" onNav={() => {}}>
|
||||
<StudentsScreen />
|
||||
</TutorShell>
|
||||
</TutorBrowser>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="login" label="Login" width={1180} height={760}>
|
||||
<TutorBrowser url="tutor.puchstein.dev/admin/login">
|
||||
<LoginScreen />
|
||||
</TutorBrowser>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
{/* ── Student check-in flow ────────────────────────────────────── */}
|
||||
<DCSection id="student" title="Studierenden-Flow — Check-in via Code"
|
||||
subtitle="QR / URL vom Beamer → Name wählen → Sitz tippen. Drei Zustände, keine Anmeldung.">
|
||||
|
||||
<DCArtboard id="s-name" label="① Name wählen" width={400} height={820}>
|
||||
<StudentPhone time="14:01">
|
||||
<StudentNamePicker />
|
||||
</StudentPhone>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="s-seat" label="② Sitz wählen" width={400} height={820}>
|
||||
<StudentPhone time="14:02">
|
||||
<StudentSeatPicker />
|
||||
</StudentPhone>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="s-confirm" label="③ Bestätigt · offen" width={400} height={820}>
|
||||
<StudentPhone time="14:03">
|
||||
<StudentConfirmed />
|
||||
</StudentPhone>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="s-locked" label="④ Slot gesperrt" width={400} height={820}>
|
||||
<StudentPhone time="14:18">
|
||||
<StudentLocked />
|
||||
</StudentPhone>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
{/* ── Student desktop / laptop ─────────────────────────────────── */}
|
||||
<DCSection id="student-desktop" title="Studierenden-Flow — Laptop-Variante"
|
||||
subtitle="Gleiche drei Zustände, mit mehr Platz: Sitzplan groß, Saison-Übersicht & Bonus-Stand seitlich.">
|
||||
|
||||
<DCArtboard id="sd-name" label="① Name wählen" width={1180} height={760}>
|
||||
<TutorBrowser url="tutor.puchstein.dev/s/K7QJMX2P" width={1180} height={760}>
|
||||
<StudentDesktopName />
|
||||
</TutorBrowser>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="sd-seat" label="② Sitz wählen" width={1180} height={760}>
|
||||
<TutorBrowser url="tutor.puchstein.dev/s/K7QJMX2P" width={1180} height={760}>
|
||||
<StudentDesktopSeat />
|
||||
</TutorBrowser>
|
||||
</DCArtboard>
|
||||
|
||||
<DCArtboard id="sd-confirm" label="③ Bestätigt · mit Saison" width={1180} height={760}>
|
||||
<TutorBrowser url="tutor.puchstein.dev/s/K7QJMX2P" width={1180} height={760}>
|
||||
<StudentDesktopConfirmed />
|
||||
</TutorBrowser>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCPostIt top={20} left={40} rotate={-3} width={210}>
|
||||
<strong>Hi-fi Entwurf</strong> — akademisch, papier-inspiriert. Klick auf einen Sitz in der Live-Ansicht öffnet die Notiz.
|
||||
</DCPostIt>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,489 +0,0 @@
|
||||
// admin.jsx — Tutor admin shell + supporting screens (dashboard, attendance, rooms, students, login)
|
||||
|
||||
const TutorShell = ({ active, onNav, children }) => {
|
||||
const navItems = [
|
||||
{ id: "dashboard", label: "Dashboard" },
|
||||
{ id: "live", label: "Live · Sitzplan" },
|
||||
{ id: "attendance", label: "Anwesenheit" },
|
||||
{ id: "rooms", label: "Räume" },
|
||||
{ id: "students", label: "Studierende" },
|
||||
];
|
||||
return (
|
||||
<div className="paper-bg" style={{ width: "100%", height: "100%", display: "grid", gridTemplateColumns: "220px 1fr", overflow: "hidden" }}>
|
||||
{/* Sidebar */}
|
||||
<aside style={{ borderRight: "1px solid var(--rule)", padding: "20px 18px", background: "rgba(0,0,0,0.015)", display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, letterSpacing: "-0.01em" }}>
|
||||
Tutor<span style={{ color: "var(--accent)" }}>·</span>manager
|
||||
</div>
|
||||
<div className="tiny" style={{ marginTop: 2 }}>v0.1 · Puchstein</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="eyebrow" style={{ marginBottom: 8 }}>Kurs</div>
|
||||
<div style={{ padding: "10px 12px", background: "#fbf7ee", border: "1px solid var(--rule)", borderRadius: 4 }}>
|
||||
<div className="serif" style={{ fontSize: 14, fontWeight: 500 }}>{COURSE.name}</div>
|
||||
<div className="tiny" style={{ marginTop: 2 }}>{COURSE.semester} · {COURSE.weekday}s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav style={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<div className="eyebrow" style={{ marginBottom: 6 }}>Navigation</div>
|
||||
{navItems.map((item) => (
|
||||
<button key={item.id}
|
||||
onClick={() => onNav && onNav(item.id)}
|
||||
style={{
|
||||
textAlign: "left", border: "none", background: active === item.id ? "rgba(31,27,22,0.08)" : "transparent",
|
||||
padding: "8px 10px", borderRadius: 4, cursor: "pointer",
|
||||
fontFamily: "var(--sans)", fontSize: 13,
|
||||
color: active === item.id ? "var(--ink)" : "var(--ink-2)",
|
||||
fontWeight: active === item.id ? 500 : 400,
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
}}>
|
||||
<span style={{ width: 4, height: 4, borderRadius: "50%", background: active === item.id ? "var(--accent)" : "var(--ink-4)" }} />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div style={{ borderTop: "1px solid var(--rule)", paddingTop: 14, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 28, height: 28, borderRadius: "50%", background: "var(--ink)", color: "var(--paper)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 600 }}>LP</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 500 }}>{COURSE.tutorin}</div>
|
||||
<div className="tiny" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>lina@puchstein.lu</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main style={{ overflow: "auto" }}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Dashboard ─────────────────────────────────────────────────────────────
|
||||
const Dashboard = () => {
|
||||
const slots = [
|
||||
{ id: 1, week: 4, day: "Do, 30. April", time: "14:00 – 15:00", room: "BC2 1.103", status: "open", code: "K7QJMX2P", checkedIn: 14, total: 19 },
|
||||
{ id: 2, week: 4, day: "Do, 30. April", time: "17:00 – 18:00", room: "BC2 1.207", status: "closed", code: null, checkedIn: 0, total: 19 },
|
||||
{ id: 3, week: 3, day: "Do, 23. April", time: "14:00 – 15:00", room: "BC2 1.103", status: "locked", code: "RX48ZF2K", checkedIn: 17, total: 19 },
|
||||
{ id: 4, week: 3, day: "Do, 23. April", time: "17:00 – 18:00", room: "BC2 1.207", status: "locked", code: "QM9WJ3VC", checkedIn: 12, total: 19 },
|
||||
{ id: 5, week: 2, day: "Do, 16. April", time: "14:00 – 15:00", room: "BC2 1.103", status: "locked", code: "B2HFNX9P", checkedIn: 18, total: 19 },
|
||||
{ id: 6, week: 2, day: "Do, 16. April", time: "17:00 – 18:00", room: "BC2 1.207", status: "locked", code: "VJ5KQM7R", checkedIn: 15, total: 19 },
|
||||
{ id: 7, week: 1, day: "Do, 09. April", time: "14:00 – 15:00", room: "— (Papier)", status: "locked", code: null, checkedIn: 16, total: 19 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: "28px 36px", display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
<header style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 16 }}>
|
||||
<div>
|
||||
<div className="eyebrow">Dashboard</div>
|
||||
<div className="serif" style={{ fontSize: 36, fontWeight: 500, letterSpacing: "-0.015em", marginTop: 2 }}>
|
||||
Diese Woche, <span className="marker">Woche 04</span>
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 6 }}>2 Slots geplant · 1 läuft gerade · 14 von 19 sind eingecheckt.</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button className="btn ghost"><Icon.download /> Export</button>
|
||||
<button className="btn"><Icon.plus /> Neuer Slot</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stat row */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14 }}>
|
||||
<StatCard label="Anwesend gerade" value="14" suffix="/ 19" accent="var(--green)" hint="Slot K7QJ-MX2P" />
|
||||
<StatCard label="Ø Anwesenheit" value="84%" hint="Über 4 Wochen" />
|
||||
<StatCard label="Bonus vergeben" value="186" suffix="Punkte" hint="Saison gesamt" />
|
||||
<StatCard label="Offene Notizen" value="7" hint="ohne Eintrag diese Woche" accent="var(--accent)" />
|
||||
</div>
|
||||
|
||||
{/* Slots table */}
|
||||
<section className="card" style={{ overflow: "hidden" }}>
|
||||
<div style={{ padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", borderBottom: "1px solid var(--rule)" }}>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 18, fontWeight: 500 }}>Slots</div>
|
||||
<UnderlineStroke width={50} />
|
||||
</div>
|
||||
<div className="small">Sortiert: neueste zuerst</div>
|
||||
</div>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "rgba(0,0,0,0.02)", color: "var(--ink-3)", textAlign: "left" }}>
|
||||
<Th>Woche</Th>
|
||||
<Th>Datum</Th>
|
||||
<Th>Zeit</Th>
|
||||
<Th>Raum</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Code</Th>
|
||||
<Th>Eingecheckt</Th>
|
||||
<Th style={{ textAlign: "right" }}>Aktionen</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{slots.map((s, i) => (
|
||||
<tr key={s.id} className="row-hover" style={{ borderTop: i === 0 ? "none" : "1px solid var(--rule)" }}>
|
||||
<Td><span className="mono" style={{ fontSize: 12 }}>W{String(s.week).padStart(2, "0")}</span></Td>
|
||||
<Td>{s.day}</Td>
|
||||
<Td className="mono" style={{ fontSize: 12 }}>{s.time}</Td>
|
||||
<Td>{s.room}</Td>
|
||||
<Td><StatusPill status={s.status} /></Td>
|
||||
<Td>{s.code ? <span className="mono" style={{ fontSize: 12, letterSpacing: "0.08em" }}>{s.code}</span> : <span className="tiny">—</span>}</Td>
|
||||
<Td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>{s.checkedIn} / {s.total}</span>
|
||||
<div style={{ width: 48, height: 4, background: "var(--paper-2)", borderRadius: 2, overflow: "hidden" }}>
|
||||
<div style={{ width: `${(s.checkedIn / s.total) * 100}%`, height: "100%", background: s.status === "locked" ? "var(--ink-3)" : "var(--accent)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td style={{ textAlign: "right" }}>
|
||||
{s.status === "open" && <button className="btn sm"><Icon.lock /> Sperren</button>}
|
||||
{s.status === "closed" && <button className="btn sm"><Icon.open /> Öffnen</button>}
|
||||
{s.status === "locked" && <button className="btn ghost sm">Anzeigen</button>}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Th = ({ children, style }) => <th style={{ padding: "10px 14px", fontFamily: "var(--mono)", fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", fontWeight: 500, ...style }}>{children}</th>;
|
||||
const Td = ({ children, style, className }) => <td className={className} style={{ padding: "12px 14px", ...style }}>{children}</td>;
|
||||
|
||||
const StatCard = ({ label, value, suffix, hint, accent }) => (
|
||||
<div className="card" style={{ padding: "14px 16px" }}>
|
||||
<div className="eyebrow">{label}</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 6, marginTop: 4 }}>
|
||||
<span className="serif" style={{ fontSize: 32, fontWeight: 500, color: accent || "var(--ink)", letterSpacing: "-0.01em" }}>{value}</span>
|
||||
{suffix && <span className="small">{suffix}</span>}
|
||||
</div>
|
||||
{hint && <div className="tiny" style={{ marginTop: 4 }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Attendance matrix ─────────────────────────────────────────────────────────────
|
||||
const AttendanceMatrix = () => {
|
||||
const weeks = [1, 2, 3, 4];
|
||||
// deterministic random-ish presence
|
||||
const presence = STUDENTS.map((s) => ({
|
||||
student: s,
|
||||
weeks: weeks.map((w) => {
|
||||
const hash = (s.id * 31 + w * 7) % 11;
|
||||
return hash > 2; // ~80%
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ padding: "28px 36px", display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<header style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<div className="eyebrow">Anwesenheit</div>
|
||||
<div className="serif" style={{ fontSize: 32, fontWeight: 500, letterSpacing: "-0.015em", marginTop: 2 }}>
|
||||
Kursmatrix · <span className="marker">SS 2026</span>
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 6 }}>Bonus = Anwesenheiten × 3 Punkte</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button className="btn ghost sm"><Icon.download /> CSV</button>
|
||||
<button className="btn ghost sm"><Icon.download /> Markdown</button>
|
||||
<button className="btn ghost sm"><Icon.download /> SQLite Backup</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div className="tabs" style={{ padding: "0 18px" }}>
|
||||
<div className="tab active">Pro Studierende:r</div>
|
||||
<div className="tab">Pro Woche</div>
|
||||
<div className="tab">Notizen</div>
|
||||
</div>
|
||||
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "rgba(0,0,0,0.02)", color: "var(--ink-3)" }}>
|
||||
<Th style={{ width: 40 }}>#</Th>
|
||||
<Th>Studierende:r</Th>
|
||||
{weeks.map((w) => (
|
||||
<Th key={w} style={{ textAlign: "center" }}>W{String(w).padStart(2, "0")}</Th>
|
||||
))}
|
||||
<Th style={{ textAlign: "right" }}>Anwesend</Th>
|
||||
<Th style={{ textAlign: "center", width: 60 }}>Bonus</Th>
|
||||
<Th style={{ width: 32 }}></Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{presence.map((row, i) => {
|
||||
const count = row.weeks.filter(Boolean).length;
|
||||
return (
|
||||
<tr key={row.student.id} className="row-hover" style={{ borderTop: "1px solid var(--rule-soft)" }}>
|
||||
<Td className="mono tiny">{String(i + 1).padStart(2, "0")}</Td>
|
||||
<Td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<span style={{ width: 22, height: 22, borderRadius: "50%", background: "var(--paper-2)", color: "var(--ink-2)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 600 }}>{row.student.initials}</span>
|
||||
<span>{row.student.name}</span>
|
||||
</div>
|
||||
</Td>
|
||||
{row.weeks.map((p, wi) => (
|
||||
<Td key={wi} style={{ textAlign: "center" }}>
|
||||
{p ? (
|
||||
<span style={{ display: "inline-flex", width: 22, height: 22, borderRadius: 3, background: "rgba(74,107,58,0.14)", color: "var(--green)", alignItems: "center", justifyContent: "center" }}>
|
||||
<Icon.check />
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ display: "inline-flex", width: 22, height: 22, borderRadius: 3, color: "var(--ink-4)", alignItems: "center", justifyContent: "center", fontSize: 13 }}>—</span>
|
||||
)}
|
||||
</Td>
|
||||
))}
|
||||
<Td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>{count} / {weeks.length}</Td>
|
||||
<Td style={{ textAlign: "center" }}>
|
||||
{count > 0 ? (
|
||||
<span style={{ display: "inline-flex", width: 24, height: 24, borderRadius: "50%", background: "rgba(138,44,31,0.08)", color: "var(--accent)", alignItems: "center", justifyContent: "center" }}>
|
||||
<Icon.check />
|
||||
</span>
|
||||
) : (
|
||||
<span className="tiny">—</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td><Icon.arrow style={{ color: "var(--ink-4)" }} /></Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Rooms / layout editor ─────────────────────────────────────────────────────────────
|
||||
const RoomsScreen = () => {
|
||||
return (
|
||||
<div style={{ padding: "28px 36px", display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<header style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<div className="eyebrow">Räume</div>
|
||||
<div className="serif" style={{ fontSize: 32, fontWeight: 500, letterSpacing: "-0.015em", marginTop: 2 }}>
|
||||
Layout-Editor · <span className="serif" style={{ fontStyle: "italic" }}>{ROOM.name}</span>
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 6 }}>Räume sind kursunabhängig — einmal anlegen, mehrere Semester nutzen.</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button className="btn ghost sm">Als Vorlage speichern</button>
|
||||
<button className="btn"><Icon.check /> Speichern</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: 18 }}>
|
||||
{/* Tool palette */}
|
||||
<div className="card" style={{ padding: 16, display: "flex", flexDirection: "column", gap: 12, height: "fit-content" }}>
|
||||
<div>
|
||||
<div className="eyebrow">Werkzeuge</div>
|
||||
<div style={{ marginTop: 8, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<ToolBtn label="Sitz" active />
|
||||
<ToolBtn label="Tisch" />
|
||||
<ToolBtn label="Tür" />
|
||||
<ToolBtn label="Fenster" />
|
||||
<ToolBtn label="Lücke" />
|
||||
<ToolBtn label="Beamer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="div-h" />
|
||||
|
||||
<div>
|
||||
<div className="eyebrow">Eigenschaften</div>
|
||||
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Field label="Bezeichnung" value="T2-3" />
|
||||
<Field label="Tisch" value="T2" />
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<Field label="X" value="540" mono />
|
||||
<Field label="Y" value="194" mono />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="div-h" />
|
||||
|
||||
<div>
|
||||
<div className="eyebrow">Räume</div>
|
||||
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<RoomItem label="BC2 1.103" sub="20 Sitze · 4 Tische" active />
|
||||
<RoomItem label="BC2 1.207" sub="16 Sitze · 4 Tische" />
|
||||
<RoomItem label="Lib 0.04" sub="12 Sitze · 3 Tische" />
|
||||
<button className="btn ghost sm" style={{ marginTop: 6, justifyContent: "center" }}><Icon.plus /> Neuer Raum</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor canvas with seat being dragged */}
|
||||
<div className="card" style={{ padding: 18, position: "relative" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<div className="serif" style={{ fontSize: 16, fontWeight: 500 }}>Bearbeiten</div>
|
||||
<div className="tiny mono">760 × 460 · 1× Zoom</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<SeatMap variant="tutor" assignments={{}} scale={0.78} />
|
||||
</div>
|
||||
<div className="handwritten" style={{ position: "absolute", top: 90, right: 90, transform: "rotate(-6deg)", maxWidth: 130, lineHeight: 1.1 }}>
|
||||
ziehen um Sitz zu verschieben →
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolBtn = ({ label, active }) => (
|
||||
<button style={{
|
||||
padding: "10px 8px", borderRadius: 4, cursor: "pointer",
|
||||
border: `1px solid ${active ? "var(--ink)" : "var(--rule)"}`,
|
||||
background: active ? "var(--ink)" : "#fbf7ee",
|
||||
color: active ? "var(--paper)" : "var(--ink-2)",
|
||||
fontFamily: "var(--sans)", fontSize: 12, fontWeight: 500,
|
||||
}}>{label}</button>
|
||||
);
|
||||
|
||||
const Field = ({ label, value, mono }) => (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<span className="tiny">{label}</span>
|
||||
<input className="input" defaultValue={value} style={{ fontFamily: mono ? "var(--mono)" : "var(--sans)", fontSize: 12 }} />
|
||||
</label>
|
||||
);
|
||||
|
||||
const RoomItem = ({ label, sub, active }) => (
|
||||
<button style={{
|
||||
border: "none", background: active ? "rgba(31,27,22,0.06)" : "transparent",
|
||||
textAlign: "left", padding: "7px 9px", borderRadius: 4, cursor: "pointer",
|
||||
borderLeft: `3px solid ${active ? "var(--accent)" : "transparent"}`,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{label}</div>
|
||||
<div className="tiny">{sub}</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Students CRUD ─────────────────────────────────────────────────────────────
|
||||
const StudentsScreen = () => {
|
||||
return (
|
||||
<div style={{ padding: "28px 36px", display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<header style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<div className="eyebrow">Studierende</div>
|
||||
<div className="serif" style={{ fontSize: 32, fontWeight: 500, letterSpacing: "-0.015em", marginTop: 2 }}>
|
||||
{STUDENTS.length} Studierende · {COURSE.name}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 10px", border: "1px solid var(--rule)", borderRadius: 4, background: "#fbf7ee" }}>
|
||||
<Icon.search style={{ color: "var(--ink-3)" }} />
|
||||
<input placeholder="Suchen…" style={{ border: "none", background: "transparent", outline: "none", fontSize: 13, width: 160 }} />
|
||||
</div>
|
||||
<button className="btn ghost sm">CSV importieren</button>
|
||||
<button className="btn"><Icon.plus /> Hinzufügen</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "rgba(0,0,0,0.02)", color: "var(--ink-3)" }}>
|
||||
<Th style={{ width: 40 }}>#</Th>
|
||||
<Th>Name</Th>
|
||||
<Th>Anwesend</Th>
|
||||
<Th>Bonus</Th>
|
||||
<Th>Notizen</Th>
|
||||
<Th>Letzte Sitzung</Th>
|
||||
<Th></Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{STUDENTS.map((s, i) => {
|
||||
const count = (s.id * 7) % 5;
|
||||
const noteCount = (s.id * 3) % 4;
|
||||
return (
|
||||
<tr key={s.id} className="row-hover" style={{ borderTop: "1px solid var(--rule-soft)" }}>
|
||||
<Td className="mono tiny">{String(i + 1).padStart(2, "0")}</Td>
|
||||
<Td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<span style={{ width: 24, height: 24, borderRadius: "50%", background: "var(--paper-2)", color: "var(--ink-2)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 600 }}>{s.initials}</span>
|
||||
<span>{s.name}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td><span style={{ fontVariantNumeric: "tabular-nums" }}>{count} / 4</span></Td>
|
||||
<Td>
|
||||
{count > 0 ? (
|
||||
<span style={{ display: "inline-flex", width: 22, height: 22, borderRadius: "50%", background: "rgba(138,44,31,0.08)", color: "var(--accent)", alignItems: "center", justifyContent: "center" }}>
|
||||
<Icon.check />
|
||||
</span>
|
||||
) : (
|
||||
<span className="tiny">—</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{noteCount > 0 ? (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)" }} />
|
||||
<span>{noteCount} Notizen</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="tiny">—</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td className="tiny">Do, 23. April · T{(s.id % 4) + 1}-{(s.id % 5) + 1}</Td>
|
||||
<Td><Icon.arrow style={{ color: "var(--ink-4)" }} /></Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Login ─────────────────────────────────────────────────────────────
|
||||
const LoginScreen = () => {
|
||||
return (
|
||||
<div className="paper-bg" style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 40 }}>
|
||||
<div style={{ width: 420 }}>
|
||||
<div className="serif" style={{ fontSize: 38, fontWeight: 500, letterSpacing: "-0.015em" }}>
|
||||
Tutor<span style={{ color: "var(--accent)" }}>·</span>manager
|
||||
</div>
|
||||
<div className="body" style={{ marginTop: 6, color: "var(--ink-3)" }}>
|
||||
Anwesenheit & Notizen für Tutorien.
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: 28, padding: 26 }}>
|
||||
<div className="eyebrow">Anmeldung</div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, marginTop: 4 }}>Willkommen zurück</div>
|
||||
<UnderlineStroke width={120} />
|
||||
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 5, marginTop: 22 }}>
|
||||
<span className="tiny">E-Mail</span>
|
||||
<input className="input" defaultValue="lina@puchstein.lu" />
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 5, marginTop: 14 }}>
|
||||
<span className="tiny">Passwort</span>
|
||||
<input type="password" className="input" defaultValue="••••••••••" />
|
||||
</label>
|
||||
|
||||
<button className="btn" style={{ width: "100%", justifyContent: "center", marginTop: 22, padding: "11px 14px" }}>
|
||||
Anmelden
|
||||
</button>
|
||||
|
||||
<div className="tiny" style={{ marginTop: 16, textAlign: "center", color: "var(--ink-3)" }}>
|
||||
Nur für Tutor:innen. Studierende nutzen den vom Beamer projizierten Code.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="handwritten" style={{ marginTop: 18, textAlign: "center" }}>
|
||||
~ Donnerstags ab 14 Uhr ~
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { TutorShell, Dashboard, AttendanceMatrix, RoomsScreen, StudentsScreen, LoginScreen });
|
||||
@@ -1,114 +0,0 @@
|
||||
|
||||
// Chrome.jsx — Simplified Chrome browser window (dark theme, macOS)
|
||||
// No dependencies, no image assets. All inline styles + inline SVG.
|
||||
|
||||
const CHROME_C = {
|
||||
barBg: '#202124',
|
||||
tabBg: '#35363a',
|
||||
text: '#e8eaed',
|
||||
dim: '#9aa0a6',
|
||||
urlBg: '#282a2d',
|
||||
};
|
||||
|
||||
function ChromeTrafficLights() {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '0 14px' }}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Single tab (active has curved scoops)
|
||||
function ChromeTab({ title = 'New Tab', active = false }) {
|
||||
const curve = (flip) => (
|
||||
<svg width="8" height="10" viewBox="0 0 8 10"
|
||||
style={{ position: 'absolute', bottom: 0, [flip ? 'right' : 'left']: -8, transform: flip ? 'scaleX(-1)' : 'none' }}>
|
||||
<path d="M0 10C2 9 6 8 8 0V10H0Z" fill={CHROME_C.tabBg}/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', height: 34, alignSelf: 'flex-end',
|
||||
padding: '0 12px', display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: active ? CHROME_C.tabBg : 'transparent',
|
||||
borderRadius: '8px 8px 0 0', minWidth: 120, maxWidth: 220,
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: 12,
|
||||
color: active ? CHROME_C.text : CHROME_C.dim,
|
||||
}}>
|
||||
{active && curve(false)}
|
||||
{active && curve(true)}
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#5f6368', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChromeTabBar({ tabs = [{ title: 'New Tab' }], activeIndex = 0 }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', height: 44,
|
||||
background: CHROME_C.barBg, paddingRight: 8,
|
||||
}}>
|
||||
<ChromeTrafficLights />
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', height: '100%', paddingLeft: 4, flex: 1 }}>
|
||||
{tabs.map((t, i) => <ChromeTab key={i} title={t.title} active={i === activeIndex} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChromeToolbar({ url = 'example.com' }) {
|
||||
const iconDot = (
|
||||
<div style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
height: 40, background: CHROME_C.tabBg,
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '0 8px',
|
||||
}}>
|
||||
{iconDot}
|
||||
{/* url bar */}
|
||||
<div style={{
|
||||
flex: 1, height: 30, borderRadius: 15, background: CHROME_C.urlBg,
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px',
|
||||
margin: '0 6px',
|
||||
}}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
|
||||
<span style={{
|
||||
flex: 1, color: CHROME_C.text, fontSize: 13,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}>{url}</span>
|
||||
</div>
|
||||
{iconDot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChromeWindow({
|
||||
tabs = [{ title: 'New Tab' }], activeIndex = 0, url = 'example.com',
|
||||
width = 900, height = 600, children,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 10, overflow: 'hidden',
|
||||
boxShadow: '0 24px 80px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.1)',
|
||||
display: 'flex', flexDirection: 'column', background: CHROME_C.tabBg,
|
||||
}}>
|
||||
<ChromeTabBar tabs={tabs} activeIndex={activeIndex} />
|
||||
<ChromeToolbar url={url} />
|
||||
<div style={{ flex: 1, background: '#fff', overflow: 'auto' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights,
|
||||
});
|
||||
@@ -1,622 +0,0 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
|
||||
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
|
||||
// State persists to a .design-canvas.state.json sidecar via the host
|
||||
// bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
'.dc-card{transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
|
||||
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
|
||||
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
|
||||
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
'[data-dc-slot]:hover .dc-expand{opacity:1}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, focused
|
||||
// artboard). Order/titles/labels persist to a .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Only direct DCSection > DCArtboard children are
|
||||
// walked — wrapping them in other elements opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
React.Children.forEach(children, (sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const srcIds = [];
|
||||
React.Children.forEach(sec.props.children, (ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (!aid) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if (e.ctrlKey) {
|
||||
// trackpad pinch (or explicit ctrl+wheel)
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(children);
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
return (
|
||||
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px 56px' }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
<div className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) ctx.setFocus(`${ns}/${first}`);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
|
||||
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
|
||||
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
|
||||
// Exports: IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status bar
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSStatusBar({ dark = false, time = '9:41' }) {
|
||||
const c = dark ? '#fff' : '#000';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
|
||||
padding: '21px 24px 19px', boxSizing: 'border-box',
|
||||
position: 'relative', zIndex: 20, width: '100%',
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
|
||||
<span style={{
|
||||
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
|
||||
fontSize: 17, lineHeight: '22px', color: c,
|
||||
}}>{time}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
|
||||
<svg width="19" height="12" viewBox="0 0 19 12">
|
||||
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
|
||||
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
|
||||
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
|
||||
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
|
||||
</svg>
|
||||
<svg width="17" height="12" viewBox="0 0 17 12">
|
||||
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
|
||||
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
|
||||
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
|
||||
</svg>
|
||||
<svg width="27" height="13" viewBox="0 0 27 13">
|
||||
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
|
||||
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
|
||||
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Liquid glass pill — blur + tint + shine
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSGlassPill({ children, dark = false, style = {} }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 44, minWidth: 44, borderRadius: 9999,
|
||||
position: 'relative', overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: dark
|
||||
? '0 2px 6px rgba(0,0,0,0.35), 0 6px 16px rgba(0,0,0,0.2)'
|
||||
: '0 1px 3px rgba(0,0,0,0.07), 0 3px 10px rgba(0,0,0,0.06)',
|
||||
...style,
|
||||
}}>
|
||||
{/* blur + tint */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.28)' : 'rgba(255,255,255,0.5)',
|
||||
}} />
|
||||
{/* shine */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15), inset -1px -1px 1px rgba(255,255,255,0.08)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
}} />
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', padding: '0 4px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Navigation bar — glass pills + large title
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) {
|
||||
const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040';
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const pillIcon = (content) => (
|
||||
<IOSGlassPill dark={dark}>
|
||||
<div style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{content}
|
||||
</div>
|
||||
</IOSGlassPill>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
paddingTop: 62, paddingBottom: 10, position: 'relative', zIndex: 5,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
}}>
|
||||
{/* back chevron */}
|
||||
{pillIcon(
|
||||
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" style={{ marginLeft: -1 }}>
|
||||
<path d="M10 2L2 10l8 8" stroke={muted} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{/* trailing ellipsis */}
|
||||
{trailingIcon && pillIcon(
|
||||
<svg width="22" height="6" viewBox="0 0 22 6">
|
||||
<circle cx="3" cy="3" r="2.5" fill={muted}/>
|
||||
<circle cx="11" cy="3" r="2.5" fill={muted}/>
|
||||
<circle cx="19" cy="3" r="2.5" fill={muted}/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{/* large title */}
|
||||
<div style={{
|
||||
padding: '0 16px',
|
||||
fontFamily: '-apple-system, system-ui',
|
||||
fontSize: 34, fontWeight: 700, lineHeight: '41px',
|
||||
color: text, letterSpacing: 0.4,
|
||||
}}>{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Grouped list (inset card, r:26) + row (52px)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
|
||||
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', minHeight: 52,
|
||||
padding: '0 16px', position: 'relative',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
letterSpacing: -0.43,
|
||||
}}>
|
||||
{icon && (
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 7, background: icon,
|
||||
marginRight: 12, flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
<div style={{ flex: 1, color: text }}>{title}</div>
|
||||
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
|
||||
{chevron && (
|
||||
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
|
||||
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, right: 0,
|
||||
left: icon ? 58 : 16, height: 0.5, background: sep,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IOSList({ header, children, dark = false }) {
|
||||
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const bg = dark ? '#1C1C1E' : '#fff';
|
||||
return (
|
||||
<div>
|
||||
{header && (
|
||||
<div style={{
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 13,
|
||||
color: hc, textTransform: 'uppercase',
|
||||
padding: '8px 36px 6px', letterSpacing: -0.08,
|
||||
}}>{header}</div>
|
||||
)}
|
||||
<div style={{
|
||||
background: bg, borderRadius: 26,
|
||||
margin: '0 16px', overflow: 'hidden',
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Device frame
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSDevice({
|
||||
children, width = 402, height = 874, dark = false,
|
||||
title, keyboard = false,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 48, overflow: 'hidden',
|
||||
position: 'relative', background: dark ? '#000' : '#F2F2F7',
|
||||
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)',
|
||||
fontFamily: '-apple-system, system-ui, sans-serif',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
}}>
|
||||
{/* dynamic island */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
|
||||
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
|
||||
}} />
|
||||
{/* status bar (absolute) */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
|
||||
<IOSStatusBar dark={dark} />
|
||||
</div>
|
||||
{/* nav + content */}
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{title !== undefined && <IOSNavBar title={title} dark={dark} />}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
||||
{keyboard && <IOSKeyboard dark={dark} />}
|
||||
</div>
|
||||
{/* home indicator — always on top */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
|
||||
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
|
||||
paddingBottom: 8, pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 139, height: 5, borderRadius: 100,
|
||||
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Keyboard — iOS 26 liquid glass
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSKeyboard({ dark = false }) {
|
||||
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
|
||||
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
|
||||
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
|
||||
|
||||
// special-key icons
|
||||
const icons = {
|
||||
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
|
||||
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
};
|
||||
|
||||
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
|
||||
<div key={k} style={{
|
||||
height: 42, borderRadius: 8.5,
|
||||
flex: flex ? 1 : undefined, width: w, minWidth: 0,
|
||||
background: ret ? '#08f' : keyBg,
|
||||
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: '-apple-system, "SF Compact", system-ui',
|
||||
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
|
||||
}}>{content}</div>
|
||||
);
|
||||
|
||||
const row = (keys, pad = 0) => (
|
||||
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
|
||||
{keys.map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
|
||||
padding: '11px 0 2px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
boxShadow: dark
|
||||
? '0 -2px 20px rgba(0,0,0,0.09)'
|
||||
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
|
||||
}}>
|
||||
{/* liquid glass bg — same recipe as nav pills */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* autocorrect bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 20, alignItems: 'center',
|
||||
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{['"The"', 'the', 'to'].map((w, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
|
||||
<div style={{
|
||||
flex: 1, textAlign: 'center',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
|
||||
}}>{w}</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* key layout */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 13,
|
||||
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{row(['q','w','e','r','t','y','u','i','o','p'])}
|
||||
{row(['a','s','d','f','g','h','j','k','l'], 20)}
|
||||
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
|
||||
{key(icons.shift, { w: 45, k: 'shift' })}
|
||||
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
|
||||
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
{key(icons.del, { w: 45, k: 'del' })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
|
||||
{key('', { flex: true, k: 'space' })}
|
||||
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* bottom spacer (emoji+mic area, icons omitted) */}
|
||||
<div style={{ height: 56, width: '100%', position: 'relative' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard,
|
||||
});
|
||||
@@ -1,244 +0,0 @@
|
||||
// rooms.jsx — richer Layout-Editor for rooms
|
||||
|
||||
const ROOM_LIST = [
|
||||
{ id: "bc2-1103", name: "BC2 1.103", sub: "20 Sitze · 4 Tische", building: "Campus Belval", capacity: 20, used: "Funktionale Programmierung · SS 2026" },
|
||||
{ id: "bc2-1207", name: "BC2 1.207", sub: "16 Sitze · 4 Tische", building: "Campus Belval", capacity: 16, used: "Datenbanken · SS 2026" },
|
||||
{ id: "lib-004", name: "Lib 0.04", sub: "12 Sitze · 3 Tische", building: "Bibliothek", capacity: 12, used: "—" },
|
||||
{ id: "kirch-12", name: "Kirchberg E12", sub: "24 Sitze · 6 Tische", building: "Kirchberg", capacity: 24, used: "Algorithmen · WS 2025" },
|
||||
];
|
||||
|
||||
const RoomsScreenV2 = () => {
|
||||
const [activeRoom, setActiveRoom] = React.useState("bc2-1103");
|
||||
const [activeTool, setActiveTool] = React.useState("seat");
|
||||
const [selectedEl, setSelectedEl] = React.useState({ kind: "seat", id: "T2-3", x: 540, y: 194, table: "T2" });
|
||||
|
||||
const tools = [
|
||||
{ id: "select", label: "Auswählen", icon: "↖" },
|
||||
{ id: "seat", label: "Sitz", icon: "○" },
|
||||
{ id: "table", label: "Tisch", icon: "▭" },
|
||||
{ id: "door", label: "Tür", icon: "⌐" },
|
||||
{ id: "window", label: "Fenster", icon: "‖" },
|
||||
{ id: "gap", label: "Lücke", icon: "·" },
|
||||
];
|
||||
|
||||
const room = ROOM_LIST.find((r) => r.id === activeRoom);
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px 28px", display: "flex", flexDirection: "column", gap: 16, height: "100%" }}>
|
||||
<header style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 16 }}>
|
||||
<div>
|
||||
<div className="eyebrow">Räume · Layout-Editor</div>
|
||||
<div className="serif" style={{ fontSize: 30, fontWeight: 500, letterSpacing: "-0.015em", marginTop: 2 }}>
|
||||
<span className="marker">{room.name}</span> <span style={{ color: "var(--ink-4)" }}>·</span> <span style={{ fontStyle: "italic", color: "var(--ink-3)" }}>{room.building}</span>
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 4 }}>Räume sind kursunabhängig — einmal anlegen, mehrere Semester nutzen. Aktuell: {room.used}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button className="btn ghost sm">Vorschau</button>
|
||||
<button className="btn ghost sm">Als Vorlage</button>
|
||||
<button className="btn sm"><Icon.check /> Speichern</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "210px 1fr 240px", gap: 14, flex: 1, minHeight: 0 }}>
|
||||
{/* LEFT: Rooms list */}
|
||||
<div className="card" style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8, overflow: "auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div className="eyebrow">Räume</div>
|
||||
<button className="btn ghost sm" style={{ padding: "3px 7px", fontSize: 11 }}><Icon.plus /></button>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{ROOM_LIST.map((r) => (
|
||||
<button key={r.id} onClick={() => setActiveRoom(r.id)}
|
||||
style={{
|
||||
border: "none", textAlign: "left", padding: "8px 9px", borderRadius: 4, cursor: "pointer",
|
||||
background: r.id === activeRoom ? "rgba(31,27,22,0.06)" : "transparent",
|
||||
borderLeft: `3px solid ${r.id === activeRoom ? "var(--accent)" : "transparent"}`,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{r.name}</div>
|
||||
<div className="tiny">{r.sub}</div>
|
||||
<div className="tiny" style={{ color: "var(--ink-4)", marginTop: 2 }}>{r.building}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CENTER: Toolbar + canvas */}
|
||||
<div className="card" style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ borderBottom: "1px solid var(--rule)", padding: "8px 12px", display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{tools.map((t) => (
|
||||
<button key={t.id} onClick={() => setActiveTool(t.id)}
|
||||
title={t.label}
|
||||
style={{
|
||||
padding: "5px 10px", borderRadius: 4, cursor: "pointer",
|
||||
border: `1px solid ${activeTool === t.id ? "var(--ink)" : "var(--rule)"}`,
|
||||
background: activeTool === t.id ? "var(--ink)" : "#fbf7ee",
|
||||
color: activeTool === t.id ? "var(--paper)" : "var(--ink-2)",
|
||||
fontFamily: "var(--sans)", fontSize: 12, fontWeight: 500,
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
}}>
|
||||
<span style={{ fontFamily: "var(--mono)", fontSize: 13 }}>{t.icon}</span>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }} className="tiny mono">
|
||||
<button className="btn ghost sm" style={{ padding: "3px 8px" }}>−</button>
|
||||
<span>78%</span>
|
||||
<button className="btn ghost sm" style={{ padding: "3px 8px" }}>+</button>
|
||||
<span style={{ color: "var(--rule)" }}>·</span>
|
||||
<span>Raster: 24px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div style={{ flex: 1, position: "relative", background: "#efe6d2", overflow: "auto", padding: "28px 30px" }}>
|
||||
{/* Rulers */}
|
||||
<div style={{ position: "absolute", top: 8, left: 30, right: 30, height: 16, display: "flex", borderBottom: "1px solid var(--rule)" }} className="tiny mono">
|
||||
{[0, 100, 200, 300, 400, 500, 600, 700].map((n) => (
|
||||
<div key={n} style={{ width: 78, fontSize: 9, color: "var(--ink-4)", borderLeft: "1px solid var(--rule-soft)", paddingLeft: 3 }}>{n}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ position: "absolute", top: 28, left: 8, bottom: 8, width: 18, display: "flex", flexDirection: "column", borderRight: "1px solid var(--rule)" }} className="tiny mono">
|
||||
{[0, 100, 200, 300, 400].map((n) => (
|
||||
<div key={n} style={{ height: 78, fontSize: 9, color: "var(--ink-4)", borderTop: "1px solid var(--rule-soft)", paddingTop: 1, paddingLeft: 2 }}>{n}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", paddingTop: 14, paddingLeft: 14 }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<SeatMap variant="tutor" assignments={{}} scale={0.78} />
|
||||
|
||||
{/* Selection ring on T2-3 */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
// T2 is at x=470, T2-3 is at t.x + t.w*0.28, t.y + t.h + 22 = (470+200*0.28, 150+70+22) = (526, 242). center.
|
||||
left: (526 * 0.78) - 22, top: (242 * 0.78) - 22,
|
||||
width: 44, height: 44, borderRadius: "50%",
|
||||
border: "2px dashed var(--accent)",
|
||||
pointerEvents: "none",
|
||||
animation: "spin 12s linear infinite",
|
||||
}} />
|
||||
{/* drag handles */}
|
||||
{[[0,-1],[1,0],[0,1],[-1,0]].map(([dx,dy], i) => (
|
||||
<div key={i} style={{
|
||||
position: "absolute",
|
||||
left: (526 * 0.78) - 4 + dx * 22,
|
||||
top: (242 * 0.78) - 4 + dy * 22,
|
||||
width: 8, height: 8,
|
||||
background: "#fbf7ee",
|
||||
border: "1.5px solid var(--accent)",
|
||||
pointerEvents: "none",
|
||||
}} />
|
||||
))}
|
||||
|
||||
{/* Marginalia */}
|
||||
<div className="handwritten" style={{ position: "absolute", top: -10, right: -110, transform: "rotate(-6deg)", width: 130, lineHeight: 1.1 }}>
|
||||
Sitz aus Palette ziehen <br/>oder Doppelklick →
|
||||
</div>
|
||||
<div className="handwritten" style={{ position: "absolute", bottom: -8, left: -10, transform: "rotate(3deg)", width: 130, lineHeight: 1.1, color: "var(--ink-3)", fontSize: 16 }}>
|
||||
Wand mit Snap-Raster
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom status bar */}
|
||||
<div style={{ borderTop: "1px solid var(--rule)", padding: "6px 12px", display: "flex", alignItems: "center", gap: 14 }} className="tiny mono">
|
||||
<span>20 Elemente</span>
|
||||
<span style={{ color: "var(--rule)" }}>·</span>
|
||||
<span>20 Sitze · 4 Tische · 1 Tür · 1 Fenster · 1 Beamer</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<span style={{ color: "var(--ink-4)" }}>Auto-gespeichert · 11:42</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Properties + layers */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, overflow: "auto" }}>
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
<div className="eyebrow">Auswahl</div>
|
||||
<div className="serif" style={{ fontSize: 16, fontWeight: 500, marginTop: 4 }}>Sitz <span className="mono" style={{ fontSize: 14 }}>{selectedEl.id}</span></div>
|
||||
<div className="tiny" style={{ marginTop: 2 }}>gehört zu Tisch {selectedEl.table}</div>
|
||||
|
||||
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<FieldV2 label="Bezeichnung" value={selectedEl.id} />
|
||||
<FieldV2 label="Tisch (Gruppe)" value={selectedEl.table} />
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<FieldV2 label="X" value={selectedEl.x} mono suffix="px" />
|
||||
<FieldV2 label="Y" value={selectedEl.y} mono suffix="px" />
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<FieldV2 label="∅" value="36" mono suffix="px" />
|
||||
<FieldV2 label="Rotation" value="0" mono suffix="°" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="div-h" style={{ margin: "12px 0" }} />
|
||||
|
||||
<div className="eyebrow">Aktionen</div>
|
||||
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<button className="btn ghost sm" style={{ justifyContent: "flex-start" }}>Duplizieren <span className="tiny mono" style={{ marginLeft: "auto", color: "var(--ink-4)" }}>⌘D</span></button>
|
||||
<button className="btn ghost sm" style={{ justifyContent: "flex-start" }}>An Tisch ausrichten</button>
|
||||
<button className="btn ghost sm" style={{ justifyContent: "flex-start", color: "var(--accent)" }}>Löschen <span className="tiny mono" style={{ marginLeft: "auto" }}>⌫</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div className="eyebrow">Ebenen</div>
|
||||
<span className="tiny">20</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 1, maxHeight: 220, overflow: "auto" }}>
|
||||
{[
|
||||
{ kind: "wand", label: "Wände" },
|
||||
{ kind: "tisch", label: "Tisch T1 + 5 Sitze", count: 6 },
|
||||
{ kind: "tisch", label: "Tisch T2 + 5 Sitze", count: 6, sel: true },
|
||||
{ kind: "tisch", label: "Tisch T3 + 5 Sitze", count: 6 },
|
||||
{ kind: "tisch", label: "Tisch T4 + 5 Sitze", count: 6 },
|
||||
{ kind: "elem", label: "Pult / Tutor:in" },
|
||||
{ kind: "elem", label: "Beamer" },
|
||||
{ kind: "elem", label: "Tür" },
|
||||
{ kind: "elem", label: "Fenster Nord" },
|
||||
].map((l, i) => (
|
||||
<div key={i} style={{
|
||||
display: "flex", alignItems: "center", gap: 7, padding: "5px 7px", borderRadius: 3,
|
||||
background: l.sel ? "rgba(138,44,31,0.08)" : "transparent",
|
||||
fontSize: 12,
|
||||
color: l.sel ? "var(--accent)" : "var(--ink-2)",
|
||||
fontWeight: l.sel ? 500 : 400,
|
||||
}}>
|
||||
<span style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-4)", width: 16 }}>{l.kind === "wand" ? "▢" : l.kind === "tisch" ? "▤" : "·"}</span>
|
||||
<span style={{ flex: 1 }}>{l.label}</span>
|
||||
{l.count && <span className="tiny mono" style={{ color: l.sel ? "var(--accent)" : "var(--ink-4)" }}>{l.count}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FieldV2 = ({ label, value, mono, suffix }) => (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<span className="tiny">{label}</span>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input className="input" defaultValue={value} style={{ fontFamily: mono ? "var(--mono)" : "var(--sans)", fontSize: 12, width: "100%", paddingRight: suffix ? 32 : 11 }} />
|
||||
{suffix && <span className="tiny mono" style={{ position: "absolute", right: 8, top: "50%", transform: "translateY(-50%)", color: "var(--ink-4)" }}>{suffix}</span>}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// keyframe for selection spin
|
||||
if (typeof document !== "undefined" && !document.getElementById("rooms-anim")) {
|
||||
const s = document.createElement("style");
|
||||
s.id = "rooms-anim";
|
||||
s.textContent = "@keyframes spin { to { transform: rotate(360deg); } }";
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
Object.assign(window, { RoomsScreenV2 });
|
||||
@@ -1,446 +0,0 @@
|
||||
// seatmap.jsx — top-down room layout with tables, chairs, and tutor notes panel
|
||||
|
||||
// SeatMap — pure visual; takes assignments + selectedStudent + variant + onSeatClick
|
||||
function SeatMap({
|
||||
assignments = SEAT_ASSIGN,
|
||||
selectedStudent,
|
||||
onSeatClick,
|
||||
hoveredSeat,
|
||||
setHoveredSeat,
|
||||
variant = "tutor", // "tutor" | "student" | "student-self"
|
||||
ownSeat, // for student view: which seat is "mine"
|
||||
scale = 1,
|
||||
}) {
|
||||
const W = ROOM.width, H = ROOM.height;
|
||||
const studentBy = (id) => STUDENTS.find((s) => s.id === id);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "relative",
|
||||
width: W * scale, height: H * scale,
|
||||
background: "#f7f1e3",
|
||||
border: "1px solid var(--rule)",
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.02), 0 1px 0 rgba(0,0,0,0.03)",
|
||||
}}>
|
||||
{/* Inner ruled grid — like graph paper */}
|
||||
<div style={{
|
||||
position: "absolute", inset: 0,
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgba(110,90,60,0.05) 1px, transparent 1px), " +
|
||||
"linear-gradient(to bottom, rgba(110,90,60,0.05) 1px, transparent 1px)",
|
||||
backgroundSize: `${24*scale}px ${24*scale}px`,
|
||||
}} />
|
||||
|
||||
<div style={{
|
||||
position: "absolute", inset: 0,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
width: W, height: H,
|
||||
}}>
|
||||
{/* Walls */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: ROOM.walls.x, top: ROOM.walls.y,
|
||||
width: ROOM.walls.w, height: ROOM.walls.h,
|
||||
border: "2px solid var(--ink-2)",
|
||||
borderRadius: 2,
|
||||
}} />
|
||||
|
||||
{/* Window */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: ROOM.window.x - 2, top: ROOM.window.y,
|
||||
width: ROOM.window.w + 4, height: ROOM.window.h,
|
||||
background: "#dfeaf0",
|
||||
borderTop: "2px solid var(--ink-2)",
|
||||
borderBottom: "2px solid var(--ink-2)",
|
||||
}}>
|
||||
<div style={{ position: "absolute", left: 1, top: "50%", width: ROOM.window.w + 2, height: 2, background: "var(--ink-2)" }} />
|
||||
</div>
|
||||
<div style={{
|
||||
position: "absolute", left: -8, top: ROOM.window.y + ROOM.window.h / 2 - 6,
|
||||
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
|
||||
transform: "rotate(-90deg)", transformOrigin: "left top",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||||
}}>Fenster</div>
|
||||
|
||||
{/* Door — gap with arc */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: ROOM.door.x, top: ROOM.door.y - 2,
|
||||
width: ROOM.door.w, height: 4,
|
||||
background: "#f7f1e3",
|
||||
}} />
|
||||
<svg style={{ position: "absolute", left: ROOM.door.x, top: ROOM.door.y - 36, pointerEvents: "none" }}
|
||||
width={ROOM.door.w + 10} height="40">
|
||||
<path d={`M 2 38 Q 2 2 ${ROOM.door.w} 2`} stroke="var(--ink-3)" strokeWidth="1" strokeDasharray="2 2" fill="none"/>
|
||||
<line x1="2" y1="38" x2="2" y2="2" stroke="var(--ink-2)" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
<div style={{
|
||||
position: "absolute", left: ROOM.door.x + 6, top: ROOM.door.y + 6,
|
||||
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||||
}}>Tür</div>
|
||||
|
||||
{/* Beamer */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: ROOM.beamer.x, top: ROOM.beamer.y,
|
||||
width: ROOM.beamer.w, height: ROOM.beamer.h,
|
||||
background: "var(--ink-2)", borderRadius: 1,
|
||||
}} />
|
||||
<div style={{
|
||||
position: "absolute", left: ROOM.beamer.x + ROOM.beamer.w + 6, top: ROOM.beamer.y,
|
||||
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||||
}}>Beamer</div>
|
||||
|
||||
{/* Podium */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: ROOM.podium.x, top: ROOM.podium.y,
|
||||
width: ROOM.podium.w, height: ROOM.podium.h,
|
||||
border: "1.5px solid var(--ink-2)",
|
||||
background: "#efe6d2",
|
||||
borderRadius: 2,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink-3)",
|
||||
letterSpacing: "0.15em", textTransform: "uppercase",
|
||||
}}>
|
||||
Pult · Tutor:in
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
{ROOM.tables.map((t) => (
|
||||
<div key={t.id} style={{
|
||||
position: "absolute",
|
||||
left: t.x, top: t.y, width: t.w, height: t.h,
|
||||
background: "#e8dec5",
|
||||
border: "1.5px solid var(--ink-2)",
|
||||
borderRadius: 3,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontFamily: "var(--serif)", fontSize: 22, color: "rgba(31,27,22,0.35)",
|
||||
fontStyle: "italic",
|
||||
}}>
|
||||
{t.label}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Seats */}
|
||||
{SEATS.map((seat) => {
|
||||
const sid = assignments[seat.id];
|
||||
const student = sid ? studentBy(sid) : null;
|
||||
const isSelected = selectedStudent && sid === selectedStudent;
|
||||
const isHover = hoveredSeat === seat.id;
|
||||
const isOwn = ownSeat === seat.id;
|
||||
|
||||
let bg, border, label, labelColor;
|
||||
if (variant === "tutor") {
|
||||
if (student) {
|
||||
bg = isSelected ? "var(--ink)" : (isHover ? "#e0d4b6" : "#fbf7ee");
|
||||
border = isSelected ? "var(--ink)" : "var(--ink-2)";
|
||||
label = student.initials;
|
||||
labelColor = isSelected ? "#f7eedc" : "var(--ink)";
|
||||
} else {
|
||||
bg = "#f7f1e3";
|
||||
border = "var(--ink-4)";
|
||||
label = "";
|
||||
labelColor = "var(--ink-4)";
|
||||
}
|
||||
} else if (variant === "student") {
|
||||
// student picking a seat
|
||||
if (student) {
|
||||
bg = "#d6cdb5"; // grey occupied
|
||||
border = "var(--ink-4)";
|
||||
label = "";
|
||||
} else if (isOwn) {
|
||||
bg = "var(--accent)";
|
||||
border = "var(--accent)";
|
||||
label = "";
|
||||
} else {
|
||||
bg = "#fbf7ee";
|
||||
border = "var(--ink-2)";
|
||||
label = "";
|
||||
}
|
||||
} else if (variant === "student-self") {
|
||||
// read-only own seat
|
||||
if (isOwn) {
|
||||
bg = "var(--accent)";
|
||||
border = "var(--accent)";
|
||||
} else if (student) {
|
||||
bg = "#d6cdb5";
|
||||
border = "var(--ink-4)";
|
||||
} else {
|
||||
bg = "#fbf7ee";
|
||||
border = "var(--ink-3)";
|
||||
}
|
||||
label = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<button key={seat.id}
|
||||
onClick={() => onSeatClick && onSeatClick(seat)}
|
||||
onMouseEnter={() => setHoveredSeat && setHoveredSeat(seat.id)}
|
||||
onMouseLeave={() => setHoveredSeat && setHoveredSeat(null)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: seat.x - 18, top: seat.y - 18,
|
||||
width: 36, height: 36, borderRadius: "50%",
|
||||
background: bg, border: `1.5px solid ${border}`,
|
||||
cursor: onSeatClick ? "pointer" : "default",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontFamily: "var(--sans)", fontWeight: 600, fontSize: 11,
|
||||
color: labelColor, padding: 0,
|
||||
boxShadow: isSelected ? "0 0 0 3px rgba(241,211,106,0.6)" : "none",
|
||||
transition: "background 120ms, border-color 120ms",
|
||||
}}
|
||||
title={student ? student.name : "frei"}>
|
||||
{label}
|
||||
{isOwn && variant !== "tutor" && (
|
||||
<span style={{ color: "#f7eedc", fontSize: 14 }}>★</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Compass */}
|
||||
<div style={{
|
||||
position: "absolute", right: 18, bottom: 14,
|
||||
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
|
||||
letterSpacing: "0.15em",
|
||||
}}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<span>N</span>
|
||||
<svg width="14" height="22"><path d="M7 2 L7 20 M3 6 L7 2 L11 6" stroke="var(--ink-3)" strokeWidth="1" fill="none"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Tutor live view — seat map + roster + notes panel
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function TutorLiveView({ variant = "split" }) {
|
||||
// variant: "split" (map + side panel) | "stacked" (notes below) | "compact"
|
||||
const [selected, setSelected] = React.useState(3);
|
||||
const [hovered, setHovered] = React.useState(null);
|
||||
const [notes, setNotes] = React.useState(NOTES);
|
||||
|
||||
const presentIds = Object.values(SEAT_ASSIGN);
|
||||
const presentSet = new Set(presentIds);
|
||||
const present = STUDENTS.filter((s) => presentSet.has(s.id));
|
||||
const absent = STUDENTS.filter((s) => !presentSet.has(s.id));
|
||||
const sel = STUDENTS.find((s) => s.id === selected);
|
||||
|
||||
const seatOf = (sid) => Object.entries(SEAT_ASSIGN).find(([, v]) => v === sid)?.[0];
|
||||
|
||||
return (
|
||||
<div className="paper-bg" style={{ width: "100%", height: "100%", padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* Header bar */}
|
||||
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 16 }}>
|
||||
<div>
|
||||
<div className="eyebrow" style={{ marginBottom: 4 }}>Tutor:innen-Ansicht · Live</div>
|
||||
<div className="serif" style={{ fontSize: 30, fontWeight: 500, letterSpacing: "-0.01em" }}>
|
||||
Woche 04 <span style={{ color: "var(--ink-4)" }}>·</span> <span className="marker">Donnerstag, 30. April 2026</span>
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 6, display: "flex", gap: 14, alignItems: "center" }}>
|
||||
<span>{COURSE.name}, {COURSE.semester}</span>
|
||||
<span style={{ color: "var(--rule)" }}>·</span>
|
||||
<span>{ROOM.name}</span>
|
||||
<span style={{ color: "var(--rule)" }}>·</span>
|
||||
<span>14:00 – 15:00 Uhr</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="eyebrow">Check-in Code</div>
|
||||
<div className="mono" style={{ fontSize: 22, letterSpacing: "0.18em", fontWeight: 600 }}>K7QJ-MX2P</div>
|
||||
<div className="tiny" style={{ marginTop: 2 }}>tutor.puchstein.dev/s/K7QJMX2P</div>
|
||||
</div>
|
||||
<StatusPill status="open" />
|
||||
<button className="btn ghost sm"><Icon.copy /> Kopieren</button>
|
||||
<button className="btn"><Icon.lock /> Sperren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: "1px solid var(--rule)" }} />
|
||||
|
||||
{/* Body grid */}
|
||||
<div style={{ flex: 1, display: "grid", gridTemplateColumns: "1fr 380px", gap: 24, minHeight: 0 }}>
|
||||
{/* Seat map column */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, minHeight: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 18, fontWeight: 500 }}>Sitzplan</div>
|
||||
<UnderlineStroke width={70} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 14, alignItems: "center" }} className="small">
|
||||
<LegendDot color="var(--ink)" label="anwesend" />
|
||||
<LegendDot color="#f7f1e3" outline="var(--ink-4)" label="frei" />
|
||||
<LegendDot color="var(--highlight-soft)" label="ausgewählt" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", flex: 1 }}>
|
||||
<SeatMap
|
||||
assignments={SEAT_ASSIGN}
|
||||
selectedStudent={selected}
|
||||
hoveredSeat={hovered}
|
||||
setHoveredSeat={setHovered}
|
||||
onSeatClick={(seat) => {
|
||||
const sid = SEAT_ASSIGN[seat.id];
|
||||
if (sid) setSelected(sid);
|
||||
}}
|
||||
variant="tutor"
|
||||
scale={0.85}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tally */}
|
||||
<div style={{ display: "flex", gap: 24, alignItems: "center", paddingTop: 4 }}>
|
||||
<Tally label="Anwesend" value={present.length} total={STUDENTS.length} accent="var(--green)" />
|
||||
<Tally label="Fehlt" value={absent.length} total={STUDENTS.length} accent="var(--accent)" />
|
||||
<Tally label="Bonus heute" value={`+${present.length * 3}`} suffix="Punkte" />
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn ghost sm"><Icon.plus /> Manuell eintragen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes panel column */}
|
||||
<div className="card" style={{ display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden" }}>
|
||||
{/* Roster */}
|
||||
<div style={{ padding: "14px 16px 10px", borderBottom: "1px solid var(--rule)" }}>
|
||||
<div className="serif" style={{ fontSize: 16, fontWeight: 500 }}>Studierende</div>
|
||||
<div className="tiny" style={{ marginTop: 2 }}>{present.length} anwesend · {absent.length} fehlen</div>
|
||||
</div>
|
||||
|
||||
<div className="scroll" style={{ overflowY: "auto", maxHeight: 220, padding: "6px 0" }}>
|
||||
{present.map((s) => {
|
||||
const isSel = s.id === selected;
|
||||
const hasNote = notes[s.id];
|
||||
return (
|
||||
<button key={s.id}
|
||||
onClick={() => setSelected(s.id)}
|
||||
className="row-hover"
|
||||
style={{
|
||||
width: "100%", textAlign: "left", border: "none", background: isSel ? "rgba(31,27,22,0.06)" : "transparent",
|
||||
padding: "7px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
|
||||
borderLeft: isSel ? "3px solid var(--ink)" : "3px solid transparent",
|
||||
}}>
|
||||
<span style={{
|
||||
width: 22, height: 22, borderRadius: "50%",
|
||||
background: "var(--ink)", color: "var(--paper)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontFamily: "var(--sans)", fontSize: 9, fontWeight: 600,
|
||||
}}>{s.initials}</span>
|
||||
<span style={{ flex: 1, fontSize: 13 }}>{s.name}</span>
|
||||
{hasNote && <span title="hat Notiz" style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)" }} />}
|
||||
<span className="mono tiny" style={{ color: "var(--ink-4)" }}>{CHECKED_IN_AT[s.id]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{absent.map((s) => (
|
||||
<button key={s.id}
|
||||
onClick={() => setSelected(s.id)}
|
||||
className="row-hover"
|
||||
style={{
|
||||
width: "100%", textAlign: "left", border: "none", background: "transparent",
|
||||
padding: "7px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
|
||||
opacity: 0.55,
|
||||
}}>
|
||||
<span style={{
|
||||
width: 22, height: 22, borderRadius: "50%",
|
||||
background: "transparent", color: "var(--ink-3)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontFamily: "var(--sans)", fontSize: 9, fontWeight: 600,
|
||||
border: "1px dashed var(--ink-4)",
|
||||
}}>{s.initials}</span>
|
||||
<span style={{ flex: 1, fontSize: 13, textDecoration: "line-through", textDecorationColor: "var(--ink-4)" }}>{s.name}</span>
|
||||
<span className="mono tiny" style={{ color: "var(--ink-4)" }}>—</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Note editor */}
|
||||
<div style={{ borderTop: "1px solid var(--rule)", padding: "14px 16px", flex: 1, display: "flex", flexDirection: "column", background: "#fbf7ee" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{
|
||||
width: 32, height: 32, borderRadius: "50%",
|
||||
background: "var(--ink)", color: "var(--paper)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontFamily: "var(--sans)", fontSize: 11, fontWeight: 600,
|
||||
}}>{sel?.initials}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="serif" style={{ fontSize: 16, fontWeight: 500 }}>{sel?.name}</div>
|
||||
<div className="tiny">Sitzplatz {seatOf(selected) || "—"} · seit {CHECKED_IN_AT[selected] || "—"}</div>
|
||||
</div>
|
||||
<span className="stamp">Präsent</span>
|
||||
</div>
|
||||
|
||||
<div className="eyebrow" style={{ marginTop: 6, marginBottom: 6 }}>Notiz · Woche 04</div>
|
||||
<textarea
|
||||
value={notes[selected] || ""}
|
||||
onChange={(e) => setNotes({ ...notes, [selected]: e.target.value })}
|
||||
placeholder="Beobachtungen für diese Woche…"
|
||||
className="ruled"
|
||||
style={{
|
||||
flex: 1, minHeight: 110, resize: "none",
|
||||
fontFamily: "var(--serif)", fontSize: 15, lineHeight: "28px",
|
||||
padding: "0 0 4px 0",
|
||||
background: "transparent", border: "none", outline: "none",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Quick tags */}
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
|
||||
{["aktiv beteiligt", "stille:r Kämpfer:in", "verstanden ✓", "nochmal aufgreifen", "Rückfrage offen", "elegante Lösung"].map((tag) => (
|
||||
<button key={tag} className="pill closed" style={{ borderColor: "var(--rule)", cursor: "pointer", fontFamily: "var(--sans)", textTransform: "none", fontSize: 11, letterSpacing: 0 }}>
|
||||
+ {tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="tiny" style={{ marginTop: 10, display: "flex", justifyContent: "space-between" }}>
|
||||
<span>Auto-gespeichert · 14:23</span>
|
||||
<a href="#" style={{ color: "var(--ink-3)" }}>Notizen vergangener Wochen ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendDot({ color, outline, label }) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
background: color, border: outline ? `1px solid ${outline}` : "none",
|
||||
}} />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Tally({ label, value, total, suffix, accent }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="eyebrow" style={{ marginBottom: 2 }}>{label}</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 5 }}>
|
||||
<span className="serif" style={{ fontSize: 28, fontWeight: 500, color: accent || "var(--ink)" }}>{value}</span>
|
||||
{total != null && <span className="small">/ {total}</span>}
|
||||
{suffix && <span className="small" style={{ marginLeft: 2 }}>{suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { SeatMap, TutorLiveView, LegendDot, Tally });
|
||||
@@ -1,138 +0,0 @@
|
||||
// shared.jsx — fake data + tiny UI helpers used everywhere
|
||||
|
||||
const COURSE = {
|
||||
name: "Funktionale Programmierung",
|
||||
semester: "SS 2026",
|
||||
tutorin: "Lina Puchstein",
|
||||
weekday: "Donnerstag",
|
||||
};
|
||||
|
||||
const STUDENTS = [
|
||||
{ id: 1, name: "Aaron Becker", initials: "AB" },
|
||||
{ id: 2, name: "Anna Lehmann", initials: "AL" },
|
||||
{ id: 3, name: "Ben Hartmann", initials: "BH" },
|
||||
{ id: 4, name: "Carla Vogt", initials: "CV" },
|
||||
{ id: 5, name: "David Köhler", initials: "DK" },
|
||||
{ id: 6, name: "Elif Yıldız", initials: "EY" },
|
||||
{ id: 7, name: "Felix Braun", initials: "FB" },
|
||||
{ id: 8, name: "Greta Sommer", initials: "GS" },
|
||||
{ id: 9, name: "Henrik Roth", initials: "HR" },
|
||||
{ id: 10, name: "Ida Neumann", initials: "IN" },
|
||||
{ id: 11, name: "Jakob Frank", initials: "JF" },
|
||||
{ id: 12, name: "Klara Wagner", initials: "KW" },
|
||||
{ id: 13, name: "Leonie Krause", initials: "LK" },
|
||||
{ id: 14, name: "Marek Schulz", initials: "MS" },
|
||||
{ id: 15, name: "Nina Albrecht", initials: "NA" },
|
||||
{ id: 16, name: "Otto Brandt", initials: "OB" },
|
||||
{ id: 17, name: "Paula Engel", initials: "PE" },
|
||||
{ id: 18, name: "Rafael Diaz", initials: "RD" },
|
||||
{ id: 19, name: "Sophia Weiß", initials: "SW" },
|
||||
];
|
||||
|
||||
// Room layout: tables with chairs around them — top-down floor plan.
|
||||
// Tables are rectangles; seats are positioned along the long edges.
|
||||
// coords are in % of room canvas (we render onto an absolutely positioned div).
|
||||
const ROOM = {
|
||||
name: "BC2 1.103",
|
||||
width: 760, // px design space
|
||||
height: 460,
|
||||
// structural elements
|
||||
walls: { x: 12, y: 12, w: 736, h: 436 },
|
||||
door: { x: 60, y: 448, w: 70, h: 6, label: "Tür" },
|
||||
window:{ x: 12, y: 120, w: 6, h: 220, label: "Fenster" },
|
||||
beamer:{ x: 372, y: 24, w: 110, h: 8, label: "Beamer" },
|
||||
podium:{ x: 332, y: 60, w: 190, h: 38, label: "Pult" },
|
||||
// 4 group tables, each with 5 seats around it (2 top, 2 bottom, 1 short edge)
|
||||
tables: [
|
||||
{ id: "T1", x: 90, y: 150, w: 200, h: 70, label: "T1" },
|
||||
{ id: "T2", x: 470, y: 150, w: 200, h: 70, label: "T2" },
|
||||
{ id: "T3", x: 90, y: 320, w: 200, h: 70, label: "T3" },
|
||||
{ id: "T4", x: 470, y: 320, w: 200, h: 70, label: "T4" },
|
||||
],
|
||||
};
|
||||
|
||||
// Generate seats around each table. Seat coords reference the seat circle's CENTER.
|
||||
function makeSeats() {
|
||||
const seats = [];
|
||||
ROOM.tables.forEach((t) => {
|
||||
// top edge: 2 seats
|
||||
seats.push({ id: `${t.id}-1`, x: t.x + t.w * 0.28, y: t.y - 22, table: t.id });
|
||||
seats.push({ id: `${t.id}-2`, x: t.x + t.w * 0.72, y: t.y - 22, table: t.id });
|
||||
// bottom edge: 2 seats
|
||||
seats.push({ id: `${t.id}-3`, x: t.x + t.w * 0.28, y: t.y + t.h + 22, table: t.id });
|
||||
seats.push({ id: `${t.id}-4`, x: t.x + t.w * 0.72, y: t.y + t.h + 22, table: t.id });
|
||||
// short edge (head): 1 seat
|
||||
seats.push({ id: `${t.id}-5`, x: t.x + t.w + 26, y: t.y + t.h / 2, table: t.id });
|
||||
});
|
||||
return seats;
|
||||
}
|
||||
const SEATS = makeSeats();
|
||||
|
||||
// Pre-assign 14 students to seats for the live view
|
||||
const SEAT_ASSIGN = {
|
||||
"T1-1": 1, "T1-2": 2, "T1-3": 3, "T1-4": 4, "T1-5": 5,
|
||||
"T2-1": 6, "T2-2": 7, "T2-3": 8, "T2-5": 10,
|
||||
"T3-1": 11, "T3-2": 12, "T3-4": 14,
|
||||
"T4-1": 15, "T4-3": 17,
|
||||
};
|
||||
|
||||
const CHECKED_IN_AT = {
|
||||
1: "14:02", 2: "14:01", 3: "14:00", 4: "14:03", 5: "14:01",
|
||||
6: "14:04", 7: "14:02", 8: "14:05", 10: "14:08",
|
||||
11: "14:11", 12: "14:06", 14: "14:09",
|
||||
15: "14:12", 17: "14:18",
|
||||
};
|
||||
|
||||
// Notes pre-filled for the live view
|
||||
const NOTES = {
|
||||
3: "Sehr aktiv heute — hat die Lösung zu Aufgabe 3 erklärt. Funktoren sitzen.",
|
||||
5: "Hängt bei Currying noch. Mit Marek gepaart, läuft besser.",
|
||||
10: "Kommt zu spät rein, sehr ruhig. Kurz nachfragen ob alles ok.",
|
||||
14: "Hat eine elegante Foldr-Lösung gefunden — bei nächstem Mal als Beispiel zeigen.",
|
||||
};
|
||||
|
||||
// Rendering helpers ---------------------------------------------------------
|
||||
|
||||
const StatusPill = ({ status }) => {
|
||||
const map = {
|
||||
open: { label: "OFFEN", cls: "open" },
|
||||
closed: { label: "GESCHL.", cls: "closed" },
|
||||
locked: { label: "GESPERRT", cls: "locked" },
|
||||
present: { label: "ANWESEND", cls: "present" },
|
||||
absent: { label: "FEHLT", cls: "absent" },
|
||||
};
|
||||
const m = map[status] || map.closed;
|
||||
return (
|
||||
<span className={`pill ${m.cls}`}>
|
||||
<span className="dot"></span>
|
||||
{m.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Hand-drawn underline stroke
|
||||
const UnderlineStroke = ({ width = 110, color = "var(--accent)" }) => (
|
||||
<svg className="underline-stroke" width={width} height="8" viewBox={`0 0 ${width} 8`} fill="none">
|
||||
<path d={`M 2 5 Q ${width*0.25} 1 ${width*0.5} 4 T ${width-2} 3`}
|
||||
stroke={color} strokeWidth="1.6" strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Tiny check / x icons
|
||||
const Icon = {
|
||||
check: (p={}) => <svg width="14" height="14" viewBox="0 0 14 14" {...p}><path d="M2.5 7.5 L5.5 10.5 L11.5 3.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
x: (p={}) => <svg width="12" height="12" viewBox="0 0 12 12" {...p}><path d="M3 3 L9 9 M9 3 L3 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>,
|
||||
lock: (p={}) => <svg width="13" height="13" viewBox="0 0 13 13" {...p}><rect x="2.5" y="6" width="8" height="5.5" rx="1" stroke="currentColor" strokeWidth="1.3" fill="none"/><path d="M4.5 6 V4.5 a2 2 0 0 1 4 0 V6" stroke="currentColor" strokeWidth="1.3" fill="none"/></svg>,
|
||||
open: (p={}) => <svg width="13" height="13" viewBox="0 0 13 13" {...p}><rect x="2.5" y="6" width="8" height="5.5" rx="1" stroke="currentColor" strokeWidth="1.3" fill="none"/><path d="M4.5 6 V4.5 a2 2 0 0 1 4 0" stroke="currentColor" strokeWidth="1.3" fill="none"/></svg>,
|
||||
copy: (p={}) => <svg width="13" height="13" viewBox="0 0 13 13" {...p}><rect x="2" y="2" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="1.2" fill="none"/><rect x="4.5" y="4.5" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="1.2" fill="none"/></svg>,
|
||||
edit: (p={}) => <svg width="13" height="13" viewBox="0 0 13 13" {...p}><path d="M2 11 L2 9 L9 2 L11 4 L4 11 Z" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinejoin="round"/></svg>,
|
||||
download: (p={}) => <svg width="14" height="14" viewBox="0 0 14 14" {...p}><path d="M7 2 V9 M3.5 6 L7 9 L10.5 6 M2.5 11.5 H11.5" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
arrow: (p={}) => <svg width="12" height="12" viewBox="0 0 12 12" {...p}><path d="M3 6 H9 M6.5 3 L9 6 L6.5 9" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
search:(p={}) => <svg width="13" height="13" viewBox="0 0 13 13" {...p}><circle cx="5.5" cy="5.5" r="3.5" stroke="currentColor" strokeWidth="1.3" fill="none"/><path d="M8 8 L11 11" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/></svg>,
|
||||
plus: (p={}) => <svg width="12" height="12" viewBox="0 0 12 12" {...p}><path d="M6 2 V10 M2 6 H10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>,
|
||||
};
|
||||
|
||||
Object.assign(window, {
|
||||
COURSE, STUDENTS, ROOM, SEATS, SEAT_ASSIGN, CHECKED_IN_AT, NOTES,
|
||||
StatusPill, UnderlineStroke, Icon,
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
// student-desktop.jsx — desktop/laptop variant of student check-in screens
|
||||
|
||||
const StudentDesktopName = () => {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const filtered = STUDENTS.filter((s) => s.name.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="paper-bg" style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 40 }}>
|
||||
<div style={{ width: 560 }}>
|
||||
<div className="eyebrow">Check-in · K7QJ-MX2P</div>
|
||||
<div className="serif" style={{ fontSize: 42, fontWeight: 500, letterSpacing: "-0.015em", marginTop: 4 }}>
|
||||
Wer bist du?
|
||||
</div>
|
||||
<div className="body" style={{ marginTop: 6, color: "var(--ink-3)" }}>
|
||||
{COURSE.name} · Donnerstag 30. April · 14 Uhr · {ROOM.name}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: 24, padding: 22 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 13px", border: "1px solid var(--rule)", borderRadius: 5, background: "#fbf7ee" }}>
|
||||
<Icon.search style={{ color: "var(--ink-3)" }} />
|
||||
<input value={query} onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
placeholder="Name eingeben…"
|
||||
style={{ border: "none", background: "transparent", outline: "none", fontSize: 15, flex: 1 }} />
|
||||
<span className="tiny mono" style={{ color: "var(--ink-4)" }}>{filtered.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="scroll" style={{ marginTop: 14, maxHeight: 320, overflowY: "auto", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 4 }}>
|
||||
{filtered.map((s, i) => (
|
||||
<button key={s.id} className="row-hover" style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "8px 10px", border: "none", background: "transparent",
|
||||
textAlign: "left", cursor: "pointer", borderRadius: 4,
|
||||
}}>
|
||||
<span style={{ width: 28, height: 28, borderRadius: "50%", background: "var(--paper-2)", color: "var(--ink-2)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 600 }}>{s.initials}</span>
|
||||
<span style={{ fontSize: 14 }}>{s.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tiny" style={{ marginTop: 14, textAlign: "center", color: "var(--ink-4)" }}>
|
||||
Tipp: <span className="mono">Tab</span> zum Springen · <span className="mono">↵</span> zum Bestätigen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StudentDesktopSeat = () => {
|
||||
return (
|
||||
<div className="paper-bg" style={{ width: "100%", height: "100%", display: "grid", gridTemplateColumns: "1fr 360px", gap: 0, overflow: "hidden" }}>
|
||||
{/* Map column */}
|
||||
<div style={{ padding: "32px 36px", display: "flex", flexDirection: "column", gap: 14, minHeight: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 16 }}>
|
||||
<div>
|
||||
<div className="eyebrow">Hallo, Carla 👋</div>
|
||||
<div className="serif" style={{ fontSize: 32, fontWeight: 500, letterSpacing: "-0.015em", marginTop: 2 }}>
|
||||
Wähle deinen <span className="marker">Sitz</span>
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 4 }}>
|
||||
Klicke auf einen freien Platz — Belegtzeichen sind grau.
|
||||
</div>
|
||||
</div>
|
||||
<div className="tiny mono" style={{ textAlign: "right" }}>
|
||||
<div style={{ color: "var(--ink-4)" }}>Slot</div>
|
||||
<div style={{ fontSize: 13, color: "var(--ink)", letterSpacing: "0.1em" }}>K7QJ-MX2P</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center", alignItems: "center", minHeight: 0 }}>
|
||||
<SeatMap variant="student" assignments={SEAT_ASSIGN} ownSeat={null} scale={0.78} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 22, paddingTop: 4 }} className="small">
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: "50%", background: "#fbf7ee", border: "1.5px solid var(--ink-2)" }} /> frei
|
||||
</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: "50%", background: "#d6cdb5" }} /> belegt
|
||||
</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: "50%", background: "var(--accent)" }} /> dein Sitz
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
<aside style={{ borderLeft: "1px solid var(--rule)", background: "rgba(0,0,0,0.015)", padding: "32px 24px", display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div>
|
||||
<div className="eyebrow">Sitzung</div>
|
||||
<div className="serif" style={{ fontSize: 18, fontWeight: 500, marginTop: 4 }}>{COURSE.name}</div>
|
||||
<div className="small" style={{ marginTop: 2 }}>Do 30. April · 14:00 – 15:00</div>
|
||||
<div className="small">{ROOM.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="div-h" />
|
||||
|
||||
<div>
|
||||
<div className="eyebrow">Eingecheckt als</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 8 }}>
|
||||
<span style={{ width: 32, height: 32, borderRadius: "50%", background: "var(--ink)", color: "var(--paper)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 600 }}>CV</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>Carla Vogt</div>
|
||||
<div className="tiny">nicht du? <a href="#" style={{ color: "var(--accent)" }}>wechseln</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="div-h" />
|
||||
|
||||
<div>
|
||||
<div className="eyebrow">Hinweise</div>
|
||||
<ul style={{ marginTop: 8, paddingLeft: 16, fontSize: 13, color: "var(--ink-2)", lineHeight: 1.55 }}>
|
||||
<li>Du kannst deinen Sitz wechseln, solange der Slot offen ist.</li>
|
||||
<li>Belegte Sitze zeigen <strong>keine Namen</strong> — Datenschutz.</li>
|
||||
<li>Wenn der Slot gesperrt ist, bleibt dein Sitz sichtbar.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div className="card" style={{ padding: 12, background: "rgba(241,211,106,0.16)", border: "1px solid rgba(176,125,42,0.3)" }}>
|
||||
<div className="eyebrow" style={{ color: "var(--amber)" }}>Bonus</div>
|
||||
<div style={{ fontSize: 13, marginTop: 4 }}>+3 Punkte sobald du einen Sitz wählst.</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StudentDesktopConfirmed = () => {
|
||||
return (
|
||||
<div className="paper-bg" style={{ width: "100%", height: "100%", display: "grid", gridTemplateColumns: "1fr 360px", overflow: "hidden" }}>
|
||||
<div style={{ padding: "32px 36px", display: "flex", flexDirection: "column", gap: 14, minHeight: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16 }}>
|
||||
<div>
|
||||
<div className="eyebrow">Eingecheckt um 14:03</div>
|
||||
<div className="serif" style={{ fontSize: 32, fontWeight: 500, letterSpacing: "-0.015em", marginTop: 2 }}>
|
||||
Du sitzt auf <span className="marker">T1-3</span>
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 4 }}>
|
||||
Anwesenheit erfasst · +3 Bonuspunkte für diese Woche.
|
||||
</div>
|
||||
</div>
|
||||
<span className="stamp" style={{ marginTop: 8, fontSize: 13, padding: "5px 12px" }}>Präsent</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center", alignItems: "center", minHeight: 0 }}>
|
||||
<SeatMap variant="student-self" assignments={SEAT_ASSIGN} ownSeat="T1-3" scale={0.78} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "center" }}>
|
||||
<button className="btn ghost sm">Sitz wechseln</button>
|
||||
<button className="btn ghost sm">Diese Seite drucken</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside style={{ borderLeft: "1px solid var(--rule)", background: "rgba(0,0,0,0.015)", padding: "32px 24px", display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div>
|
||||
<div className="eyebrow">Slot-Status</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6 }}>
|
||||
<StatusPill status="open" />
|
||||
<span className="tiny">sperrt ~14:15</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="div-h" />
|
||||
|
||||
<div>
|
||||
<div className="eyebrow">Deine Saison</div>
|
||||
<div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[
|
||||
{ week: "W04", date: "Do 30. April", seat: "T1-3", state: "present", today: true },
|
||||
{ week: "W03", date: "Do 23. April", seat: "T1-3", state: "present" },
|
||||
{ week: "W02", date: "Do 16. April", seat: "T2-4", state: "present" },
|
||||
{ week: "W01", date: "Do 09. April", seat: "—", state: "absent" },
|
||||
].map((r) => (
|
||||
<div key={r.week} style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 12 }}>
|
||||
<span className="mono" style={{ width: 36, color: r.today ? "var(--accent)" : "var(--ink-3)", fontWeight: r.today ? 600 : 400 }}>{r.week}</span>
|
||||
<span style={{ flex: 1, color: r.today ? "var(--ink)" : "var(--ink-2)" }}>{r.date}</span>
|
||||
<span className="mono tiny" style={{ color: "var(--ink-4)" }}>{r.seat}</span>
|
||||
{r.state === "present" ? (
|
||||
<span style={{ display: "inline-flex", width: 18, height: 18, borderRadius: "50%", background: "rgba(74,107,58,0.14)", color: "var(--green)", alignItems: "center", justifyContent: "center" }}>
|
||||
<Icon.check />
|
||||
</span>
|
||||
) : (
|
||||
<span className="tiny" style={{ width: 18, textAlign: "center", color: "var(--ink-4)" }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 14, padding: "10px 12px", background: "rgba(138,44,31,0.05)", border: "1px solid rgba(138,44,31,0.18)", borderRadius: 4 }}>
|
||||
<div className="eyebrow" style={{ color: "var(--accent)" }}>Bonus gesamt</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 4, marginTop: 2 }}>
|
||||
<span className="serif" style={{ fontSize: 26, fontWeight: 500, color: "var(--accent)" }}>9</span>
|
||||
<span className="small">/ 12 Punkte</span>
|
||||
</div>
|
||||
<div className="tiny" style={{ marginTop: 2 }}>3 von 4 Tutorien besucht</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div className="tiny" style={{ color: "var(--ink-4)" }}>
|
||||
Nächstes Tutorium: Do, 7. Mai · 14:00
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { StudentDesktopName, StudentDesktopSeat, StudentDesktopConfirmed });
|
||||
@@ -1,153 +0,0 @@
|
||||
// student.jsx — student-facing check-in screens (in iOS frames)
|
||||
|
||||
const StudentNamePicker = () => {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const filtered = STUDENTS.filter((s) => s.name.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="paper-bg" style={{ width: "100%", height: "100%", padding: "16px 18px", display: "flex", flexDirection: "column", gap: 14, overflow: "hidden" }}>
|
||||
<div>
|
||||
<div className="eyebrow" style={{ fontSize: 9 }}>Check-in · K7QJ-MX2P</div>
|
||||
<div className="serif" style={{ fontSize: 24, fontWeight: 500, letterSpacing: "-0.01em", marginTop: 2 }}>
|
||||
Wer bist du?
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 4 }}>
|
||||
{COURSE.name} · Do 30. April · 14 Uhr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 11px", border: "1px solid var(--rule)", borderRadius: 6, background: "#fbf7ee" }}>
|
||||
<Icon.search style={{ color: "var(--ink-3)" }} />
|
||||
<input value={query} onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Name eingeben…"
|
||||
style={{ border: "none", background: "transparent", outline: "none", fontSize: 14, flex: 1 }} />
|
||||
</div>
|
||||
|
||||
<div className="scroll" style={{ flex: 1, overflowY: "auto", margin: "0 -4px" }}>
|
||||
{filtered.map((s) => (
|
||||
<button key={s.id} style={{
|
||||
width: "100%", display: "flex", alignItems: "center", gap: 11,
|
||||
padding: "10px 4px", border: "none", background: "transparent",
|
||||
borderBottom: "1px solid var(--rule-soft)", textAlign: "left", cursor: "pointer",
|
||||
}}>
|
||||
<span style={{ width: 30, height: 30, borderRadius: "50%", background: "var(--paper-2)", color: "var(--ink-2)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 600 }}>{s.initials}</span>
|
||||
<span style={{ fontSize: 14 }}>{s.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="tiny" style={{ textAlign: "center", color: "var(--ink-4)" }}>
|
||||
Nicht in der Liste? Sprich die Tutor:in an.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StudentSeatPicker = () => {
|
||||
return (
|
||||
<div className="paper-bg" style={{ width: "100%", height: "100%", padding: "14px 14px", display: "flex", flexDirection: "column", gap: 10, overflow: "hidden" }}>
|
||||
<div>
|
||||
<div className="eyebrow" style={{ fontSize: 9 }}>Hallo, Carla 👋</div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, marginTop: 2, letterSpacing: "-0.01em" }}>
|
||||
Wähle deinen Sitz
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 2 }}>
|
||||
Tippe auf einen freien Platz · {ROOM.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini seat map scaled to fit phone width */}
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<SeatMap variant="student" assignments={SEAT_ASSIGN} ownSeat={null} scale={0.46} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 12, justifyContent: "center", padding: "6px 0" }} className="tiny">
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: "50%", background: "#fbf7ee", border: "1.5px solid var(--ink-2)" }} />
|
||||
frei
|
||||
</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: "50%", background: "#d6cdb5" }} />
|
||||
belegt
|
||||
</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: "50%", background: "var(--accent)" }} />
|
||||
dein Sitz
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: "10px 12px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 18 }}>ℹ️</span>
|
||||
<div className="tiny" style={{ flex: 1 }}>
|
||||
Du kannst deinen Sitz wechseln, solange der Slot offen ist.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StudentConfirmed = () => {
|
||||
return (
|
||||
<div className="paper-bg" style={{ width: "100%", height: "100%", padding: "14px 14px", display: "flex", flexDirection: "column", gap: 10, overflow: "hidden" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 10 }}>
|
||||
<div>
|
||||
<div className="eyebrow" style={{ fontSize: 9 }}>Eingecheckt um 14:03</div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, marginTop: 2, letterSpacing: "-0.01em" }}>
|
||||
Du sitzt auf <span className="marker">T1-3</span>
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 2 }}>
|
||||
+3 Bonuspunkte für diese Woche
|
||||
</div>
|
||||
</div>
|
||||
<span className="stamp" style={{ marginTop: 6 }}>Präsent</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<SeatMap variant="student-self" assignments={SEAT_ASSIGN} ownSeat="T1-3" scale={0.46} />
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: "10px 12px" }}>
|
||||
<div className="eyebrow">Slot-Status</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 4 }}>
|
||||
<StatusPill status="open" />
|
||||
<span className="tiny">Sperrt voraussichtlich um 14:15</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn ghost sm" style={{ justifyContent: "center" }}>Sitz wechseln</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StudentLocked = () => {
|
||||
return (
|
||||
<div className="paper-bg" style={{ width: "100%", height: "100%", padding: "14px 14px", display: "flex", flexDirection: "column", gap: 10, overflow: "hidden" }}>
|
||||
<div>
|
||||
<div className="eyebrow" style={{ fontSize: 9 }}>Slot gesperrt · 14:18</div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500, marginTop: 2, letterSpacing: "-0.01em" }}>
|
||||
Anwesenheit erfasst
|
||||
</div>
|
||||
<div className="small" style={{ marginTop: 2 }}>
|
||||
Dein Sitz für diese Woche bleibt sichtbar.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<SeatMap variant="student-self" assignments={SEAT_ASSIGN} ownSeat="T1-3" scale={0.46} />
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: "10px 12px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Icon.lock style={{ color: "var(--accent)" }} />
|
||||
<div className="tiny" style={{ flex: 1 }}>
|
||||
Check-in für diesen Slot ist geschlossen. Bonus wurde gutgeschrieben.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tiny" style={{ textAlign: "center", color: "var(--ink-4)", marginTop: 4 }}>
|
||||
Nächstes Tutorium: Do, 7. Mai · 14:00
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { StudentNamePicker, StudentSeatPicker, StudentConfirmed, StudentLocked });
|
||||
@@ -1,151 +0,0 @@
|
||||
/* Academic / paper-inspired design system for Tutormanager */
|
||||
|
||||
:root {
|
||||
--paper: #f4efe6;
|
||||
--paper-2: #ebe4d6;
|
||||
--paper-3: #ded4c0;
|
||||
--rule: #c9bfa9;
|
||||
--rule-soft: #d9d0bb;
|
||||
--ink: #1f1b16;
|
||||
--ink-2: #3a342b;
|
||||
--ink-3: #6b6356;
|
||||
--ink-4: #968b7a;
|
||||
--accent: #8a2c1f; /* oxblood */
|
||||
--accent-soft: #c66a5b;
|
||||
--highlight: #f1d36a; /* highlighter yellow */
|
||||
--highlight-soft: #f5e3a4;
|
||||
--green: #4a6b3a; /* present */
|
||||
--red: #8a2c1f; /* absent / lock */
|
||||
--amber: #b07d2a; /* open */
|
||||
|
||||
--serif: "Source Serif 4", "Source Serif Pro", "EB Garamond", Georgia, serif;
|
||||
--sans: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
/* Paper grain — subtle SVG noise overlay */
|
||||
.paper-bg {
|
||||
background-color: var(--paper);
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 15%, rgba(160,140,110,0.05) 0, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(160,140,110,0.04) 0, transparent 50%),
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.12 0 0 0 0 0.10 0 0 0 0 0.07 0 0 0 0.06 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
}
|
||||
|
||||
/* Ruled lines — like a notebook */
|
||||
.ruled {
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent 27px,
|
||||
var(--rule-soft) 27px,
|
||||
var(--rule-soft) 28px
|
||||
);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body, .ui {
|
||||
font-family: var(--sans);
|
||||
color: var(--ink);
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
}
|
||||
|
||||
.serif { font-family: var(--serif); font-weight: 400; letter-spacing: -0.01em; }
|
||||
.mono { font-family: var(--mono); }
|
||||
|
||||
.h1 { font-family: var(--serif); font-weight: 500; font-size: 44px; line-height: 1.05; letter-spacing: -0.02em; color: var(--ink); }
|
||||
.h2 { font-family: var(--serif); font-weight: 500; font-size: 28px; line-height: 1.15; letter-spacing: -0.01em; color: var(--ink); }
|
||||
.h3 { font-family: var(--serif); font-weight: 500; font-size: 20px; line-height: 1.2; color: var(--ink); }
|
||||
.eyebrow { font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.body { font-family: var(--sans); font-size: 14px; line-height: 1.5; color: var(--ink-2); }
|
||||
.small { font-family: var(--sans); font-size: 12px; color: var(--ink-3); }
|
||||
.tiny { font-family: var(--sans); font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
/* Underline marker — looks like a hand drawn highlight stroke */
|
||||
.marker {
|
||||
background: linear-gradient(180deg, transparent 60%, var(--highlight-soft) 60%, var(--highlight-soft) 92%, transparent 92%);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* Status pills */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
padding: 3px 9px; border-radius: 999px;
|
||||
border: 1px solid currentColor;
|
||||
background: transparent;
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
.pill.open { color: var(--amber); background: rgba(176,125,42,0.08); }
|
||||
.pill.closed { color: var(--ink-3); }
|
||||
.pill.locked { color: var(--accent); background: rgba(138,44,31,0.06); }
|
||||
.pill.present { color: var(--green); background: rgba(74,107,58,0.08); }
|
||||
.pill.absent { color: var(--accent); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
font-family: var(--sans); font-size: 13px; font-weight: 500;
|
||||
padding: 8px 14px; border-radius: 6px;
|
||||
border: 1px solid var(--ink); background: var(--ink); color: var(--paper);
|
||||
cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.btn.ghost { background: transparent; color: var(--ink); border-color: var(--rule); }
|
||||
.btn.ghost:hover { background: rgba(0,0,0,0.04); }
|
||||
.btn.accent { background: var(--accent); border-color: var(--accent); color: #f7eedc; }
|
||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: #fbf7ee;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
/* Marginalia — handwritten look. Use Caveat as marginal handwriting. */
|
||||
.handwritten {
|
||||
font-family: "Caveat", "Kalam", cursive;
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Custom scrollbars */
|
||||
.scroll::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.scroll::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 8px; }
|
||||
.scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* hand-drawn underline svg accent under section titles */
|
||||
.underline-stroke { display: inline-block; margin-top: 2px; }
|
||||
|
||||
/* Stamp — for "PRÄSENT" mark */
|
||||
.stamp {
|
||||
display: inline-block; font-family: var(--mono); font-weight: 700; font-size: 11px;
|
||||
letter-spacing: 0.15em; text-transform: uppercase;
|
||||
color: var(--accent); border: 2px solid var(--accent);
|
||||
padding: 3px 8px; border-radius: 3px;
|
||||
transform: rotate(-4deg);
|
||||
background: rgba(138,44,31,0.04);
|
||||
}
|
||||
|
||||
/* Subtle hover row */
|
||||
.row-hover:hover { background: rgba(0,0,0,0.025); }
|
||||
|
||||
/* Tab bar */
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--rule); }
|
||||
.tab { font-family: var(--sans); font-size: 13px; padding: 10px 14px; color: var(--ink-3); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tab.active { color: var(--ink); border-bottom-color: var(--ink); }
|
||||
|
||||
/* Input */
|
||||
.input {
|
||||
font-family: var(--sans); font-size: 13px;
|
||||
padding: 8px 11px; border: 1px solid var(--rule);
|
||||
background: #fbf7ee; border-radius: 4px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.input:focus { outline: none; border-color: var(--ink); }
|
||||
|
||||
/* Inline divider */
|
||||
.div-h { height: 1px; background: var(--rule); width: 100%; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,243 +0,0 @@
|
||||
# Attendance Tracking Tool — Design Spec
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Status:** Approved for implementation
|
||||
|
||||
## Context
|
||||
|
||||
FPTutor is used to manage a functional programming tutoring course (~19 students, Thursdays). Students earn 3 bonus points per attended Projektstunde. Currently attendance is tracked on paper sheets; this tool replaces and extends that.
|
||||
|
||||
The tool is designed to be reusable across future semesters and other tutorien.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Stack
|
||||
|
||||
- **Backend:** Rust + Axum, `sqlx` (SQLite), JWT auth for tutors
|
||||
- **Frontend:** SvelteKit with `adapter-static` (SPA, served by Axum)
|
||||
- **Database:** SQLite via a Kubernetes PersistentVolumeClaim
|
||||
- **Deployment:** Single container on a k8s namespace, `tutor.puchstein.dev`
|
||||
|
||||
Single binary + static files, one container, one PVC. No Node server at runtime — minimizes node load. SQLite keeps the footprint small and makes end-of-semester teardown trivial.
|
||||
|
||||
Axum must serve `index.html` as fallback for all non-`/api` routes so that SvelteKit client-side routing works on direct navigation or page refresh.
|
||||
|
||||
### Repository layout
|
||||
|
||||
```
|
||||
tools/attendance/
|
||||
├── backend/ # Rust/Axum
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs
|
||||
│ │ ├── db.rs # sqlx pool setup, migrations
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── admin.rs # tutor-facing endpoints
|
||||
│ │ │ ├── checkin.rs # student-facing endpoints
|
||||
│ │ │ └── export.rs # CSV, Markdown, SQLite backup
|
||||
│ │ └── auth.rs # JWT middleware
|
||||
│ └── Cargo.toml
|
||||
├── frontend/ # SvelteKit
|
||||
│ ├── src/
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── admin/ # tutor panel
|
||||
│ │ │ └── s/[code]/ # student check-in
|
||||
│ │ └── lib/
|
||||
│ └── svelte.config.js # adapter-static
|
||||
└── k8s/
|
||||
├── deployment.yaml
|
||||
├── service.yaml
|
||||
├── ingress.yaml
|
||||
├── pvc.yaml
|
||||
└── cronjob.yaml # daily SQLite backup, retains last 7
|
||||
```
|
||||
|
||||
Visual/frontend design is handled separately via Claude Design — this spec covers structure and flows only.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
```sql
|
||||
-- Reusable across semesters and courses
|
||||
CREATE TABLE courses (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
semester TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Tutors are global accounts; course assignment via tutor_courses join table
|
||||
CREATE TABLE tutors (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Many-to-many: one tutor can manage multiple courses across semesters
|
||||
CREATE TABLE tutor_courses (
|
||||
tutor_id INTEGER REFERENCES tutors(id),
|
||||
course_id INTEGER REFERENCES courses(id),
|
||||
PRIMARY KEY (tutor_id, course_id)
|
||||
);
|
||||
|
||||
CREATE TABLE students (
|
||||
id INTEGER PRIMARY KEY,
|
||||
course_id INTEGER NOT NULL REFERENCES courses(id),
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Room layout stored as JSON for flexibility
|
||||
-- layout_json: [{id, label, x, y, width, height, type}]
|
||||
-- type: "seat" | "table" | "gap" | "door"
|
||||
CREATE TABLE rooms (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
layout_json TEXT NOT NULL -- JSON array of layout elements
|
||||
);
|
||||
|
||||
-- A week unit covering all parallel slots that week
|
||||
-- UNIQUE ensures one session per course per week
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
course_id INTEGER NOT NULL REFERENCES courses(id),
|
||||
week_nr INTEGER NOT NULL,
|
||||
date TEXT NOT NULL CHECK (date GLOB '????-??-??'), -- ISO 8601 YYYY-MM-DD
|
||||
UNIQUE(course_id, week_nr)
|
||||
);
|
||||
|
||||
-- One parallel slot per session (e.g. 14-15h room A, 17-18h room B)
|
||||
-- room_id is nullable: manual/paper-only slots have no layout
|
||||
-- status lives here so individual slots can be opened/closed independently
|
||||
CREATE TABLE slots (
|
||||
id INTEGER PRIMARY KEY,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id),
|
||||
room_id INTEGER REFERENCES rooms(id), -- NULL = no layout (manual entry only)
|
||||
tutor_id INTEGER NOT NULL REFERENCES tutors(id),
|
||||
start_time TEXT NOT NULL, -- HH:MM
|
||||
end_time TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'closed', -- closed | open | locked
|
||||
code TEXT UNIQUE -- short alphanumeric, set when status → open
|
||||
);
|
||||
|
||||
-- Attendance record
|
||||
-- seat_id NULL = no seat assigned (manual entry or slot without layout)
|
||||
-- UNIQUE prevents double check-in at the DB level
|
||||
CREATE TABLE attendances (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slot_id INTEGER NOT NULL REFERENCES slots(id),
|
||||
student_id INTEGER NOT NULL REFERENCES students(id),
|
||||
seat_id TEXT, -- layout element id, NULL if manual or no layout
|
||||
checked_in_at TEXT NOT NULL CHECK (checked_in_at GLOB '????-??-??T??:??:??*'), -- ISO 8601 datetime
|
||||
UNIQUE(slot_id, student_id),
|
||||
UNIQUE(slot_id, seat_id) -- FCFS seat lock (SQLite NULLs are not deduplicated,
|
||||
-- so multiple NULL seat_ids are allowed — intentional for manual entries)
|
||||
);
|
||||
|
||||
-- SQLite does not enforce foreign keys by default.
|
||||
-- The backend must issue `PRAGMA foreign_keys = ON` on every connection from the sqlx pool.
|
||||
|
||||
-- Tutor notes per student per slot (visible only to tutors, not students)
|
||||
CREATE TABLE notes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slot_id INTEGER NOT NULL REFERENCES slots(id),
|
||||
student_id INTEGER NOT NULL REFERENCES students(id),
|
||||
tutor_id INTEGER NOT NULL REFERENCES tutors(id),
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL CHECK (updated_at GLOB '????-??-??T??:??:??*'),
|
||||
UNIQUE(slot_id, student_id, tutor_id)
|
||||
);
|
||||
```
|
||||
|
||||
**Key decisions:**
|
||||
- `status` is on `slots`, not `sessions` — tutors can open the 14h slot before the 17h slot is ready.
|
||||
- `manual` boolean removed — `seat_id IS NULL` is the canonical signal for a non-digital entry.
|
||||
- `tutors.course_id` replaced by `tutor_courses` join table for cross-semester reuse.
|
||||
- `UNIQUE(slot_id, student_id)` prevents double check-in; backend uses INSERT + explicit conflict handling.
|
||||
- `UNIQUE(slot_id, seat_id)` enforces FCFS at the DB level; concurrent requests get a constraint error, backend returns HTTP 409 + "seat taken, please choose another" to the client.
|
||||
- `slots.code` and `slots.status` must be set atomically in a single `UPDATE slots SET status = 'open', code = ? WHERE id = ?`. The backend refuses to serve a check-in page for any slot where `status = 'open'` but `code IS NULL`.
|
||||
- For layout-bearing slots (`room_id IS NOT NULL`), the backend rejects `seat_id = NULL` submissions at the application layer — the DB NULL-deduplication behaviour cannot enforce this.
|
||||
- **Seat change:** A student may change their seat while the slot is `open`. The backend deletes the existing attendance row for `(slot_id, student_id)` then inserts the new one atomically in a transaction. The previously held seat becomes free immediately. Once the slot is `locked`, no changes are possible.
|
||||
- **Cookie trust:** Cookies are unsigned in the initial implementation — accepted risk for a small in-person group where the tutor physically observes the room. Tutor must cross-check the seat map against visible students before locking. The `checkin.rs` auth layer is designed for a drop-in HMAC replacement without further changes.
|
||||
|
||||
Rooms are created independently of sessions and can be reused across semesters. The student dropdown on the check-in page is filtered by the slot's course, preventing cross-course name leakage.
|
||||
|
||||
---
|
||||
|
||||
## Check-in Code
|
||||
|
||||
- Generated when a tutor sets a slot to `open`.
|
||||
- 8-character alphanumeric (upper-case, no ambiguous chars like 0/O/1/I): ~2.8 trillion combinations — collision risk negligible for this scale.
|
||||
- Unique globally (enforced by `slots.code UNIQUE`).
|
||||
- Code is valid as long as `slots.status = 'open'`. Once locked or closed, the URL returns the seat map in read-only mode so returning students can still see their own seat highlighted.
|
||||
|
||||
---
|
||||
|
||||
## UI Flows
|
||||
|
||||
### Tutor Admin Panel (`/admin`)
|
||||
|
||||
Requires JWT login.
|
||||
|
||||
| Section | Actions |
|
||||
|---|---|
|
||||
| Dashboard | List of all slots with status badge; per-slot open/close/lock toggle; copy check-in link |
|
||||
| Courses & Students | Create course, add/import students (manual or CSV upload) |
|
||||
| Rooms | Create room, open layout editor (drag-and-drop seats onto canvas); rooms are course-independent |
|
||||
| Sessions | Create weekly session (course + week_nr + date), add slots (room, tutor, time) |
|
||||
| Attendance | Per-week table, per-student table, manual entry for any slot, export |
|
||||
| Notes | Live seat map per slot (tutor-only, names visible); click student → inline note editor (upsert); notes appear in per-student and per-week views |
|
||||
| Backups | Download current SQLite file; automatic daily backup via a Kubernetes CronJob (`k8s/cronjob.yaml`) that copies the DB to `backup-YYYY-MM-DD.sqlite` on the PVC and prunes files older than 7 days |
|
||||
|
||||
### Student Check-in (`/s/{code}`)
|
||||
|
||||
No login required.
|
||||
|
||||
1. Tutor opens a slot → `code` generated → tutor projects `tutor.puchstein.dev/s/{code}` on the beamer.
|
||||
2. Student opens URL on phone/laptop.
|
||||
3. If no cookie for this code: student selects their name from a dropdown (names filtered to the slot's course only).
|
||||
4. Cookie `attendance_identity` set: `{ code, student_id }`, `httpOnly`, `SameSite=Strict`, 24h expiry. Scoping to `code` means each week starts fresh without requiring cookie clearing.
|
||||
5. Room layout renders: free seats selectable; occupied seats shown as grey — no name visible (privacy). Student's own seat (if already checked in) highlighted.
|
||||
6. Student clicks a seat → `POST /api/checkin` → backend attempts insert:
|
||||
- Success → seat locked, confirmation shown.
|
||||
- HTTP 409 (seat taken) → UI prompts student to pick another seat.
|
||||
7. If slot is `locked` or `closed`, the page is read-only: own seat highlighted if checked in, otherwise "check-in not available" message.
|
||||
|
||||
### Backward Compatibility (Week 1 Paper List)
|
||||
|
||||
Tutor creates the week-1 session and its slots with `room_id = NULL` (no layout). Attendance is entered manually per student in the admin panel. `seat_id = NULL` for all entries.
|
||||
|
||||
---
|
||||
|
||||
## Export
|
||||
|
||||
All exports are scoped to a course.
|
||||
|
||||
| Export | Format | Scope |
|
||||
|---|---|---|
|
||||
| Weekly attendance | CSV, Markdown | One session — all slots merged, student list with present/absent |
|
||||
| Full course matrix | CSV, Markdown | All sessions × all students; `Bonus` column = `3` if unexcused absences ≤ 1, else `0` |
|
||||
| SQLite backup | `.sqlite` file download | Full database via HTTP stream |
|
||||
|
||||
Markdown exports are structured for `pandoc` conversion to PDF.
|
||||
|
||||
**Bonus rule:** A student earns 3 bonus points if their unexcused absences across all sessions ≤ 1. An "unexcused absence" is any session where the student has no attendance record for any of its slots. Students with 2+ unexcused absences receive 0 bonus points.
|
||||
|
||||
---
|
||||
|
||||
## Privacy & Extensibility
|
||||
|
||||
- Student names are never shown on occupied seats — only the seat owner sees their own name (via cookie).
|
||||
- Cookie contains only `student_id` (opaque integer); no PII in the cookie value.
|
||||
- The auth layer (cookie → student_id) is isolated in `checkin.rs` so a future HMAC-signed token, short PIN, or session-scoped QR code can replace the trust-based flow without touching the rest of the system.
|
||||
- The student dropdown only shows students belonging to the slot's course — no cross-course name leakage.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Visual/frontend design (handled via Claude Design)
|
||||
- Real-time seat map sync (polling on page load is sufficient for ~20 students)
|
||||
- Email notifications
|
||||
- Grade calculation (grader tool handles assignment feedback separately)
|
||||
@@ -1 +0,0 @@
|
||||
$(cat /home/mpuchstein/.gemini/tmp/tutortool/tool-outputs/session-beccefa4-06f6-4b19-b134-41e04a948063/web_fetch_1777347296690_0.txt)
|
||||
Reference in New Issue
Block a user