Add support for space resizing and enhanced layout management

- Update `SpacesService` to handle width and height properties for spaces.
- Introduce `UpdateSpaceDto` for improved validation in batch update operations.
- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.
- Modify Prisma schema to include `width` and `height` fields for spaces.
- Enhance floorplan rendering to maintain proportional layout during editing and viewing.
main
Pau Costa Ferrer 2026-03-28 15:53:33 +01:00
parent 45e42c3d37
commit 89fdbf6617
7 changed files with 344 additions and 77 deletions

View File

@ -4,17 +4,13 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages.">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`).">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/events/events.gateway.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/events/events.gateway.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/reservations.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/reservations.controller.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.controller.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/SpaceCard.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/SpaceCard.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/building/[id]/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/building/[id]/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/admin/FloorplanEditor.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/admin/FloorplanEditor.svelte" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -111,8 +107,8 @@
</configuration>
<recent_temporary>
<list>
<item itemvalue="npm.web &gt; dev" />
<item itemvalue="npm.api &gt; start:dev" />
<item itemvalue="npm.web &gt; dev" />
<item itemvalue="Docker.docker-compose.yml: Compose Deployment" />
</list>
</recent_temporary>
@ -133,7 +129,7 @@
<updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="1946000" />
<workItem from="1774706361502" duration="2888000" />
</task>
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services.">
<option name="closed" value="true" />
@ -175,7 +171,15 @@
<option name="project" value="LOCAL" />
<updated>1774707617715</updated>
</task>
<option name="localTasksCounter" value="6" />
<task id="LOCAL-00006" summary="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`).">
<option name="closed" value="true" />
<created>1774708414077</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1774708414077</updated>
</task>
<option name="localTasksCounter" value="7" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -187,6 +191,7 @@
<MESSAGE value="Implement authentication, organization management, and floor layout features&#10;&#10;- Add API authentication with JWT strategy using NestJS.&#10;- Configure Prisma schema for organizations, users, buildings, floors, and spaces.&#10;- Seed initial database with test data and relationships.&#10;- Build SvelteKit web pages for dashboard and floor layout with secured routing and organization hierarchy display.&#10;- Remove deprecated `AppService` from API." />
<MESSAGE value="Add floorplan editor for admin layout management&#10;&#10;- Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces.&#10;- Add backend support for floorplan image upload and batch updating space positions.&#10;- Update Prisma schema to include `planImageUrl` for floors.&#10;- Enable API proxy in Vite configuration for straightforward local development.&#10;- Refactor API calls to use a centralized `apiFetch` utility with token handling." />
<MESSAGE value="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages." />
<option name="LAST_COMMIT_MESSAGE" value="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages." />
<MESSAGE value="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
<option name="LAST_COMMIT_MESSAGE" value="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
</component>
</project>

View File

