fix: superadmin access for sessions/rooms; redesign room editor
Some checks failed
Release / release (push) Failing after 1m42s
Some checks failed
Release / release (push) Failing after 1m42s
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>,
|
State(pool): State<SqlitePool>,
|
||||||
Json(req): Json<CreateRoom>,
|
Json(req): Json<CreateRoom>,
|
||||||
) -> Result<(StatusCode, Json<Value>), AppError> {
|
) -> 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)
|
let layout_json = serde_json::to_string(&req.layout)
|
||||||
.map_err(|e| AppError::BadRequest(format!("layout serialization error: {e}")))?;
|
.map_err(|e| AppError::BadRequest(format!("layout serialization error: {e}")))?;
|
||||||
let id = sqlx::query("INSERT INTO rooms (name, layout_json) VALUES (?, ?)")
|
let id = sqlx::query("INSERT INTO rooms (name, layout_json) VALUES (?, ?)")
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ async fn list_sessions(
|
|||||||
State(pool): State<SqlitePool>,
|
State(pool): State<SqlitePool>,
|
||||||
Query(q): Query<SessionQuery>,
|
Query(q): Query<SessionQuery>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> 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>(
|
let sessions = sqlx::query_as::<_, Session>(
|
||||||
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY date, week_nr",
|
"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")
|
chrono::NaiveDate::parse_from_str(&req.date, "%Y-%m-%d")
|
||||||
.map_err(|_| AppError::BadRequest("date must be YYYY-MM-DD".into()))?;
|
.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 (?, ?, ?)")
|
let id = sqlx::query("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, ?, ?)")
|
||||||
.bind(req.course_id)
|
.bind(req.course_id)
|
||||||
@@ -105,23 +109,25 @@ async fn create_slot(
|
|||||||
.await?;
|
.await?;
|
||||||
let (course_id,) = session_row.ok_or(AppError::NotFound)?;
|
let (course_id,) = session_row.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
// Verify requesting tutor has access to the course
|
if !claims.is_superadmin {
|
||||||
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
// 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
|
// Verify the slot's tutor_id belongs to this course AND is active
|
||||||
let member: Option<(i64,)> = sqlx::query_as(
|
let member: Option<(i64,)> = sqlx::query_as(
|
||||||
"SELECT 1 FROM tutor_courses tc
|
"SELECT 1 FROM tutor_courses tc
|
||||||
JOIN tutors t ON t.id = tc.tutor_id
|
JOIN tutors t ON t.id = tc.tutor_id
|
||||||
WHERE tc.tutor_id = ? AND tc.course_id = ? AND t.is_active = 1",
|
WHERE tc.tutor_id = ? AND tc.course_id = ? AND t.is_active = 1",
|
||||||
)
|
)
|
||||||
.bind(req.tutor_id)
|
.bind(req.tutor_id)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
if member.is_none() {
|
if member.is_none() {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Tutor:in ist in diesem Kurs nicht aktiv oder existiert nicht.".into(),
|
"Tutor:in ist in diesem Kurs nicht aktiv oder existiert nicht.".into(),
|
||||||
));
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally verify room exists
|
// Optionally verify room exists
|
||||||
@@ -183,7 +189,9 @@ async fn update_slot_status(
|
|||||||
.await?;
|
.await?;
|
||||||
let (course_id, existing_code) = row.ok_or(AppError::NotFound)?;
|
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)
|
// If transitioning to open and no code yet, generate one (retry on UNIQUE collision)
|
||||||
let code: Option<String> = if req.status == "open" {
|
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 = ?")
|
sqlx::query("DELETE FROM slots WHERE id = ?")
|
||||||
.bind(slot_id)
|
.bind(slot_id)
|
||||||
|
|||||||
@@ -11,253 +11,255 @@
|
|||||||
selectedId?: string | null;
|
selectedId?: string | null;
|
||||||
occupiedSeatIds?: string[];
|
occupiedSeatIds?: string[];
|
||||||
mySeatId?: string | null;
|
mySeatId?: string | null;
|
||||||
studentNames?: Record<string, string>; // seat_id -> name
|
studentNames?: Record<string, string>;
|
||||||
|
roomWidth?: number;
|
||||||
|
roomHeight?: number;
|
||||||
|
zoom?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
elements = $bindable([]),
|
elements = $bindable([]),
|
||||||
editable = false,
|
editable = false,
|
||||||
clickable = false,
|
clickable = false,
|
||||||
snapStep = 0.5,
|
snapStep = 0.5,
|
||||||
onElementClick,
|
onElementClick,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
selectedId = null,
|
selectedId = null,
|
||||||
occupiedSeatIds = [],
|
occupiedSeatIds = [],
|
||||||
mySeatId = null,
|
mySeatId = null,
|
||||||
studentNames = {}
|
studentNames = {},
|
||||||
|
roomWidth = 20,
|
||||||
|
roomHeight = 15,
|
||||||
|
zoom = 1.0,
|
||||||
}: Props = $props();
|
}: 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 draggingId = $state<string | null>(null);
|
||||||
let resizingId = $state<string | null>(null);
|
let resizingId = $state<string | null>(null);
|
||||||
let resizeDir = $state<'e' | 's' | 'se' | null>(null);
|
let resizeDir = $state<'e' | 's' | 'se' | null>(null);
|
||||||
|
let startGrid = { x: 0, y: 0 };
|
||||||
let startX = 0;
|
let initEl = { x: 0, y: 0, w: 0, h: 0 };
|
||||||
let startY = 0;
|
|
||||||
let initialX = 0;
|
|
||||||
let initialY = 0;
|
|
||||||
let initialW = 0;
|
|
||||||
let initialH = 0;
|
|
||||||
let dragMoved = false;
|
let dragMoved = false;
|
||||||
|
|
||||||
const GRID_SIZE = 40;
|
function onElemPointerDown(e: PointerEvent, el: LayoutElement) {
|
||||||
|
if (clickable && !editable) { onElementClick?.(el); return; }
|
||||||
function handleMouseDown(e: MouseEvent, el: LayoutElement) {
|
|
||||||
if (clickable && !editable) {
|
|
||||||
onElementClick?.(el);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!editable) return;
|
if (!editable) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
(e.currentTarget as Element).setPointerCapture(e.pointerId);
|
||||||
draggingId = el.id;
|
draggingId = el.id;
|
||||||
dragMoved = false;
|
dragMoved = false;
|
||||||
startX = e.clientX;
|
startGrid = toGridCoords(e);
|
||||||
startY = e.clientY;
|
initEl = { x: el.x, y: el.y, w: el.width, h: el.height };
|
||||||
initialX = el.x;
|
|
||||||
initialY = el.y;
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResizeDown(e: MouseEvent, el: LayoutElement, dir: 'e' | 's' | 'se') {
|
function onElemPointerMove(e: PointerEvent, el: LayoutElement) {
|
||||||
if (!editable) return;
|
if (!editable || draggingId !== el.id) return;
|
||||||
resizingId = el.id;
|
const cur = toGridCoords(e);
|
||||||
resizeDir = dir;
|
const dx = cur.x - startGrid.x;
|
||||||
startX = e.clientX;
|
const dy = cur.y - startGrid.y;
|
||||||
startY = e.clientY;
|
if (Math.abs(dx) > 0.05 || Math.abs(dy) > 0.05) dragMoved = true;
|
||||||
initialW = el.width;
|
const i = elements.findIndex(x => x.id === el.id);
|
||||||
initialH = el.height;
|
if (i === -1) return;
|
||||||
e.stopPropagation();
|
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) {
|
function onElemPointerUp(e: PointerEvent, el: LayoutElement) {
|
||||||
if (!editable) return;
|
if (!editable || draggingId !== el.id) return;
|
||||||
|
if (dragMoved) {
|
||||||
if (draggingId) {
|
onLayoutChange?.(elements);
|
||||||
dragMoved = true;
|
} else {
|
||||||
const index = elements.findIndex(el => el.id === draggingId);
|
onElementClick?.(el);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
draggingId = null;
|
draggingId = null;
|
||||||
resizingId = null;
|
|
||||||
resizeDir = null;
|
|
||||||
dragMoved = false;
|
dragMoved = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute viewBox based on elements or default
|
function onResizeDown(e: PointerEvent, el: LayoutElement, dir: 'e' | 's' | 'se') {
|
||||||
let maxX = $derived(elements.reduce((max, el) => Math.max(max, el.x + el.width), 20));
|
if (!editable) return;
|
||||||
let maxY = $derived(elements.reduce((max, el) => Math.max(max, el.y + el.height), 15));
|
e.stopPropagation();
|
||||||
let viewBox = $derived(`0 0 ${Math.ceil(maxX * GRID_SIZE)} ${Math.ceil(maxY * GRID_SIZE)}`);
|
(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>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
|
||||||
onmousemove={handleWindowMouseMove}
|
|
||||||
onmouseup={handleWindowMouseUp}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<svg
|
||||||
<svg
|
bind:this={svgEl}
|
||||||
{viewBox}
|
{viewBox}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
width={svgWidth}
|
||||||
class="room-canvas"
|
height={svgHeight}
|
||||||
|
class="room-canvas"
|
||||||
class:editable
|
class:editable
|
||||||
class:clickable={clickable || editable}
|
class:clickable={clickable || editable}
|
||||||
onclick={(e) => {
|
onpointerdown={(e) => {
|
||||||
// Deselect if clicking on empty canvas
|
if (editable && e.target === e.currentTarget) onElementClick?.(null);
|
||||||
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 -->
|
<!-- Grid -->
|
||||||
{#if editable && snapStep > 0}
|
{#if editable && snapStep > 0}
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="grid" width={GRID_SIZE * snapStep} height={GRID_SIZE * snapStep} patternUnits="userSpaceOnUse">
|
<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="var(--rule-soft)" stroke-width="1" stroke-dasharray="2,2"/>
|
<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>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
<rect width={roomWidth * GRID_SIZE} height={roomHeight * GRID_SIZE} fill="url(#editgrid)" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each elements as el (el.id)}
|
{#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})"
|
transform="translate({el.x * GRID_SIZE}, {el.y * GRID_SIZE})"
|
||||||
class="element {el.type}"
|
class="element {el.type}"
|
||||||
class:selected={selectedId === el.id}
|
class:selected={selectedId === el.id}
|
||||||
|
class:dragging={draggingId === el.id}
|
||||||
class:occupied={el.type === 'seat' && occupiedSeatIds.includes(el.id)}
|
class:occupied={el.type === 'seat' && occupiedSeatIds.includes(el.id)}
|
||||||
class:is-mine={el.id === mySeatId}
|
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'}
|
{#if el.type === 'seat'}
|
||||||
<rect
|
<rect width={el.width * GRID_SIZE} height={el.height * GRID_SIZE} rx="8" />
|
||||||
width={el.width * GRID_SIZE}
|
<text
|
||||||
height={el.height * GRID_SIZE}
|
x={el.width * GRID_SIZE / 2}
|
||||||
rx="8"
|
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"
|
dominant-baseline="middle"
|
||||||
>
|
>{el.label}</text>
|
||||||
{el.label}
|
|
||||||
</text>
|
|
||||||
{#if studentNames[el.id]}
|
{#if studentNames[el.id]}
|
||||||
<text
|
<text
|
||||||
x={(el.width * GRID_SIZE) / 2}
|
x={el.width * GRID_SIZE / 2}
|
||||||
y={(el.height * GRID_SIZE) + 14}
|
y={el.height * GRID_SIZE + 14}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
class="student-name"
|
class="student-name"
|
||||||
>
|
>{studentNames[el.id]}</text>
|
||||||
{studentNames[el.id]}
|
|
||||||
</text>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else if el.type === 'table'}
|
{:else if el.type === 'table'}
|
||||||
<rect
|
<rect width={el.width * GRID_SIZE} height={el.height * GRID_SIZE} rx="12" />
|
||||||
width={el.width * GRID_SIZE}
|
{#if editable}
|
||||||
height={el.height * GRID_SIZE}
|
<text
|
||||||
rx="16"
|
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'}
|
{:else if el.type === 'gap'}
|
||||||
<!-- Invisible interaction target -->
|
<rect
|
||||||
<rect
|
width={el.width * GRID_SIZE}
|
||||||
width={el.width * GRID_SIZE}
|
height={el.height * GRID_SIZE}
|
||||||
height={el.height * GRID_SIZE}
|
class="gap-rect"
|
||||||
fill="transparent"
|
stroke-dasharray="4,3"
|
||||||
stroke={editable ? "var(--rule)" : "none"}
|
|
||||||
stroke-dasharray="4,4"
|
|
||||||
/>
|
/>
|
||||||
{#if editable}
|
{#if editable}
|
||||||
<text
|
<text
|
||||||
x={(el.width * GRID_SIZE) / 2}
|
x={el.width * GRID_SIZE / 2}
|
||||||
y={(el.height * GRID_SIZE) / 2}
|
y={el.height * GRID_SIZE / 2}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
dominant-baseline="middle"
|
dominant-baseline="middle"
|
||||||
style="font-size: 10px; fill: var(--ink-4);"
|
class="gap-label"
|
||||||
>
|
>LÜCKE</text>
|
||||||
GAP
|
|
||||||
</text>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else if el.type === 'door'}
|
{:else if el.type === 'door'}
|
||||||
<rect
|
<rect width={el.width * GRID_SIZE} height={el.height * GRID_SIZE} class="door-rect" />
|
||||||
width={el.width * GRID_SIZE}
|
<text
|
||||||
height={el.height * GRID_SIZE}
|
x={el.width * GRID_SIZE / 2}
|
||||||
fill="transparent"
|
y={el.height * GRID_SIZE / 2}
|
||||||
stroke="var(--rule)"
|
text-anchor="middle"
|
||||||
stroke-width="2"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={(el.width * GRID_SIZE) / 2}
|
|
||||||
y={(el.height * GRID_SIZE) / 2}
|
|
||||||
text-anchor="middle"
|
|
||||||
dominant-baseline="middle"
|
dominant-baseline="middle"
|
||||||
style="font-size: 10px; fill: var(--ink-3); font-family: var(--mono); letter-spacing: 0.1em;"
|
class="door-label"
|
||||||
>
|
>TÜR</text>
|
||||||
DOOR
|
|
||||||
</text>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if editable && selectedId === el.id}
|
{#if editable && selectedId === el.id}
|
||||||
<!-- Resize handles -->
|
<!-- 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')} />
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<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
|
||||||
<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')} />
|
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}
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -265,68 +267,96 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.room-canvas {
|
.room-canvas {
|
||||||
background: transparent;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
.room-canvas.clickable .element {
|
.room-canvas.editable { cursor: default; }
|
||||||
cursor: pointer;
|
.room-canvas.clickable .element { cursor: pointer; }
|
||||||
}
|
.room-canvas.editable .element { cursor: grab; }
|
||||||
|
.room-canvas.editable .element.dragging { cursor: grabbing; }
|
||||||
.element rect {
|
|
||||||
transition: fill 0.2s, stroke 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.element.seat rect {
|
.room-bg {
|
||||||
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 {
|
|
||||||
fill: white;
|
fill: white;
|
||||||
}
|
stroke: var(--rule, #e8e4dc);
|
||||||
|
|
||||||
.element.selected rect {
|
|
||||||
stroke: var(--highlight);
|
|
||||||
stroke-width: 2.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.element.table rect {
|
|
||||||
fill: rgba(0,0,0,0.03);
|
|
||||||
stroke: var(--rule);
|
|
||||||
stroke-width: 1;
|
stroke-width: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.student-name {
|
/* Seats */
|
||||||
font-size: 11px;
|
.element.seat rect {
|
||||||
fill: var(--ink-3);
|
fill: #faf7f0;
|
||||||
font-family: var(--sans);
|
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 {
|
.resize-handle {
|
||||||
fill: white;
|
fill: white;
|
||||||
stroke: var(--accent);
|
stroke: var(--accent, #8a2c1f);
|
||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
}
|
}
|
||||||
.resize-handle.e { cursor: ew-resize; }
|
.resize-handle.e { cursor: ew-resize; }
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import RoomCanvas from '$lib/RoomCanvas.svelte';
|
import RoomCanvas from '$lib/RoomCanvas.svelte';
|
||||||
import type { Room, LayoutElement } from '$lib/types';
|
import type { Room, LayoutElement } from '$lib/types';
|
||||||
|
|
||||||
const roomIdStr = ($page.params as Record<string, string>).roomId;
|
const roomId = $derived(page.params.roomId ? parseInt(page.params.roomId) : 0);
|
||||||
const roomId = $derived(roomIdStr ? parseInt(roomIdStr) : 0);
|
|
||||||
|
|
||||||
let room = $state<Room | null>(null);
|
let room = $state<Room | null>(null);
|
||||||
let errorMsg = $state<string | null>(null);
|
let errorMsg = $state<string | null>(null);
|
||||||
|
let saveOk = $state(false);
|
||||||
let snapToGrid = $state(true);
|
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(() => {
|
$effect(() => {
|
||||||
if (roomId) {
|
if (!roomId) return;
|
||||||
api.admin.rooms.get(roomId)
|
api.admin.rooms.get(roomId)
|
||||||
.then((r: Room) => { room = r; })
|
.then((r: Room) => {
|
||||||
.catch((e: unknown) => {
|
room = r;
|
||||||
errorMsg = e instanceof Error ? e.message : 'Raum konnte nicht geladen werden.';
|
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() {
|
async function saveLayout() {
|
||||||
@@ -26,158 +41,445 @@
|
|||||||
errorMsg = null;
|
errorMsg = null;
|
||||||
try {
|
try {
|
||||||
await api.admin.rooms.updateLayout(room.id, room.layout);
|
await api.admin.rooms.updateLayout(room.id, room.layout);
|
||||||
|
saveOk = true;
|
||||||
|
setTimeout(() => { saveOk = false; }, 2500);
|
||||||
} catch (e) {
|
} 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']) {
|
function addElement(type: LayoutElement['type']) {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
const id = Math.random().toString(36).slice(2, 11);
|
const id = Math.random().toString(36).slice(2, 11);
|
||||||
let nextLabel: string;
|
let label = '';
|
||||||
|
|
||||||
if (type === 'seat') {
|
if (type === 'seat') {
|
||||||
const existingLabels = room.layout
|
const nums = room.layout
|
||||||
.filter((e: LayoutElement) => e.type === 'seat')
|
.filter(e => e.type === 'seat')
|
||||||
.map((e: LayoutElement) => parseInt(e.label, 10))
|
.map(e => parseInt(e.label, 10))
|
||||||
.filter(n => !isNaN(n));
|
.filter(n => !isNaN(n));
|
||||||
const maxLabel = existingLabels.length > 0 ? Math.max(...existingLabels) : 0;
|
label = (nums.length ? Math.max(...nums) + 1 : 1).toString();
|
||||||
nextLabel = (maxLabel + 1).toString();
|
|
||||||
} else {
|
|
||||||
nextLabel = '';
|
|
||||||
}
|
}
|
||||||
|
room.layout = [...room.layout, {
|
||||||
const newEl: LayoutElement = {
|
id, label, x: 0, y: 0,
|
||||||
id,
|
|
||||||
label: nextLabel,
|
|
||||||
x: 0, y: 0,
|
|
||||||
width: type === 'table' ? 2 : 1,
|
width: type === 'table' ? 2 : 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
type,
|
type,
|
||||||
};
|
}];
|
||||||
room.layout = [...room.layout, newEl];
|
|
||||||
selectedElementId = id;
|
selectedElementId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function duplicateElement() {
|
function duplicateElement() {
|
||||||
if (!room || !selectedElement) return;
|
if (!room || !selectedElement) return;
|
||||||
const id = Math.random().toString(36).slice(2, 11);
|
const id = Math.random().toString(36).slice(2, 11);
|
||||||
|
let label = selectedElement.label;
|
||||||
let nextLabel: string;
|
|
||||||
if (selectedElement.type === 'seat') {
|
if (selectedElement.type === 'seat') {
|
||||||
const existingLabels = room.layout
|
const nums = room.layout.filter(e => e.type === 'seat').map(e => parseInt(e.label, 10)).filter(n => !isNaN(n));
|
||||||
.filter((e: LayoutElement) => e.type === 'seat')
|
label = (nums.length ? Math.max(...nums) + 1 : 1).toString();
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
room.layout = [...room.layout, {
|
||||||
const duplicatedEl: LayoutElement = {
|
...selectedElement, id, label,
|
||||||
...selectedElement,
|
x: Math.min(roomWidth - selectedElement.width, selectedElement.x + 0.5),
|
||||||
id,
|
y: Math.min(roomHeight - selectedElement.height, selectedElement.y + 0.5),
|
||||||
label: nextLabel,
|
}];
|
||||||
x: selectedElement.x + (snapToGrid ? 0.5 : 0.1),
|
|
||||||
y: selectedElement.y + (snapToGrid ? 0.5 : 0.1),
|
|
||||||
};
|
|
||||||
room.layout = [...room.layout, duplicatedEl];
|
|
||||||
selectedElementId = id;
|
selectedElementId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedElementId = $state<string | null>(null);
|
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() {
|
function deleteElement() {
|
||||||
if (!room || !selectedElementId) return;
|
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;
|
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>
|
</script>
|
||||||
|
|
||||||
{#if room}
|
{#if room}
|
||||||
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:16px">
|
<div class="editor-shell">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
|
||||||
<div>
|
<!-- ── Header ── -->
|
||||||
<span class="eyebrow">Räume</span>
|
<header class="editor-header">
|
||||||
<h2 class="h2" style="font-family:var(--serif)">{room.name}</h2>
|
<div class="header-left">
|
||||||
</div>
|
<a href="/admin/rooms" class="btn ghost sm">← Räume</a>
|
||||||
<div style="display:flex;gap:8px">
|
<span class="hd-sep">/</span>
|
||||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;margin-right:12px;font-size:13px;color:var(--ink-3)">
|
<span class="hd-title">{room.name}</span>
|
||||||
<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>
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
{#if errorMsg}
|
<label class="snap-label">
|
||||||
<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">
|
<input type="checkbox" bind:checked={snapToGrid} />
|
||||||
{errorMsg}
|
Raster fangen
|
||||||
</div>
|
</label>
|
||||||
{/if}
|
{#if saveOk}
|
||||||
|
<span class="save-ok">✓ Gespeichert</span>
|
||||||
|
{/if}
|
||||||
|
<button class="btn" onclick={saveLayout}>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div style="display:flex;gap:20px">
|
<!-- ── Dims bar ── -->
|
||||||
<div style="flex:1">
|
<div class="dims-bar">
|
||||||
<RoomCanvas
|
<span class="dims-eyebrow">Raumgröße</span>
|
||||||
bind:elements={room.layout}
|
<label class="dims-field">
|
||||||
editable={true}
|
Breite
|
||||||
snapStep={snapToGrid ? 0.5 : 0}
|
<input class="input dims-input" type="number" min="5" max="50" step="1" bind:value={roomWidth} />
|
||||||
selectedId={selectedElementId}
|
</label>
|
||||||
onElementClick={(el) => { selectedElementId = el ? el.id : null; }}
|
<span class="dims-x">×</span>
|
||||||
/>
|
<label class="dims-field">
|
||||||
</div>
|
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">
|
{#if errorMsg}
|
||||||
<div class="eyebrow">Auswahl</div>
|
<div class="error-banner">{errorMsg}</div>
|
||||||
{#if selectedElement}
|
{/if}
|
||||||
<div style="display:flex;flex-direction:column;gap:8px">
|
|
||||||
<div>
|
<!-- ── Body ── -->
|
||||||
<div class="tiny" style="color:var(--ink-3)">Bezeichnung</div>
|
<div class="editor-body">
|
||||||
<input class="input" bind:value={selectedElement.label} />
|
|
||||||
</div>
|
<!-- Canvas area -->
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
<div class="canvas-area" use:wheelZoom>
|
||||||
<div>
|
|
||||||
<div class="tiny" style="color:var(--ink-3)">X-Pos</div>
|
<!-- Floating toolbar sidebar -->
|
||||||
<input class="input" type="number" step="0.5" bind:value={selectedElement.x} />
|
<div class="toolbar" class:open={sidebarOpen}>
|
||||||
</div>
|
<button
|
||||||
<div>
|
class="toolbar-toggle"
|
||||||
<div class="tiny" style="color:var(--ink-3)">Y-Pos</div>
|
onclick={() => sidebarOpen = !sidebarOpen}
|
||||||
<input class="input" type="number" step="0.5" bind:value={selectedElement.y} />
|
title={sidebarOpen ? 'Ausblenden' : 'Werkzeuge einblenden'}
|
||||||
</div>
|
>
|
||||||
</div>
|
{#if sidebarOpen}
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
<div>
|
<path d="M8 1L3 6l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<div class="tiny" style="color:var(--ink-3)">Breite</div>
|
</svg>
|
||||||
<input class="input" type="number" step="0.5" bind:value={selectedElement.width} />
|
{:else}
|
||||||
</div>
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
<div>
|
<path d="M4 1l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<div class="tiny" style="color:var(--ink-3)">Höhe</div>
|
</svg>
|
||||||
<input class="input" type="number" step="0.5" bind:value={selectedElement.height} />
|
{/if}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
<div style="height:1px;background:var(--rule);margin:8px 0"></div>
|
{#if sidebarOpen}
|
||||||
<button
|
<div class="tool-list">
|
||||||
class="btn ghost sm" style="justify-content:center"
|
<button class="tool-btn" onclick={() => addElement('seat')}>
|
||||||
onclick={duplicateElement}
|
<svg class="tool-icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
>Duplizieren</button>
|
<rect x="2" y="2" width="14" height="14" rx="4" stroke="currentColor" stroke-width="1.4"/>
|
||||||
<button
|
<text x="9" y="12" text-anchor="middle" font-size="8" font-family="monospace" fill="currentColor">1</text>
|
||||||
style="color:var(--accent);background:none;border:none;cursor:pointer;text-align:left;font-family:var(--sans);font-size:13px;padding:4px 0"
|
</svg>
|
||||||
onclick={deleteElement}
|
<span>Sitz</span>
|
||||||
>Löschen ⌫</button>
|
</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>
|
</div>
|
||||||
{:else}
|
|
||||||
<span class="small" style="color:var(--ink-4)">Element auswählen</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div style="padding:28px 36px">
|
<div style="padding:28px 36px">
|
||||||
<span class="small" style="color:var(--ink-4)">Raum wird geladen…</span>
|
<span class="small" style="color:var(--ink-4)">Raum wird geladen…</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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