fix: superadmin access for sessions/rooms; redesign room editor

Backend:
- sessions.rs: add is_superadmin bypass to list_sessions,
  create_session, create_slot, update_slot_status, delete_slot
- rooms.rs: allow empty layout on create_room (layout is built
  in editor after creation)

Frontend room editor:
- Fix drag coordinate sync: replace window mousemove delta calc
  with SVG CTM inverse transform (getScreenCTM), eliminating
  the 3x movement ratio bug
- Switch to pointer capture (setPointerCapture) per element;
  remove svelte:window handlers
- Positions always snap to 0.5 grid on drop, preventing
  backend validation errors
- Left floating sidebar toolbar (collapsible) with Sitz/Tisch/
  Tür/Lücke buttons and inline SVG icons
- Room dimensions bar (Breite × Tiefe, 5–50 grid units)
- Zoom controls (25%–400%) via scroll wheel or +/- buttons;
  SVG scales via width/height attrs so scrollbars work correctly
- Property panel inputs snap to 0.5 on blur (prevents save errors)
- Canvas bounded to room dimensions during drag
This commit is contained in:
2026-05-06 16:09:08 +02:00
parent c8a4bc1820
commit fd6beb4591
4 changed files with 717 additions and 373 deletions

View File

@@ -104,7 +104,9 @@ async fn create_room(
State(pool): State<SqlitePool>,
Json(req): Json<CreateRoom>,
) -> Result<(StatusCode, Json<Value>), AppError> {
validate_layout(&req.layout)?;
if !req.layout.is_empty() {
validate_layout(&req.layout)?;
}
let layout_json = serde_json::to_string(&req.layout)
.map_err(|e| AppError::BadRequest(format!("layout serialization error: {e}")))?;
let id = sqlx::query("INSERT INTO rooms (name, layout_json) VALUES (?, ?)")

View File

@@ -33,7 +33,9 @@ async fn list_sessions(
State(pool): State<SqlitePool>,
Query(q): Query<SessionQuery>,
) -> Result<Json<Value>, AppError> {
super::verify_tutor_course_access(&pool, claims.sub, q.course_id).await?;
if !claims.is_superadmin {
super::verify_tutor_course_access(&pool, claims.sub, q.course_id).await?;
}
let sessions = sqlx::query_as::<_, Session>(
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY date, week_nr",
@@ -74,7 +76,9 @@ async fn create_session(
chrono::NaiveDate::parse_from_str(&req.date, "%Y-%m-%d")
.map_err(|_| AppError::BadRequest("date must be YYYY-MM-DD".into()))?;
super::verify_tutor_course_access(&pool, claims.sub, req.course_id).await?;
if !claims.is_superadmin {
super::verify_tutor_course_access(&pool, claims.sub, req.course_id).await?;
}
let id = sqlx::query("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, ?, ?)")
.bind(req.course_id)
@@ -105,23 +109,25 @@ async fn create_slot(
.await?;
let (course_id,) = session_row.ok_or(AppError::NotFound)?;
// Verify requesting tutor has access to the course
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
if !claims.is_superadmin {
// Verify requesting tutor has access to the course
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
// Verify the slot's tutor_id belongs to this course AND is active
let member: Option<(i64,)> = sqlx::query_as(
"SELECT 1 FROM tutor_courses tc
JOIN tutors t ON t.id = tc.tutor_id
WHERE tc.tutor_id = ? AND tc.course_id = ? AND t.is_active = 1",
)
.bind(req.tutor_id)
.bind(course_id)
.fetch_optional(&pool)
.await?;
if member.is_none() {
return Err(AppError::BadRequest(
"Tutor:in ist in diesem Kurs nicht aktiv oder existiert nicht.".into(),
));
// Verify the slot's tutor_id belongs to this course AND is active
let member: Option<(i64,)> = sqlx::query_as(
"SELECT 1 FROM tutor_courses tc
JOIN tutors t ON t.id = tc.tutor_id
WHERE tc.tutor_id = ? AND tc.course_id = ? AND t.is_active = 1",
)
.bind(req.tutor_id)
.bind(course_id)
.fetch_optional(&pool)
.await?;
if member.is_none() {
return Err(AppError::BadRequest(
"Tutor:in ist in diesem Kurs nicht aktiv oder existiert nicht.".into(),
));
}
}
// Optionally verify room exists
@@ -183,7 +189,9 @@ async fn update_slot_status(
.await?;
let (course_id, existing_code) = row.ok_or(AppError::NotFound)?;
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
if !claims.is_superadmin {
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
}
// If transitioning to open and no code yet, generate one (retry on UNIQUE collision)
let code: Option<String> = if req.status == "open" {
@@ -262,7 +270,9 @@ async fn delete_slot(
));
}
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
if !claims.is_superadmin {
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
}
sqlx::query("DELETE FROM slots WHERE id = ?")
.bind(slot_id)

View File

@@ -11,253 +11,255 @@
selectedId?: string | null;
occupiedSeatIds?: string[];
mySeatId?: string | null;
studentNames?: Record<string, string>; // seat_id -> name
studentNames?: Record<string, string>;
roomWidth?: number;
roomHeight?: number;
zoom?: number;
}
let {
elements = $bindable([]),
editable = false,
let {
elements = $bindable([]),
editable = false,
clickable = false,
snapStep = 0.5,
onElementClick,
onElementClick,
onLayoutChange,
selectedId = null,
occupiedSeatIds = [],
mySeatId = null,
studentNames = {}
studentNames = {},
roomWidth = 20,
roomHeight = 15,
zoom = 1.0,
}: Props = $props();
const GRID_SIZE = 40;
let svgEl: SVGSVGElement;
function toGridCoords(e: PointerEvent): { x: number; y: number } {
const ctm = svgEl.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const pt = svgEl.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgPt = pt.matrixTransform(ctm.inverse());
return { x: svgPt.x / GRID_SIZE, y: svgPt.y / GRID_SIZE };
}
function snapVal(v: number): number {
if (snapStep <= 0) return v;
return Math.round(v / snapStep) * snapStep;
}
let draggingId = $state<string | null>(null);
let resizingId = $state<string | null>(null);
let resizeDir = $state<'e' | 's' | 'se' | null>(null);
let startX = 0;
let startY = 0;
let initialX = 0;
let initialY = 0;
let initialW = 0;
let initialH = 0;
let startGrid = { x: 0, y: 0 };
let initEl = { x: 0, y: 0, w: 0, h: 0 };
let dragMoved = false;
const GRID_SIZE = 40;
function handleMouseDown(e: MouseEvent, el: LayoutElement) {
if (clickable && !editable) {
onElementClick?.(el);
return;
}
function onElemPointerDown(e: PointerEvent, el: LayoutElement) {
if (clickable && !editable) { onElementClick?.(el); return; }
if (!editable) return;
e.stopPropagation();
(e.currentTarget as Element).setPointerCapture(e.pointerId);
draggingId = el.id;
dragMoved = false;
startX = e.clientX;
startY = e.clientY;
initialX = el.x;
initialY = el.y;
e.stopPropagation();
startGrid = toGridCoords(e);
initEl = { x: el.x, y: el.y, w: el.width, h: el.height };
}
function handleResizeDown(e: MouseEvent, el: LayoutElement, dir: 'e' | 's' | 'se') {
if (!editable) return;
resizingId = el.id;
resizeDir = dir;
startX = e.clientX;
startY = e.clientY;
initialW = el.width;
initialH = el.height;
e.stopPropagation();
function onElemPointerMove(e: PointerEvent, el: LayoutElement) {
if (!editable || draggingId !== el.id) return;
const cur = toGridCoords(e);
const dx = cur.x - startGrid.x;
const dy = cur.y - startGrid.y;
if (Math.abs(dx) > 0.05 || Math.abs(dy) > 0.05) dragMoved = true;
const i = elements.findIndex(x => x.id === el.id);
if (i === -1) return;
const el2 = elements[i]!;
const newX = Math.max(0, Math.min(roomWidth - el2.width, snapVal(initEl.x + dx)));
const newY = Math.max(0, Math.min(roomHeight - el2.height, snapVal(initEl.y + dy)));
elements[i] = { ...el2, x: newX, y: newY };
}
function handleWindowMouseMove(e: MouseEvent) {
if (!editable) return;
if (draggingId) {
dragMoved = true;
const index = elements.findIndex(el => el.id === draggingId);
if (index !== -1) {
const dx = (e.clientX - startX) / GRID_SIZE;
const dy = (e.clientY - startY) / GRID_SIZE;
let newX = initialX + dx;
let newY = initialY + dy;
if (snapStep > 0) {
newX = Math.round(newX / snapStep) * snapStep;
newY = Math.round(newY / snapStep) * snapStep;
}
const currentEl = elements[index];
if (currentEl) {
elements[index] = { ...currentEl, x: Math.max(0, newX), y: Math.max(0, newY) };
}
}
} else if (resizingId && resizeDir) {
const index = elements.findIndex(el => el.id === resizingId);
if (index !== -1) {
const dx = (e.clientX - startX) / GRID_SIZE;
const dy = (e.clientY - startY) / GRID_SIZE;
let newW = initialW;
let newH = initialH;
if (resizeDir.includes('e')) newW = initialW + dx;
if (resizeDir.includes('s')) newH = initialH + dy;
if (snapStep > 0) {
newW = Math.round(newW / snapStep) * snapStep;
newH = Math.round(newH / snapStep) * snapStep;
}
const currentEl = elements[index];
if (currentEl) {
elements[index] = {
...currentEl,
width: Math.max(snapStep || 0.1, newW),
height: Math.max(snapStep || 0.1, newH)
};
}
}
}
}
function handleWindowMouseUp() {
if (editable) {
if (draggingId) {
if (dragMoved) {
onLayoutChange?.(elements);
} else {
const el = elements.find(e => e.id === draggingId);
if (el) onElementClick?.(el);
}
} else if (resizingId) {
onLayoutChange?.(elements);
}
function onElemPointerUp(e: PointerEvent, el: LayoutElement) {
if (!editable || draggingId !== el.id) return;
if (dragMoved) {
onLayoutChange?.(elements);
} else {
onElementClick?.(el);
}
draggingId = null;
resizingId = null;
resizeDir = null;
dragMoved = false;
}
// Compute viewBox based on elements or default
let maxX = $derived(elements.reduce((max, el) => Math.max(max, el.x + el.width), 20));
let maxY = $derived(elements.reduce((max, el) => Math.max(max, el.y + el.height), 15));
let viewBox = $derived(`0 0 ${Math.ceil(maxX * GRID_SIZE)} ${Math.ceil(maxY * GRID_SIZE)}`);
function onResizeDown(e: PointerEvent, el: LayoutElement, dir: 'e' | 's' | 'se') {
if (!editable) return;
e.stopPropagation();
(e.currentTarget as Element).setPointerCapture(e.pointerId);
resizingId = el.id;
resizeDir = dir;
startGrid = toGridCoords(e);
initEl = { x: el.x, y: el.y, w: el.width, h: el.height };
}
function onResizeMove(e: PointerEvent, el: LayoutElement) {
if (!editable || resizingId !== el.id || !resizeDir) return;
const cur = toGridCoords(e);
const dx = cur.x - startGrid.x;
const dy = cur.y - startGrid.y;
const minSz = snapStep > 0 ? snapStep : 0.5;
const i = elements.findIndex(x => x.id === el.id);
if (i === -1) return;
const el2 = elements[i]!;
let newW = el2.width;
let newH = el2.height;
if (resizeDir.includes('e')) newW = snapVal(Math.max(minSz, initEl.w + dx));
if (resizeDir.includes('s')) newH = snapVal(Math.max(minSz, initEl.h + dy));
elements[i] = { ...el2, width: newW, height: newH };
}
function onResizeUp() {
if (resizingId) onLayoutChange?.(elements);
resizingId = null;
resizeDir = null;
}
const viewBox = $derived(`0 0 ${roomWidth * GRID_SIZE} ${roomHeight * GRID_SIZE}`);
const svgWidth = $derived(roomWidth * GRID_SIZE * zoom);
const svgHeight = $derived(roomHeight * GRID_SIZE * zoom);
</script>
<svelte:window
onmousemove={handleWindowMouseMove}
onmouseup={handleWindowMouseUp}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<svg
<svg
bind:this={svgEl}
{viewBox}
preserveAspectRatio="xMidYMid meet"
class="room-canvas"
width={svgWidth}
height={svgHeight}
class="room-canvas"
class:editable
class:clickable={clickable || editable}
onclick={(e) => {
// Deselect if clicking on empty canvas
if (editable && e.target === e.currentTarget) {
onElementClick?.(null);
}
onpointerdown={(e) => {
if (editable && e.target === e.currentTarget) onElementClick?.(null);
}}
>
<!-- Room boundary -->
<rect x="0" y="0" width={roomWidth * GRID_SIZE} height={roomHeight * GRID_SIZE} class="room-bg" />
<!-- Grid -->
{#if editable && snapStep > 0}
<defs>
<pattern id="grid" width={GRID_SIZE * snapStep} height={GRID_SIZE * snapStep} patternUnits="userSpaceOnUse">
<path d="M {GRID_SIZE * snapStep} 0 L 0 0 0 {GRID_SIZE * snapStep}" fill="none" stroke="var(--rule-soft)" stroke-width="1" stroke-dasharray="2,2"/>
<pattern id="editgrid" width={GRID_SIZE * snapStep} height={GRID_SIZE * snapStep} patternUnits="userSpaceOnUse">
<path
d="M {GRID_SIZE * snapStep} 0 L 0 0 0 {GRID_SIZE * snapStep}"
fill="none"
stroke="rgba(0,0,0,0.05)"
stroke-width="0.5"
stroke-dasharray="2,3"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
<rect width={roomWidth * GRID_SIZE} height={roomHeight * GRID_SIZE} fill="url(#editgrid)" />
{/if}
{#each elements as el (el.id)}
<g
<!-- svelte-ignore a11y_no_static_element_interactions -->
<g
transform="translate({el.x * GRID_SIZE}, {el.y * GRID_SIZE})"
class="element {el.type}"
class:selected={selectedId === el.id}
class:dragging={draggingId === el.id}
class:occupied={el.type === 'seat' && occupiedSeatIds.includes(el.id)}
class:is-mine={el.id === mySeatId}
onmousedown={(e) => handleMouseDown(e, el)}
onpointerdown={(e) => onElemPointerDown(e, el)}
onpointermove={(e) => onElemPointerMove(e, el)}
onpointerup={(e) => onElemPointerUp(e, el)}
>
{#if el.type === 'seat'}
<rect
width={el.width * GRID_SIZE}
height={el.height * GRID_SIZE}
rx="8"
/>
<text
x={(el.width * GRID_SIZE) / 2}
y={(el.height * GRID_SIZE) / 2}
text-anchor="middle"
<rect width={el.width * GRID_SIZE} height={el.height * GRID_SIZE} rx="8" />
<text
x={el.width * GRID_SIZE / 2}
y={el.height * GRID_SIZE / 2}
text-anchor="middle"
dominant-baseline="middle"
>
{el.label}
</text>
>{el.label}</text>
{#if studentNames[el.id]}
<text
x={(el.width * GRID_SIZE) / 2}
y={(el.height * GRID_SIZE) + 14}
text-anchor="middle"
<text
x={el.width * GRID_SIZE / 2}
y={el.height * GRID_SIZE + 14}
text-anchor="middle"
class="student-name"
>
{studentNames[el.id]}
</text>
>{studentNames[el.id]}</text>
{/if}
{:else if el.type === 'table'}
<rect
width={el.width * GRID_SIZE}
height={el.height * GRID_SIZE}
rx="16"
/>
<rect width={el.width * GRID_SIZE} height={el.height * GRID_SIZE} rx="12" />
{#if editable}
<text
x={el.width * GRID_SIZE / 2}
y={el.height * GRID_SIZE / 2}
text-anchor="middle"
dominant-baseline="middle"
class="type-label"
>TISCH</text>
{/if}
{:else if el.type === 'gap'}
<!-- Invisible interaction target -->
<rect
width={el.width * GRID_SIZE}
height={el.height * GRID_SIZE}
fill="transparent"
stroke={editable ? "var(--rule)" : "none"}
stroke-dasharray="4,4"
<rect
width={el.width * GRID_SIZE}
height={el.height * GRID_SIZE}
class="gap-rect"
stroke-dasharray="4,3"
/>
{#if editable}
<text
x={(el.width * GRID_SIZE) / 2}
y={(el.height * GRID_SIZE) / 2}
text-anchor="middle"
<text
x={el.width * GRID_SIZE / 2}
y={el.height * GRID_SIZE / 2}
text-anchor="middle"
dominant-baseline="middle"
style="font-size: 10px; fill: var(--ink-4);"
>
GAP
</text>
class="gap-label"
>LÜCKE</text>
{/if}
{:else if el.type === 'door'}
<rect
width={el.width * GRID_SIZE}
height={el.height * GRID_SIZE}
fill="transparent"
stroke="var(--rule)"
stroke-width="2"
/>
<text
x={(el.width * GRID_SIZE) / 2}
y={(el.height * GRID_SIZE) / 2}
text-anchor="middle"
<rect width={el.width * GRID_SIZE} height={el.height * GRID_SIZE} class="door-rect" />
<text
x={el.width * GRID_SIZE / 2}
y={el.height * GRID_SIZE / 2}
text-anchor="middle"
dominant-baseline="middle"
style="font-size: 10px; fill: var(--ink-3); font-family: var(--mono); letter-spacing: 0.1em;"
>
DOOR
</text>
class="door-label"
>TÜR</text>
{/if}
{#if editable && selectedId === el.id}
<!-- Resize handles -->
<rect class="resize-handle e" x={el.width * GRID_SIZE - 4} y={(el.height * GRID_SIZE) / 2 - 4} width="8" height="8" onmousedown={(e) => handleResizeDown(e, el, 'e')} />
<rect class="resize-handle s" x={(el.width * GRID_SIZE) / 2 - 4} y={el.height * GRID_SIZE - 4} width="8" height="8" onmousedown={(e) => handleResizeDown(e, el, 's')} />
<rect class="resize-handle se" x={el.width * GRID_SIZE - 4} y={el.height * GRID_SIZE - 4} width="8" height="8" onmousedown={(e) => handleResizeDown(e, el, 'se')} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<rect
class="resize-handle e"
x={el.width * GRID_SIZE - 5} y={el.height * GRID_SIZE / 2 - 5}
width="10" height="10" rx="2"
onpointerdown={(e) => onResizeDown(e, el, 'e')}
onpointermove={(e) => onResizeMove(e, el)}
onpointerup={onResizeUp}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<rect
class="resize-handle s"
x={el.width * GRID_SIZE / 2 - 5} y={el.height * GRID_SIZE - 5}
width="10" height="10" rx="2"
onpointerdown={(e) => onResizeDown(e, el, 's')}
onpointermove={(e) => onResizeMove(e, el)}
onpointerup={onResizeUp}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<rect
class="resize-handle se"
x={el.width * GRID_SIZE - 5} y={el.height * GRID_SIZE - 5}
width="10" height="10" rx="2"
onpointerdown={(e) => onResizeDown(e, el, 'se')}
onpointermove={(e) => onResizeMove(e, el)}
onpointerup={onResizeUp}
/>
{/if}
</g>
{/each}
@@ -265,68 +267,96 @@
<style>
.room-canvas {
background: transparent;
width: 100%;
height: auto;
user-select: none;
touch-action: none;
display: block;
}
.room-canvas.clickable .element {
cursor: pointer;
}
.element rect {
transition: fill 0.2s, stroke 0.2s;
}
.room-canvas.editable { cursor: default; }
.room-canvas.clickable .element { cursor: pointer; }
.room-canvas.editable .element { cursor: grab; }
.room-canvas.editable .element.dragging { cursor: grabbing; }
.element.seat rect {
fill: #fbf7ee;
stroke: var(--ink-2);
stroke-width: 1.5;
}
.element.seat text {
fill: var(--ink);
font-weight: 500;
font-family: var(--mono);
font-size: 12px;
}
.element.seat.occupied rect {
fill: #d6cdb5;
stroke: var(--ink-2);
}
.element.seat.occupied text {
fill: var(--ink-2);
}
.element.seat.is-mine rect {
fill: var(--accent);
stroke: var(--accent);
}
.element.seat.is-mine text {
.room-bg {
fill: white;
}
.element.selected rect {
stroke: var(--highlight);
stroke-width: 2.5;
}
.element.table rect {
fill: rgba(0,0,0,0.03);
stroke: var(--rule);
stroke: var(--rule, #e8e4dc);
stroke-width: 1;
}
.student-name {
font-size: 11px;
fill: var(--ink-3);
font-family: var(--sans);
/* Seats */
.element.seat rect {
fill: #faf7f0;
stroke: var(--ink-2, #888);
stroke-width: 1.5;
transition: filter 0.1s;
}
.element.seat text {
fill: var(--ink, #1a1814);
font-weight: 600;
font-family: var(--mono, monospace);
font-size: 13px;
}
.element.seat.occupied rect { fill: #ddd9cc; }
.element.seat.occupied text { fill: var(--ink-3, #aaa); }
.element.seat.is-mine rect { fill: var(--accent, #8a2c1f); stroke: var(--accent, #8a2c1f); }
.element.seat.is-mine text { fill: white; }
/* Tables */
.element.table rect {
fill: rgba(0,0,0,0.03);
stroke: var(--ink-3, #bbb);
stroke-width: 1;
}
.type-label {
font-size: 9px;
fill: var(--ink-4, #ccc);
font-family: var(--mono, monospace);
letter-spacing: 0.1em;
}
/* Gaps */
.gap-rect {
fill: transparent;
stroke: var(--rule, #ddd);
stroke-width: 1;
}
.gap-label {
font-size: 9px;
fill: var(--ink-4, #ccc);
font-family: var(--mono, monospace);
letter-spacing: 0.08em;
}
/* Doors */
.door-rect {
fill: rgba(0,0,0,0.015);
stroke: var(--ink-3, #aaa);
stroke-width: 2;
}
.door-label {
font-size: 9px;
fill: var(--ink-3, #aaa);
font-family: var(--mono, monospace);
letter-spacing: 0.1em;
}
/* Selection */
.element.selected rect:first-of-type {
stroke: var(--accent, #8a2c1f);
stroke-width: 2.5;
filter: drop-shadow(0 0 5px rgba(138,44,31,0.2));
}
/* Student name below seat */
.student-name {
font-size: 10px;
fill: var(--ink-3, #aaa);
font-family: var(--sans, sans-serif);
}
/* Resize handles */
.resize-handle {
fill: white;
stroke: var(--accent);
stroke: var(--accent, #8a2c1f);
stroke-width: 1.5;
}
.resize-handle.e { cursor: ew-resize; }

View File

@@ -1,24 +1,39 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import { api } from '$lib/api';
import RoomCanvas from '$lib/RoomCanvas.svelte';
import type { Room, LayoutElement } from '$lib/types';
const roomIdStr = ($page.params as Record<string, string>).roomId;
const roomId = $derived(roomIdStr ? parseInt(roomIdStr) : 0);
const roomId = $derived(page.params.roomId ? parseInt(page.params.roomId) : 0);
let room = $state<Room | null>(null);
let errorMsg = $state<string | null>(null);
let room = $state<Room | null>(null);
let errorMsg = $state<string | null>(null);
let saveOk = $state(false);
let snapToGrid = $state(true);
let sidebarOpen = $state(true);
let zoom = $state(1.0);
let roomWidth = $state(20);
let roomHeight = $state(15);
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 4.0;
const GRID = 40;
$effect(() => {
if (roomId) {
api.admin.rooms.get(roomId)
.then((r: Room) => { room = r; })
.catch((e: unknown) => {
errorMsg = e instanceof Error ? e.message : 'Raum konnte nicht geladen werden.';
});
}
if (!roomId) return;
api.admin.rooms.get(roomId)
.then((r: Room) => {
room = r;
if (r.layout.length > 0) {
const maxX = Math.max(...r.layout.map(e => e.x + e.width));
const maxY = Math.max(...r.layout.map(e => e.y + e.height));
roomWidth = Math.max(20, Math.ceil(maxX) + 2);
roomHeight = Math.max(15, Math.ceil(maxY) + 2);
}
})
.catch((e: unknown) => {
errorMsg = e instanceof Error ? e.message : 'Raum konnte nicht geladen werden.';
});
});
async function saveLayout() {
@@ -26,158 +41,445 @@
errorMsg = null;
try {
await api.admin.rooms.updateLayout(room.id, room.layout);
saveOk = true;
setTimeout(() => { saveOk = false; }, 2500);
} catch (e) {
errorMsg = e instanceof Error ? e.message : 'failed to save layout';
errorMsg = e instanceof Error ? e.message : 'Speichern fehlgeschlagen.';
}
}
function addElement(type: LayoutElement['type']) {
if (!room) return;
const id = Math.random().toString(36).slice(2, 11);
let nextLabel: string;
let label = '';
if (type === 'seat') {
const existingLabels = room.layout
.filter((e: LayoutElement) => e.type === 'seat')
.map((e: LayoutElement) => parseInt(e.label, 10))
const nums = room.layout
.filter(e => e.type === 'seat')
.map(e => parseInt(e.label, 10))
.filter(n => !isNaN(n));
const maxLabel = existingLabels.length > 0 ? Math.max(...existingLabels) : 0;
nextLabel = (maxLabel + 1).toString();
} else {
nextLabel = '';
label = (nums.length ? Math.max(...nums) + 1 : 1).toString();
}
const newEl: LayoutElement = {
id,
label: nextLabel,
x: 0, y: 0,
room.layout = [...room.layout, {
id, label, x: 0, y: 0,
width: type === 'table' ? 2 : 1,
height: 1,
type,
};
room.layout = [...room.layout, newEl];
}];
selectedElementId = id;
}
function duplicateElement() {
if (!room || !selectedElement) return;
const id = Math.random().toString(36).slice(2, 11);
let nextLabel: string;
let label = selectedElement.label;
if (selectedElement.type === 'seat') {
const existingLabels = room.layout
.filter((e: LayoutElement) => e.type === 'seat')
.map((e: LayoutElement) => parseInt(e.label, 10))
.filter(n => !isNaN(n));
const maxLabel = existingLabels.length > 0 ? Math.max(...existingLabels) : 0;
nextLabel = (maxLabel + 1).toString();
} else {
nextLabel = selectedElement.label;
const nums = room.layout.filter(e => e.type === 'seat').map(e => parseInt(e.label, 10)).filter(n => !isNaN(n));
label = (nums.length ? Math.max(...nums) + 1 : 1).toString();
}
const duplicatedEl: LayoutElement = {
...selectedElement,
id,
label: nextLabel,
x: selectedElement.x + (snapToGrid ? 0.5 : 0.1),
y: selectedElement.y + (snapToGrid ? 0.5 : 0.1),
};
room.layout = [...room.layout, duplicatedEl];
room.layout = [...room.layout, {
...selectedElement, id, label,
x: Math.min(roomWidth - selectedElement.width, selectedElement.x + 0.5),
y: Math.min(roomHeight - selectedElement.height, selectedElement.y + 0.5),
}];
selectedElementId = id;
}
let selectedElementId = $state<string | null>(null);
const selectedElement = $derived(room?.layout.find((e: LayoutElement) => e.id === selectedElementId));
const selectedElement = $derived(room?.layout.find(e => e.id === selectedElementId));
function deleteElement() {
if (!room || !selectedElementId) return;
room.layout = room.layout.filter((e: LayoutElement) => e.id !== selectedElementId);
room.layout = room.layout.filter(e => e.id !== selectedElementId);
selectedElementId = null;
}
function snapProp(prop: 'x' | 'y' | 'width' | 'height') {
if (!room || !selectedElementId) return;
const i = room.layout.findIndex(e => e.id === selectedElementId);
if (i === -1) return;
const el = room.layout[i]!;
room.layout[i] = { ...el, [prop]: Math.round(el[prop] * 2) / 2 };
}
function zoomIn() { zoom = Math.min(MAX_ZOOM, Math.round((zoom + 0.25) * 100) / 100); }
function zoomOut() { zoom = Math.max(MIN_ZOOM, Math.round((zoom - 0.25) * 100) / 100); }
function zoomReset() { zoom = 1.0; }
// Wheel zoom — registered non-passively via action so preventDefault works
function wheelZoom(node: HTMLElement) {
function handler(e: WheelEvent) {
e.preventDefault();
const delta = e.deltaY < 0 ? 0.1 : -0.1;
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.round((zoom + delta) * 100) / 100));
}
node.addEventListener('wheel', handler, { passive: false });
return { destroy() { node.removeEventListener('wheel', handler); } };
}
</script>
{#if room}
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<span class="eyebrow">Räume</span>
<h2 class="h2" style="font-family:var(--serif)">{room.name}</h2>
</div>
<div style="display:flex;gap:8px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;margin-right:12px;font-size:13px;color:var(--ink-3)">
<input type="checkbox" bind:checked={snapToGrid} /> Raster fangen
</label>
<button class="btn ghost" onclick={() => addElement('seat')}>+ Sitz</button>
<button class="btn ghost" onclick={() => addElement('table')}>+ Tisch</button>
<button class="btn ghost" onclick={() => addElement('door')}>+ Tür</button>
<button class="btn ghost" onclick={() => addElement('gap')}>+ Gap</button>
<button class="btn" onclick={saveLayout}>Speichern</button>
</div>
<div class="editor-shell">
<!-- ── Header ── -->
<header class="editor-header">
<div class="header-left">
<a href="/admin/rooms" class="btn ghost sm">← Räume</a>
<span class="hd-sep">/</span>
<span class="hd-title">{room.name}</span>
</div>
{#if errorMsg}
<div style="background:rgba(138,44,31,0.06);border:1px solid var(--accent);color:var(--accent);padding:10px 14px;border-radius:4px;font-size:13px">
{errorMsg}
</div>
{/if}
<div class="header-right">
<label class="snap-label">
<input type="checkbox" bind:checked={snapToGrid} />
Raster fangen
</label>
{#if saveOk}
<span class="save-ok">✓ Gespeichert</span>
{/if}
<button class="btn" onclick={saveLayout}>Speichern</button>
</div>
</header>
<div style="display:flex;gap:20px">
<div style="flex:1">
<RoomCanvas
bind:elements={room.layout}
editable={true}
snapStep={snapToGrid ? 0.5 : 0}
selectedId={selectedElementId}
onElementClick={(el) => { selectedElementId = el ? el.id : null; }}
/>
</div>
<!-- ── Dims bar ── -->
<div class="dims-bar">
<span class="dims-eyebrow">Raumgröße</span>
<label class="dims-field">
Breite
<input class="input dims-input" type="number" min="5" max="50" step="1" bind:value={roomWidth} />
</label>
<span class="dims-x">×</span>
<label class="dims-field">
Tiefe
<input class="input dims-input" type="number" min="5" max="50" step="1" bind:value={roomHeight} />
</label>
<span class="dims-unit">Rasterfelder</span>
</div>
<div class="card" style="width:240px;padding:16px;display:flex;flex-direction:column;gap:12px">
<div class="eyebrow">Auswahl</div>
{#if selectedElement}
<div style="display:flex;flex-direction:column;gap:8px">
<div>
<div class="tiny" style="color:var(--ink-3)">Bezeichnung</div>
<input class="input" bind:value={selectedElement.label} />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<div class="tiny" style="color:var(--ink-3)">X-Pos</div>
<input class="input" type="number" step="0.5" bind:value={selectedElement.x} />
</div>
<div>
<div class="tiny" style="color:var(--ink-3)">Y-Pos</div>
<input class="input" type="number" step="0.5" bind:value={selectedElement.y} />
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<div class="tiny" style="color:var(--ink-3)">Breite</div>
<input class="input" type="number" step="0.5" bind:value={selectedElement.width} />
</div>
<div>
<div class="tiny" style="color:var(--ink-3)">Höhe</div>
<input class="input" type="number" step="0.5" bind:value={selectedElement.height} />
</div>
</div>
<div style="height:1px;background:var(--rule);margin:8px 0"></div>
<button
class="btn ghost sm" style="justify-content:center"
onclick={duplicateElement}
>Duplizieren</button>
<button
style="color:var(--accent);background:none;border:none;cursor:pointer;text-align:left;font-family:var(--sans);font-size:13px;padding:4px 0"
onclick={deleteElement}
>Löschen </button>
{#if errorMsg}
<div class="error-banner">{errorMsg}</div>
{/if}
<!-- ── Body ── -->
<div class="editor-body">
<!-- Canvas area -->
<div class="canvas-area" use:wheelZoom>
<!-- Floating toolbar sidebar -->
<div class="toolbar" class:open={sidebarOpen}>
<button
class="toolbar-toggle"
onclick={() => sidebarOpen = !sidebarOpen}
title={sidebarOpen ? 'Ausblenden' : 'Werkzeuge einblenden'}
>
{#if sidebarOpen}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M8 1L3 6l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M4 1l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{/if}
</button>
{#if sidebarOpen}
<div class="tool-list">
<button class="tool-btn" onclick={() => addElement('seat')}>
<svg class="tool-icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="2" y="2" width="14" height="14" rx="4" stroke="currentColor" stroke-width="1.4"/>
<text x="9" y="12" text-anchor="middle" font-size="8" font-family="monospace" fill="currentColor">1</text>
</svg>
<span>Sitz</span>
</button>
<button class="tool-btn" onclick={() => addElement('table')}>
<svg class="tool-icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="1" y="5" width="16" height="8" rx="3" stroke="currentColor" stroke-width="1.4"/>
</svg>
<span>Tisch</span>
</button>
<button class="tool-btn" onclick={() => addElement('door')}>
<svg class="tool-icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="3" y="1" width="12" height="16" rx="1" stroke="currentColor" stroke-width="1.4"/>
<circle cx="12.5" cy="9" r="1" fill="currentColor"/>
</svg>
<span>Tür</span>
</button>
<button class="tool-btn" onclick={() => addElement('gap')}>
<svg class="tool-icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="2" y="2" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.4" stroke-dasharray="3,2"/>
</svg>
<span>Lücke</span>
</button>
</div>
{:else}
<span class="small" style="color:var(--ink-4)">Element auswählen</span>
{/if}
</div>
<!-- Zoom controls -->
<div class="zoom-bar">
<button class="zoom-btn" onclick={zoomOut} disabled={zoom <= MIN_ZOOM} title="Herauszoomen"></button>
<button class="zoom-level" onclick={zoomReset} title="Zoom zurücksetzen">{Math.round(zoom * 100)}%</button>
<button class="zoom-btn" onclick={zoomIn} disabled={zoom >= MAX_ZOOM} title="Hineinzoomen">+</button>
</div>
<!-- Scrollable canvas viewport -->
<div class="canvas-viewport">
<div class="canvas-pad">
<RoomCanvas
bind:elements={room.layout}
editable={true}
snapStep={snapToGrid ? 0.5 : 0}
selectedId={selectedElementId}
{roomWidth}
{roomHeight}
{zoom}
onElementClick={(el) => { selectedElementId = el ? el.id : null; }}
/>
</div>
</div>
</div>
<!-- Properties panel -->
<aside class="props-panel card">
<div class="eyebrow" style="margin-bottom:10px">Auswahl</div>
{#if selectedElement}
<div class="props-fields">
<div>
<div class="tiny prop-label">Bezeichnung</div>
<input class="input" bind:value={selectedElement.label} />
</div>
<div class="prop-grid2">
<div>
<div class="tiny prop-label">X-Pos</div>
<input class="input" type="number" step="0.5"
bind:value={selectedElement.x}
onblur={() => snapProp('x')} />
</div>
<div>
<div class="tiny prop-label">Y-Pos</div>
<input class="input" type="number" step="0.5"
bind:value={selectedElement.y}
onblur={() => snapProp('y')} />
</div>
</div>
<div class="prop-grid2">
<div>
<div class="tiny prop-label">Breite</div>
<input class="input" type="number" step="0.5"
bind:value={selectedElement.width}
onblur={() => snapProp('width')} />
</div>
<div>
<div class="tiny prop-label">Höhe</div>
<input class="input" type="number" step="0.5"
bind:value={selectedElement.height}
onblur={() => snapProp('height')} />
</div>
</div>
<hr class="prop-divider" />
<button class="btn ghost sm" style="width:100%;justify-content:center"
onclick={duplicateElement}>Duplizieren</button>
<button class="del-btn" onclick={deleteElement}>Löschen ⌫</button>
</div>
{:else}
<p class="small" style="color:var(--ink-4)">Element auswählen zum Bearbeiten</p>
{/if}
</aside>
</div>
</div>
{:else if errorMsg}
<div style="padding:28px 36px">
<div class="error-banner">{errorMsg}</div>
</div>
{:else}
<div style="padding:28px 36px">
<span class="small" style="color:var(--ink-4)">Raum wird geladen…</span>
</div>
{/if}
<style>
/* ── Shell ── */
.editor-shell {
display: flex;
flex-direction: column;
}
/* ── Header ── */
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 28px;
border-bottom: 1px solid var(--rule);
flex-shrink: 0;
}
.header-left { display: flex; align-items: center; gap: 10px; }
.hd-sep { color: var(--ink-4); font-size: 16px; }
.hd-title { font-family: var(--serif); font-size: 18px; font-weight: 500; }
.header-right { display: flex; align-items: center; gap: 12px; }
.snap-label {
display: flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--ink-3); cursor: pointer;
}
.save-ok {
font-size: 12px; color: #2e7d40;
font-family: var(--mono); letter-spacing: 0.02em;
}
/* ── Dims bar ── */
.dims-bar {
display: flex; align-items: center; gap: 10px;
padding: 8px 28px;
border-bottom: 1px solid var(--rule);
background: var(--paper-2, #faf8f5);
flex-shrink: 0;
}
.dims-eyebrow {
font-family: var(--mono); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--ink-3); margin-right: 4px;
}
.dims-field {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--ink-3);
}
.dims-input { width: 64px !important; text-align: center; padding: 3px 6px !important; font-size: 13px !important; }
.dims-x { color: var(--ink-4); font-size: 14px; padding: 0 2px; }
.dims-unit { font-size: 11px; color: var(--ink-4); }
/* ── Error ── */
.error-banner {
background: rgba(138,44,31,0.06);
border: 1px solid var(--accent);
color: var(--accent);
padding: 10px 28px;
font-size: 13px;
flex-shrink: 0;
}
/* ── Body ── */
.editor-body {
display: flex;
overflow: hidden;
min-height: 560px;
}
/* ── Canvas area ── */
.canvas-area {
flex: 1;
position: relative;
background: var(--paper-2, #faf8f5);
overflow: hidden;
}
.canvas-viewport {
position: absolute;
inset: 0;
overflow: auto;
}
.canvas-pad { padding: 20px; display: inline-block; }
/* ── Floating toolbar ── */
.toolbar {
position: absolute;
top: 14px; left: 14px;
z-index: 10;
background: white;
border: 1px solid var(--rule);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
overflow: hidden;
display: flex;
flex-direction: column;
}
.toolbar-toggle {
width: 34px; height: 34px;
display: flex; align-items: center; justify-content: center;
background: none; border: none; cursor: pointer;
color: var(--ink-3); border-radius: 0;
transition: background 0.12s, color 0.12s;
}
.toolbar-toggle:hover { background: var(--paper-2); color: var(--ink); }
.tool-list {
display: flex; flex-direction: column;
padding: 5px;
gap: 2px;
border-top: 1px solid var(--rule);
}
.tool-btn {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px;
background: none;
border: 1px solid transparent;
border-radius: 5px;
cursor: pointer;
font-family: var(--sans); font-size: 12px;
color: var(--ink-2); white-space: nowrap;
transition: background 0.1s, border-color 0.1s, color 0.1s;
}
.tool-btn:hover { background: var(--paper-2); border-color: var(--rule); color: var(--ink); }
.tool-icon { flex-shrink: 0; color: var(--ink-3); transition: color 0.1s; }
.tool-btn:hover .tool-icon { color: var(--ink); }
/* ── Zoom bar ── */
.zoom-bar {
position: absolute;
bottom: 14px; right: 14px;
z-index: 10;
display: flex; align-items: center; gap: 1px;
background: white;
border: 1px solid var(--rule);
border-radius: 6px;
box-shadow: 0 1px 6px rgba(0,0,0,0.07);
padding: 2px;
}
.zoom-btn {
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
background: none; border: none; border-radius: 4px;
cursor: pointer; font-size: 15px; font-weight: 500;
color: var(--ink-2); line-height: 1;
transition: background 0.1s;
}
.zoom-btn:hover:not(:disabled) { background: var(--paper-2); }
.zoom-btn:disabled { opacity: 0.3; cursor: default; }
.zoom-level {
padding: 0 8px; height: 26px;
display: flex; align-items: center; justify-content: center;
font-family: var(--mono); font-size: 11px;
color: var(--ink-3); min-width: 42px;
background: none; border: none; border-radius: 4px; cursor: pointer;
transition: background 0.1s;
}
.zoom-level:hover { background: var(--paper-2); color: var(--ink); }
/* ── Properties panel ── */
.props-panel {
width: 220px;
flex-shrink: 0;
padding: 16px;
display: flex; flex-direction: column; gap: 12px;
border-left: 1px solid var(--rule);
border-radius: 0;
overflow-y: auto;
}
.props-fields { display: flex; flex-direction: column; gap: 10px; }
.prop-label { color: var(--ink-3); margin-bottom: 3px; }
.prop-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.prop-divider { border: none; border-top: 1px solid var(--rule); margin: 2px 0; }
.del-btn {
background: none; border: none; cursor: pointer;
font-family: var(--sans); font-size: 13px;
color: var(--accent); text-align: left; padding: 4px 0;
}
.del-btn:hover { opacity: 0.7; }
</style>