@ -58,6 +58,8 @@ model Space {
floorId String
x Float? // Percentage-based for responsiveness (Stage 3)
y Float?
width Float? // Percentage-based for resizing (Stage 4)
height Float? // Percentage-based for resizing (Stage 4)
reservations Reservation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -0,0 +1,35 @@
import { IsString, IsNumber, IsOptional, IsEnum, IsUUID } from 'class-validator';
import { SpaceType } from '@prisma/client';
export class UpdateSpaceDto {
@IsString()
id: string;
@IsNumber()
@IsOptional()
x?: number;
@IsNumber()
@IsOptional()
y?: number;
@IsNumber()
@IsOptional()
width?: number;
@IsNumber()
@IsOptional()
height?: number;
@IsString()
@IsOptional()
name?: string;
@IsEnum(SpaceType)
@IsOptional()
type?: SpaceType;
@IsUUID()
@IsOptional()
floorId?: string;
}

View File

@ -1,9 +1,10 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch, Query } from '@nestjs/common';
import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch, Query, ParseArrayPipe } from '@nestjs/common';
import { SpacesService } from './spaces.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { UpdateSpaceDto } from './dto/update-space.dto';
@Controller('spaces')
@UseGuards(JwtAuthGuard, RolesGuard)
@ -33,7 +34,7 @@ export class SpacesController {
@Post('batch-update')
@Roles(Role.ADMIN)
async batchUpdate(@Body() spaces: any[]) {
async batchUpdate(@Body(new ParseArrayPipe({ items: UpdateSpaceDto })) spaces: UpdateSpaceDto[]) {
return this.spacesService.batchUpdateSpaces(spaces);
}
}

View File

@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateSpaceDto } from './dto/update-space.dto';
@Injectable()
export class SpacesService {
private readonly logger = new Logger(SpacesService.name);
@ -70,18 +72,26 @@ export class SpacesService {
});
}
async batchUpdateSpaces(spaces: { id: string; x: number; y: number; type?: any; name?: string; floorId?: string }[]) {
async batchUpdateSpaces(spaces: UpdateSpaceDto[]) {
const updates = spaces.map((space) => {
const { id, ...data } = space;
if (id.startsWith('temp-')) {
if (!data.type) {
throw new Error(`Space type is required for new spaces`);
}
if (!data.floorId) {
throw new Error(`FloorId is required for new spaces`);
}
// Create new space if it's a new one from the toolbox
return this.prisma.space.create({
data: {
name: data.name || `${data.type} ${Math.floor(Math.random() * 1000)}`,
type: data.type,
floorId: data.floorId!,
floorId: data.floorId,
x: data.x,
y: data.y,
width: data.width,
height: data.height,
},
});
}
@ -90,6 +100,8 @@ export class SpacesService {
data: {
x: data.x,
y: data.y,
width: data.width,
height: data.height,
name: data.name,
},
});

View File

@ -13,6 +13,8 @@
let socket: Socket;
let spaceStatus: Record<string, any> = {};
let selectedSpace: any = null;
let containerWidth = 800;
let containerHeight = 600;
$: {
// Initialize space status from the current state
@ -26,6 +28,50 @@
};
});
spaceStatus = { ...spaceStatus };
updateContainerSizeToFitAll();
}
function updateContainerSizeToFitAll() {
if (spaces.length === 0) return;
let maxR = 800;
let maxB = 600;
spaces.forEach(s => {
const w = s.width || (s.type === 'DESK' ? 12 : 20);
const h = s.height || (s.type === 'DESK' ? 8 : 15);
const r = ((s.x + w) / 100) * 800; // Use 800 as base or whatever
const b = ((s.y + h) / 100) * 600;
if (r > maxR) maxR = r;
if (b > maxB) maxB = b;
});
// To be consistent with editor, we should probably use the same base.
// However, in the viewer, we just want to show everything.
// If spaces were saved with percentages relative to a larger container,
// we need to know that container's aspect ratio.
// Actually, the percentages ARE the aspect ratio.
// If a space is at x=110%, it means it's outside the "original" 800px.
// So we should just find the max percentage and scale the container.
let maxXP = 100;
let maxYP = 100;
spaces.forEach(s => {
const w = s.width || (s.type === 'DESK' ? 12 : 20);
const h = s.height || (s.type === 'DESK' ? 8 : 15);
if (s.x + w > maxXP) maxXP = s.x + w;
if (s.y + h > maxYP) maxYP = s.y + h;
});
containerWidth = (maxXP / 100) * 800;
containerHeight = (maxYP / 100) * 600;
// Add some padding
containerWidth += 40;
containerHeight += 40;
}
// Disable real-time updates if viewing a different date than today
@ -74,14 +120,14 @@
<div
bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden border mx-auto floorplan-container"
style="width: 800px; height: 600px; background-image: url({planImageUrl}); background-size: contain; background-repeat: no-repeat; background-position: center;"
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({planImageUrl}); background-size: cover; background-repeat: no-repeat; background-position: center;"
>
{#each spaces as space (space.id)}
{@const status = spaceStatus[space.id] || { isOccupied: false }}
<button
on:click={() => handleSpaceClick(space)}
class="absolute p-2 border-2 rounded-lg shadow-md transition-all {space.type !== 'AMENITY' ? 'hover:scale-105 cursor-pointer' : 'cursor-default opacity-80'} {status.isOccupied ? 'bg-yellow-50 border-yellow-500' : 'bg-green-50 border-green-500'}"
style="left: 0; top: 0; transform: translate({(space.x / 100) * (container?.clientWidth || 800)}px, {(space.y / 100) * (container?.clientHeight || 600)}px)"
style="left: 0; top: 0; width: {(space.width / 100) * containerWidth}px; height: {(space.height / 100) * containerHeight}px; transform: translate({(space.x / 100) * containerWidth}px, {(space.y / 100) * containerHeight}px)"
title="{space.name} ({status.isOccupied ? 'Booked' : 'Available'})"
>
<div class="flex items-center gap-1">
@ -133,6 +179,6 @@
background-image:
linear-gradient(to right, #e2e8f0 1px, transparent 1px),
linear-gradient(to bottom, #e2e8f0 1px, transparent 1px);
background-size: 40px 40px;
background-size: 20px 20px;
}
</style>

View File

@ -10,6 +10,43 @@
let container: HTMLDivElement;
let spaces = [...initialSpaces];
let containerWidth = 800;
let containerHeight = 600;
// Keep local spaces in sync if initialSpaces changes from parent (e.g. after full page load)
$: if (initialSpaces) {
// Only re-sync if the number of spaces changed or they are all new
// to avoid wiping out unsaved local changes while editing.
// In most cases, initialSpaces only changes on mount or full refresh.
if (spaces.length === 0 || (initialSpaces.length > 0 && !initialSpaces[0].id.startsWith('temp-') && spaces[0]?.id?.startsWith('temp-'))) {
spaces = [...initialSpaces];
updateContainerSizeToFitAll();
}
}
function updateContainerSizeToFitAll() {
if (spaces.length === 0) return;
let maxR = 800;
let maxB = 600;
spaces.forEach(s => {
const w = s.width || (s.type === 'DESK' ? 12 : 20);
const h = s.height || (s.type === 'DESK' ? 8 : 15);
// Calculate right and bottom in pixels relative to 800x600 (or current if it's the master)
// Actually, since we're starting from percentages, we should probably pick a base pixel size.
// Let's use 800 as the base width.
const r = ((s.x + w) / 100) * containerWidth;
const b = ((s.y + h) / 100) * containerHeight;
if (r > maxR) maxR = r;
if (b > maxB) maxB = b;
});
containerWidth = Math.max(800, Math.ceil(maxR + 40)); // +40 for some padding
containerHeight = Math.max(600, Math.ceil(maxB + 40));
}
let toolboxItems = [
{ type: 'DESK', icon: Laptop, label: 'Desk' },
{ type: 'MEETING_ROOM', icon: Users, label: 'Meeting Room' },
@ -21,17 +58,25 @@
let editingId: string | null = null;
let fileInput: HTMLInputElement;
onMount(() => {
initInteract();
});
function interactAction(node: HTMLElement, space: any) {
const isDesk = space.type === 'DESK';
const interactable = interact(node);
function initInteract() {
interact('.draggable-space').draggable({
interactable.draggable({
inertia: true,
allowFrom: '.drag-handle',
modifiers: [
interact.modifiers.restrictRect({
restriction: 'parent',
endOnly: true
}),
interact.modifiers.snap({
targets: [
interact.snappers.grid({ x: 20, y: 20 })
],
range: Infinity,
relativePoints: [ { x: 0, y: 0 } ],
offset: 'parent'
})
],
autoScroll: true,
@ -40,6 +85,37 @@
end: onDragEnd
}
});
if (!isDesk) {
interactable.resizable({
edges: { right: '.resize-handle', bottom: '.resize-handle' },
modifiers: [
interact.modifiers.restrictSize({
min: { width: 40, height: 40 }
}),
interact.modifiers.restrictRect({
restriction: 'parent'
}),
interact.modifiers.snap({
targets: [
interact.snappers.grid({ x: 20, y: 20 })
],
range: Infinity,
offset: 'parent'
})
],
listeners: {
move: resizeMoveListener,
end: onResizeEnd
}
});
}
return {
destroy() {
interactable.unset();
}
};
}
function dragMoveListener(event: any) {
@ -52,21 +128,84 @@
target.setAttribute('data-y', y);
}
function onDragEnd(event: any) {
function resizeMoveListener(event: any) {
const target = event.target;
let x = parseFloat(target.getAttribute('data-x')) || 0;
let y = parseFloat(target.getAttribute('data-y')) || 0;
target.style.width = event.rect.width + 'px';
target.style.height = event.rect.height + 'px';
x += event.deltaRect.left;
y += event.deltaRect.top;
target.style.transform = `translate(${x}px, ${y}px)`;
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
}
function onDragEnd(event: any) {
updateSpaceDimensions(event.target);
}
function onResizeEnd(event: any) {
updateSpaceDimensions(event.target);
}
function updateSpaceDimensions(target: any) {
const id = target.getAttribute('data-id');
const x = parseFloat(target.getAttribute('data-x')) || 0;
const y = parseFloat(target.getAttribute('data-y')) || 0;
const width = target.offsetWidth;
const height = target.offsetHeight;
// Convert to percentage
const rect = container.getBoundingClientRect();
const xPercent = (x / rect.width) * 100;
const yPercent = (y / rect.height) * 100;
if (!containerWidth || !containerHeight) return; // Prevent division by zero
// If the element is outside the current container, grow it!
const right = x + width;
const bottom = y + height;
const oldWidth = containerWidth;
const oldHeight = containerHeight;
let needsResize = false;
if (right > containerWidth - 20) {
containerWidth = Math.ceil(right + 40);
needsResize = true;
}
if (bottom > containerHeight - 20) {
containerHeight = Math.ceil(bottom + 40);
needsResize = true;
}
if (needsResize) {
spaces = spaces.map(s => {
// Recalculate all percentages based on new container size to keep pixel positions same
return {
...s,
x: (s.x * oldWidth) / containerWidth,
y: (s.y * oldHeight) / containerHeight,
width: (s.width * oldWidth) / containerWidth,
height: (s.height * oldHeight) / containerHeight
};
});
}
// Convert current target to percentages based on the (potentially new) container size
const xPercent = (x / containerWidth) * 100;
const yPercent = (y / containerHeight) * 100;
const widthPercent = (width / containerWidth) * 100;
const heightPercent = (height / containerHeight) * 100;
if (isNaN(xPercent) || isNaN(yPercent) || isNaN(widthPercent) || isNaN(heightPercent)) return;
const index = spaces.findIndex(s => s.id === id);
if (index !== -1) {
spaces[index].x = xPercent;
spaces[index].y = yPercent;
spaces[index].width = widthPercent;
spaces[index].height = heightPercent;
spaces = [...spaces];
}
}
@ -77,12 +216,11 @@
type: item.type,
x: 5,
y: 5,
width: item.type === 'DESK' ? 12 : 20, // Default widths as percentage
height: item.type === 'DESK' ? 8 : 15, // Default heights as percentage
floorId
};
spaces = [...spaces, newSpace];
// We need to wait for the DOM to update before initializing interact on new element
setTimeout(() => initInteract(), 0);
}
async function handleFileUpload(event: Event) {
@ -113,10 +251,14 @@
async function saveLayout() {
saving = true;
try {
await apiFetch('/spaces/batch-update', {
const response = await apiFetch<any[]>('/spaces/batch-update', {
method: 'POST',
body: JSON.stringify(spaces)
});
// Update local spaces with returned data (especially for new IDs)
if (Array.isArray(response)) {
spaces = response;
}
alert('Layout saved successfully!');
} catch (err) {
console.error('Save failed', err);
@ -204,15 +346,14 @@
</div>
</div>
<!-- Editor Canvas -->
<div class="flex-1 p-8 overflow-auto bg-gray-200 flex justify-center items-start">
<div
bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden floorplan-container"
style="width: 800px; height: 600px; background-image: url({planImageUrl}); background-size: contain; background-repeat: no-repeat; background-position: center;"
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({planImageUrl}); background-size: cover; background-repeat: no-repeat; background-position: center;"
>
{#if !planImageUrl}
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400">
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none">
<Upload size={48} class="mb-4 opacity-20" />
<p>Upload a floorplan image to start</p>
</div>
@ -220,24 +361,26 @@
{#each spaces as space (space.id)}
<div
class="draggable-space absolute cursor-move p-2 bg-white border-2 border-blue-500 rounded-lg shadow-lg group"
style="left: 0; top: 0; transform: translate({(space.x / 100) * (container?.clientWidth || 800)}px, {(space.y / 100) * (container?.clientHeight || 600)}px)"
use:interactAction={space}
class="draggable-space absolute p-2 bg-white border-2 border-blue-500 rounded-lg shadow-lg group {space.type !== 'DESK' ? 'resizable-space' : ''}"
style="left: 0; top: 0; width: {(space.width / 100) * containerWidth}px; height: {(space.height / 100) * containerHeight}px; transform: translate({(space.x / 100) * containerWidth}px, {(space.y / 100) * containerHeight}px)"
data-id={space.id}
data-x={(space.x / 100) * (container?.clientWidth || 800)}
data-y={(space.y / 100) * (container?.clientHeight || 600)}
data-x={(space.x / 100) * containerWidth}
data-y={(space.y / 100) * containerHeight}
>
<div class="flex items-center gap-2">
<div class="text-blue-600">
<div class="flex flex-col h-full drag-handle cursor-move">
<div class="flex items-center gap-2 mb-1">
<div class="text-blue-600 shrink-0">
{#if space.type === 'DESK'}
<Laptop size={20} />
<Laptop size={16} />
{:else if space.type === 'MEETING_ROOM'}
<Users size={20} />
<Users size={16} />
{:else if space.subtype === 'TOILET'}
<Bath size={20} />
<Bath size={16} />
{:else if space.subtype === 'COFFEE'}
<Coffee size={20} />
<Coffee size={16} />
{:else}
<div class="w-5 h-5 bg-gray-400 rounded-full"></div>
<div class="w-4 h-4 bg-gray-400 rounded-full"></div>
{/if}
</div>
@ -248,14 +391,14 @@
on:blur={stopEditing}
on:keydown={(e) => handleKeydown(e, space.id)}
on:click|stopPropagation
class="text-[10px] font-bold text-gray-700 border border-blue-300 outline-none bg-blue-50 rounded px-1 w-20"
class="text-[10px] font-bold text-gray-700 border border-blue-300 outline-none bg-blue-50 rounded px-1 w-full"
autofocus
/>
{:else}
<span
on:click|stopPropagation={() => startEditing(space.id)}
title="Click to rename"
class="text-[10px] font-bold text-gray-700 select-none whitespace-nowrap hover:bg-gray-100 px-1 rounded cursor-text"
class="text-[10px] font-bold text-gray-700 select-none truncate hover:bg-gray-100 px-1 rounded cursor-text"
>
{space.name}
</span>
@ -263,11 +406,25 @@
<button
on:click|stopPropagation={() => removeSpace(space.id)}
class="hidden group-hover:flex items-center justify-center w-4 h-4 text-white bg-red-500 rounded-full hover:bg-red-600 ml-1"
class="hidden group-hover:flex items-center justify-center shrink-0 w-4 h-4 text-white bg-red-500 rounded-full hover:bg-red-600 ml-auto"
>
×
</button>
</div>
{#if space.type !== 'DESK'}
<div class="flex-1 border border-dashed border-gray-200 rounded flex items-center justify-center text-[8px] text-gray-400 overflow-hidden relative pointer-events-none">
{space.type.replace('_', ' ')}
</div>
{/if}
</div>
{#if space.type !== 'DESK'}
<!-- Explicit resize handle OUTSIDE drag-handle -->
<div class="resize-handle absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10 flex items-center justify-center">
<div class="w-2 h-2 border-b-2 border-r-2 border-blue-400 opacity-50"></div>
</div>
{/if}
</div>
{/each}
</div>
@ -281,6 +438,15 @@
user-select: none;
}
.resize-handle {
background: linear-gradient(135deg, transparent 50%, rgba(59, 130, 246, 0.1) 50%, rgba(59, 130, 246, 0.1) 100%);
border-radius: 0 0 4px 0;
}
.resize-handle:hover {
background: linear-gradient(135deg, transparent 50%, rgba(59, 130, 246, 0.3) 50%, rgba(59, 130, 246, 0.3) 100%);
}
.floorplan-container:not([style*="background-image: url()"]):not([style*='background-image: url("")']):not([style*="background-image: url(null)"]) {
/* When an image is set, we don't need the grid if we want it to be clean,
but the instruction says "if layout isn't configured yet".
@ -293,6 +459,6 @@
background-image:
linear-gradient(to right, #e2e8f0 1px, transparent 1px),
linear-gradient(to bottom, #e2e8f0 1px, transparent 1px);
background-size: 40px 40px;
background-size: 20px 20px;
}
</style>