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:
@@ -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 (?, ?)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user