Add assets module with file upload and retrieval support

- Introduce `AssetsModule` with APIs for uploading and retrieving organization logos, building images, and floor plans.
- Implement file validation for type and size (10MB max).
- Add `resolveAssetUrl` utility for generating absolute asset URLs.
- Enhance admin UI components to support image file uploads and previews.
- Update Prisma schema to associate assets with organizations, buildings, and floors.
- Modify existing update logic to clear references when external URLs are used.
main
Pau Costa Ferrer 2026-03-28 17:10:22 +01:00
parent b618c10cdd
commit 2bab9da7c8
20 changed files with 679 additions and 120 deletions

View File

@ -4,12 +4,23 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Fix ownership checks and improve delete booking button UI&#10;&#10;- Update `isOwner` logic to account for nested user objects in reservation data.&#10;- Enhance delete button styling for better visibility and usability."> <list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add organization management with logo support&#10;&#10;- Implement organization settings page (`+page.svelte`) for updating organization name and logo.&#10;- Create backend APIs for retrieving and updating organization details with role-based access control.&#10;- Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance navigation to dynamically display organization logo and name.">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/apps/api/src/prisma/prisma.service.ts" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/apps/api/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/package.json" 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/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/app.module.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.module.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/api/src/app.module.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.module.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/organizations/dto/update-organization.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/organizations/dto/update-organization.dto.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/organizations/organizations.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/organizations/organizations.service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/dto/building-floor.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/dto/building-floor.dto.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/api/client.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/api/client.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/admin/BuildingEditorModal.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/admin/BuildingEditorModal.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" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/admin/organization/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/admin/organization/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -131,7 +142,7 @@
<updated>1774643312599</updated> <updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" /> <workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" /> <workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="5853000" /> <workItem from="1774706361502" duration="7821000" />
</task> </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."> <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" /> <option name="closed" value="true" />
@ -237,7 +248,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1774711743510</updated> <updated>1774711743510</updated>
</task> </task>
<option name="localTasksCounter" value="14" /> <task id="LOCAL-00014" summary="Add organization management with logo support&#10;&#10;- Implement organization settings page (`+page.svelte`) for updating organization name and logo.&#10;- Create backend APIs for retrieving and updating organization details with role-based access control.&#10;- Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance navigation to dynamically display organization logo and name.">
<option name="closed" value="true" />
<created>1774712424451</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1774712424451</updated>
</task>
<option name="localTasksCounter" value="15" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@ -258,6 +277,8 @@
<MESSAGE value="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users." /> <MESSAGE value="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users." />
<MESSAGE value="Enhance real-time space status updates and handling&#10;&#10;- Improve WebSocket connection reliability with updated transport options.&#10;- Introduce logic to prioritize real-time updates over initial data for live views.&#10;- Add logging for better debugging of WebSocket events and data propagation.&#10;- Update UI components (`SpaceCard`, `FloorplanViewer`) to avoid overwrites from stale data.&#10;- Refactor date handling for consistent behavior across components." /> <MESSAGE value="Enhance real-time space status updates and handling&#10;&#10;- Improve WebSocket connection reliability with updated transport options.&#10;- Introduce logic to prioritize real-time updates over initial data for live views.&#10;- Add logging for better debugging of WebSocket events and data propagation.&#10;- Update UI components (`SpaceCard`, `FloorplanViewer`) to avoid overwrites from stale data.&#10;- Refactor date handling for consistent behavior across components." />
<MESSAGE value="Fix ownership checks and improve delete booking button UI&#10;&#10;- Update `isOwner` logic to account for nested user objects in reservation data.&#10;- Enhance delete button styling for better visibility and usability." /> <MESSAGE value="Fix ownership checks and improve delete booking button UI&#10;&#10;- Update `isOwner` logic to account for nested user objects in reservation data.&#10;- Enhance delete button styling for better visibility and usability." />
<option name="LAST_COMMIT_MESSAGE" value="Fix ownership checks and improve delete booking button UI&#10;&#10;- Update `isOwner` logic to account for nested user objects in reservation data.&#10;- Enhance delete button styling for better visibility and usability." /> <MESSAGE value="Add organization management with logo support&#10;&#10;- Introduce `OrganizationsModule` with related logic in `AppModule`.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance seeds to include default organization logo.&#10;- Update layout to dynamically display organization details, including logo and name.&#10;- Add UI support for organization-based navigation in admin routes." />
<MESSAGE value="Add organization management with logo support&#10;&#10;- Implement organization settings page (`+page.svelte`) for updating organization name and logo.&#10;- Create backend APIs for retrieving and updating organization details with role-based access control.&#10;- Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance navigation to dynamically display organization logo and name." />
<option name="LAST_COMMIT_MESSAGE" value="Add organization management with logo support&#10;&#10;- Implement organization settings page (`+page.svelte`) for updating organization name and logo.&#10;- Create backend APIs for retrieving and updating organization details with role-based access control.&#10;- Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance navigation to dynamically display organization logo and name." />
</component> </component>
</project> </project>

View File

@ -50,6 +50,7 @@
"@nestjs/testing": "^10.4.22", "@nestjs/testing": "^10.4.22",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^22.19.15", "@types/node": "^22.19.15",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",

View File

@ -1,4 +1,3 @@
// apps/api/prisma/schema.prisma
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
@ -8,6 +7,96 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model Organization {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logoUrl String?
logoId String? @unique
buildings Building[]
logo Asset? @relation(fields: [logoId], references: [id])
users User[]
}
model Building {
id String @id @default(uuid())
name String
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
description String?
imageUrl String?
imageId String? @unique
image Asset? @relation(fields: [imageId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id])
floors Floor[]
}
model Floor {
id String @id @default(uuid())
number Int
buildingId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
planImageUrl String?
name String?
planImageId String? @unique
building Building @relation(fields: [buildingId], references: [id])
planImage Asset? @relation(fields: [planImageId], references: [id])
spaces Space[]
}
model Asset {
id String @id @default(uuid())
data Bytes
mimeType String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
building Building?
floor Floor?
organization Organization?
}
model Space {
id String @id @default(uuid())
name String
type SpaceType
floorId String
x Float?
y Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
height Float?
width Float?
reservations Reservation[]
floor Floor @relation(fields: [floorId], references: [id])
}
model User {
id String @id @default(uuid())
email String @unique
password String
role Role @default(EMPLOYEE)
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reservations Reservation[]
organization Organization @relation(fields: [organizationId], references: [id])
}
model Reservation {
id String @id @default(uuid())
startTime DateTime
endTime DateTime
userId String
spaceId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
space Space @relation(fields: [spaceId], references: [id])
user User @relation(fields: [userId], references: [id])
}
enum Role { enum Role {
ADMIN ADMIN
SUPERVISOR SUPERVISOR
@ -19,76 +108,3 @@ enum SpaceType {
MEETING_ROOM MEETING_ROOM
AMENITY AMENITY
} }
model Organization {
id String @id @default(uuid())
name String
logoUrl String?
buildings Building[]
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Building {
id String @id @default(uuid())
name String
description String? @db.Text
imageUrl String?
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
floors Floor[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Floor {
id String @id @default(uuid())
name String?
number Int
building Building @relation(fields: [buildingId], references: [id])
buildingId String
planImageUrl String?
spaces Space[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Space {
id String @id @default(uuid())
name String
type SpaceType
floor Floor @relation(fields: [floorId], references: [id])
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
}
model User {
id String @id @default(uuid())
email String @unique
password String
role Role @default(EMPLOYEE)
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
reservations Reservation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Reservation {
id String @id @default(uuid())
startTime DateTime
endTime DateTime
user User @relation(fields: [userId], references: [id])
userId String
space Space @relation(fields: [spaceId], references: [id])
spaceId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -10,6 +10,7 @@ import { EventsModule } from './events/events.module';
import { ReportsModule } from './reports/reports.module'; import { ReportsModule } from './reports/reports.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { OrganizationsModule } from './organizations/organizations.module'; import { OrganizationsModule } from './organizations/organizations.module';
import { AssetsModule } from './assets/assets.module';
import { LoggerMiddleware } from './common/middleware/logger.middleware'; import { LoggerMiddleware } from './common/middleware/logger.middleware';
@Module({ @Module({
@ -23,6 +24,7 @@ import { LoggerMiddleware } from './common/middleware/logger.middleware';
ReportsModule, ReportsModule,
UsersModule, UsersModule,
OrganizationsModule, OrganizationsModule,
AssetsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@ -0,0 +1,179 @@
import {
Controller,
Get,
Post,
Param,
UseInterceptors,
UploadedFile,
Res,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
UseGuards,
ForbiddenException,
Req,
NotFoundException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { AssetsService } from './assets.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';
@Controller('assets')
export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
@Post('organization/:id/logo')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
@UseInterceptors(FileInterceptor('file'))
async uploadLogo(
@Param('id') id: string,
@Req() req,
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 10 }), // 10MB
new FileTypeValidator({ fileType: 'image/(jpeg|png|svg+xml|webp)' }),
],
}),
)
file: Express.Multer.File,
) {
if (req.user.organizationId !== id) {
throw new ForbiddenException('You can only upload logo for your own organization');
}
return this.assetsService.uploadOrganizationLogo(id, file);
}
@Post('building/:id/image')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
@UseInterceptors(FileInterceptor('file'))
async uploadBuildingImage(
@Param('id') id: string,
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 10 }), // 10MB
new FileTypeValidator({ fileType: 'image/(jpeg|png|webp)' }),
],
}),
)
file: Express.Multer.File,
) {
// In a real app, check if user belongs to the organization that owns this building
return this.assetsService.uploadBuildingImage(id, file);
}
@Post('floor/:id/plan')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
@UseInterceptors(FileInterceptor('file'))
async uploadFloorPlan(
@Param('id') id: string,
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 10 }), // 10MB
new FileTypeValidator({ fileType: 'image/(jpeg|png|svg+xml|webp)' }),
],
}),
)
file: Express.Multer.File,
) {
// In a real app, check permissions
return this.assetsService.uploadFloorPlan(id, file);
}
@Get(':id')
async getAsset(@Param('id') id: string, @Res() res: Response) {
try {
const asset = await this.assetsService.getAsset(id);
res.set({
'Content-Type': asset.mimeType || 'image/png',
'Content-Length': asset.data.length,
'Cache-Control': 'public, max-age=31536000', // 1 year cache for unique assets
});
res.end(asset.data);
} catch (error) {
if (error instanceof NotFoundException) {
res.status(404).json({
statusCode: 404,
message: 'Asset not found',
});
return;
}
throw error;
}
}
@Get('organization/:id/logo')
async getLogo(@Param('id') id: string, @Res() res: Response) {
try {
const asset = await this.assetsService.getOrganizationLogo(id);
res.set({
'Content-Type': asset.mimeType || 'image/png',
'Content-Length': asset.data.length,
'Cache-Control': 'no-cache', // Might change for the same entity ID
});
res.end(asset.data);
} catch (error) {
if (error instanceof NotFoundException) {
res.status(404).json({
statusCode: 404,
message: 'Logo not found',
});
return;
}
throw error;
}
}
@Get('building/:id/image')
async getBuildingImage(@Param('id') id: string, @Res() res: Response) {
try {
const asset = await this.assetsService.getBuildingImage(id);
res.set({
'Content-Type': asset.mimeType || 'image/png',
'Content-Length': asset.data.length,
'Cache-Control': 'no-cache',
});
res.end(asset.data);
} catch (error) {
if (error instanceof NotFoundException) {
res.status(404).json({
statusCode: 404,
message: 'Building image not found',
});
return;
}
throw error;
}
}
@Get('floor/:id/plan')
async getFloorPlan(@Param('id') id: string, @Res() res: Response) {
try {
const asset = await this.assetsService.getFloorPlan(id);
res.set({
'Content-Type': asset.mimeType || 'image/png',
'Content-Length': asset.data.length,
'Cache-Control': 'no-cache',
});
res.end(asset.data);
} catch (error) {
if (error instanceof NotFoundException) {
res.status(404).json({
statusCode: 404,
message: 'Floor plan not found',
});
return;
}
throw error;
}
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AssetsService } from './assets.service';
import { AssetsController } from './assets.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [AssetsService],
controllers: [AssetsController],
})
export class AssetsModule {}

View File

@ -0,0 +1,111 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AssetsService {
constructor(private prisma: PrismaService) {}
async uploadOrganizationLogo(id: string, file: Express.Multer.File) {
const asset = await this.prisma.asset.create({
data: {
data: Buffer.from(file.buffer),
mimeType: file.mimetype,
},
});
return this.prisma.organization.update({
where: { id },
data: {
logoId: asset.id,
logoUrl: `/assets/${asset.id}`,
},
});
}
async deleteOrganizationLogo(id: string) {
return this.prisma.organization.update({
where: { id },
data: {
logoId: null,
logoUrl: null,
},
});
}
async uploadBuildingImage(id: string, file: Express.Multer.File) {
const asset = await this.prisma.asset.create({
data: {
data: Buffer.from(file.buffer),
mimeType: file.mimetype,
},
});
return this.prisma.building.update({
where: { id },
data: {
imageId: asset.id,
imageUrl: `/assets/${asset.id}`,
},
});
}
async uploadFloorPlan(id: string, file: Express.Multer.File) {
const asset = await this.prisma.asset.create({
data: {
data: Buffer.from(file.buffer),
mimeType: file.mimetype,
},
});
return this.prisma.floor.update({
where: { id },
data: {
planImageId: asset.id,
planImageUrl: `/assets/${asset.id}`,
},
});
}
async getOrganizationLogo(id: string) {
const org = await this.prisma.organization.findUnique({
where: { id },
include: { logo: true },
});
if (!org || !org.logo) {
throw new NotFoundException('Logo not found');
}
return org.logo;
}
async getAsset(id: string) {
const asset = await this.prisma.asset.findUnique({
where: { id },
});
if (!asset) {
throw new NotFoundException('Asset not found');
}
return asset;
}
async getBuildingImage(id: string) {
const building = await this.prisma.building.findUnique({
where: { id },
include: { image: true },
});
if (!building || !building.image) {
throw new NotFoundException('Building image not found');
}
return building.image;
}
async getFloorPlan(id: string) {
const floor = await this.prisma.floor.findUnique({
where: { id },
include: { planImage: true },
});
if (!floor || !floor.planImage) {
throw new NotFoundException('Floor plan not found');
}
return floor.planImage;
}
}

View File

@ -6,6 +6,7 @@ export class UpdateOrganizationDto {
name?: string; name?: string;
@IsOptional() @IsOptional()
@IsUrl() // @IsUrl()
@IsString()
logoUrl?: string | null; logoUrl?: string | null;
} }

View File

@ -17,9 +17,18 @@ export class OrganizationsService {
} }
async update(id: string, updateOrganizationDto: UpdateOrganizationDto) { async update(id: string, updateOrganizationDto: UpdateOrganizationDto) {
const data: any = { ...updateOrganizationDto };
// If logoUrl is being set to something that is not an internal asset, clear logoId
if (updateOrganizationDto.logoUrl !== undefined) {
if (updateOrganizationDto.logoUrl === null || updateOrganizationDto.logoUrl === '' || !updateOrganizationDto.logoUrl.startsWith('/assets/')) {
data.logoId = null;
}
}
return this.prisma.organization.update({ return this.prisma.organization.update({
where: { id }, where: { id },
data: updateOrganizationDto, data,
}); });
} }
} }

View File

@ -9,7 +9,7 @@ export class CreateBuildingDto {
description?: string; description?: string;
@IsOptional() @IsOptional()
@IsUrl() // @IsUrl()
@IsString() @IsString()
imageUrl?: string | null; imageUrl?: string | null;
} }
@ -24,7 +24,7 @@ export class UpdateBuildingDto {
description?: string; description?: string;
@IsOptional() @IsOptional()
@IsUrl() // @IsUrl()
@IsString() @IsString()
imageUrl?: string | null; imageUrl?: string | null;
} }

View File

@ -77,7 +77,16 @@ export class SpacesService {
}); });
} }
async updateBuilding(id: string, data: any) { async updateBuilding(id: string, dto: any) {
const data: any = { ...dto };
// If imageUrl is being set to something that is not an internal asset, clear imageId
if (dto.imageUrl !== undefined) {
if (dto.imageUrl === null || dto.imageUrl === '' || !dto.imageUrl.startsWith('/assets/')) {
data.imageId = null;
}
}
return this.prisma.building.update({ return this.prisma.building.update({
where: { id }, where: { id },
data, data,
@ -102,7 +111,16 @@ export class SpacesService {
}); });
} }
async updateFloor(id: string, data: any) { async updateFloor(id: string, dto: any) {
const data: any = { ...dto };
// If planImageUrl is being set to something that is not an internal asset, clear planImageId
if (dto.planImageUrl !== undefined) {
if (dto.planImageUrl === null || dto.planImageUrl === '' || !dto.planImageUrl.startsWith('/assets/')) {
data.planImageId = null;
}
}
return this.prisma.floor.update({ return this.prisma.floor.update({
where: { id }, where: { id },
data, data,
@ -116,9 +134,16 @@ export class SpacesService {
} }
async updateFloorPlan(floorId: string, planImageUrl: string) { async updateFloorPlan(floorId: string, planImageUrl: string) {
const data: any = { planImageUrl };
// If planImageUrl is being set to something that is not an internal asset, clear planImageId
if (planImageUrl === null || planImageUrl === '' || !planImageUrl.startsWith('/assets/')) {
data.planImageId = null;
}
return this.prisma.floor.update({ return this.prisma.floor.update({
where: { id: floorId }, where: { id: floorId },
data: { planImageUrl }, data,
}); });
} }

View File

@ -7,10 +7,14 @@ export async function apiFetch<T>(
const fetchFn = options.fetch ?? fetch; const fetchFn = options.fetch ?? fetch;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> ?? {}), ...(options.headers as Record<string, string> ?? {}),
}; };
// Only set Content-Type to application/json if it's not already set and it's not a multipart request
if (!headers['Content-Type'] && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
// Automatically add Authorization header if token exists in localStorage (browser) // Automatically add Authorization header if token exists in localStorage (browser)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');

View File

@ -3,6 +3,7 @@
import { io, Socket } from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
import { Laptop, Bath, Coffee, Users, AlertCircle } from 'lucide-svelte'; import { Laptop, Bath, Coffee, Users, AlertCircle } from 'lucide-svelte';
import BookingModal from './BookingModal.svelte'; import BookingModal from './BookingModal.svelte';
import { resolveAssetUrl } from '$lib/utils/url';
export let floorId: string; export let floorId: string;
export let spaces: any[] = []; export let spaces: any[] = [];
@ -141,7 +142,7 @@
<div <div
bind:this={container} bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden border mx-auto floorplan-container" class="relative bg-white shadow-xl rounded-lg overflow-hidden border mx-auto floorplan-container"
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({planImageUrl}); background-size: cover; background-repeat: no-repeat; background-position: center;" style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({resolveAssetUrl(planImageUrl)}); background-size: cover; background-repeat: no-repeat; background-position: center;"
> >
{#each spaces as space (space.id)} {#each spaces as space (space.id)}
{@const status = spaceStatus[space.id] || { isOccupied: false }} {@const status = spaceStatus[space.id] || { isOccupied: false }}
@ -160,7 +161,7 @@
{#if status.isOccupied && space.type === 'DESK'} {#if status.isOccupied && space.type === 'DESK'}
<div class="absolute -top-4 -left-4 w-6 h-6 rounded-full bg-white border border-yellow-500 overflow-hidden shadow-sm flex items-center justify-center"> <div class="absolute -top-4 -left-4 w-6 h-6 rounded-full bg-white border border-yellow-500 overflow-hidden shadow-sm flex items-center justify-center">
{#if status.reservation?.user?.profilePicture} {#if status.reservation?.user?.profilePicture}
<img src={status.reservation.user.profilePicture} alt={status.reservation.user.email} class="w-full h-full object-cover" /> <img src={resolveAssetUrl(status.reservation.user.profilePicture)} alt={status.reservation.user.email} class="w-full h-full object-cover" />
{:else} {:else}
<span class="text-[8px] font-bold text-yellow-700"> <span class="text-[8px] font-bold text-yellow-700">
{status.reservation?.user?.email?.charAt(0).toUpperCase()} {status.reservation?.user?.email?.charAt(0).toUpperCase()}

View File

@ -2,6 +2,7 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { X, Plus, Trash2, Building2 } from 'lucide-svelte'; import { X, Plus, Trash2, Building2 } from 'lucide-svelte';
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
import { resolveAssetUrl } from '$lib/utils/url';
export let building: any = null; // if null, we are creating a new building export let building: any = null; // if null, we are creating a new building
export let show = false; export let show = false;
@ -11,11 +12,15 @@
let name = building?.name || ''; let name = building?.name || '';
let description = building?.description || ''; let description = building?.description || '';
let imageUrl = building?.imageUrl || ''; let imageUrl = building?.imageUrl || '';
let imageFile: File | null = null;
let fileInput: HTMLInputElement;
let floors = building?.floors ? JSON.parse(JSON.stringify(building.floors)) : []; let floors = building?.floors ? JSON.parse(JSON.stringify(building.floors)) : [];
let loading = false; let loading = false;
let error = ''; let error = '';
let showDangerZone = false; let showDangerZone = false;
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
$: { $: {
if (show && building) { if (show && building) {
name = building.name; name = building.name;
@ -26,22 +31,62 @@
name = ''; name = '';
description = ''; description = '';
imageUrl = ''; imageUrl = '';
imageFile = null;
floors = []; floors = [];
} }
} }
function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
error = "File size exceeds 10MB limit";
target.value = "";
return;
}
imageFile = file;
// If a file is selected, clear the imageUrl to avoid ambiguity
imageUrl = "";
error = "";
}
}
function handleUrlChange() {
// If the user starts typing a URL, clear the file selection
if (imageFile) {
imageFile = null;
if (fileInput) {
fileInput.value = "";
}
}
}
// Reactive preview URL
$: previewUrl = imageFile ? URL.createObjectURL(imageFile) : resolveAssetUrl(imageUrl);
async function saveBuilding() { async function saveBuilding() {
loading = true; loading = true;
error = ''; error = '';
try { try {
let savedBuilding; let savedBuilding;
if (building) { if (building) {
if (imageFile) {
const formData = new FormData();
formData.append('file', imageFile);
const assetResponse = await apiFetch<any>(`/assets/building/${building.id}/image`, {
method: 'POST',
body: formData,
});
imageUrl = assetResponse.imageUrl;
}
savedBuilding = await apiFetch(`/spaces/buildings/${building.id}`, { savedBuilding = await apiFetch(`/spaces/buildings/${building.id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ body: JSON.stringify({
name, name,
description: description || null, description: description || null,
imageUrl: imageUrl || null imageUrl: imageUrl || ""
}) })
}); });
@ -60,15 +105,32 @@
} }
} }
} else { } else {
savedBuilding = await apiFetch('/spaces/buildings', { savedBuilding = await apiFetch<any>('/spaces/buildings', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name, name,
description: description || null, description: description || null,
imageUrl: imageUrl || null imageUrl: imageUrl || ""
}) })
}); });
if (imageFile) {
const formData = new FormData();
formData.append('file', imageFile);
const assetResponse = await apiFetch<any>(`/assets/building/${savedBuilding.id}/image`, {
method: 'POST',
body: formData,
});
// Re-update the building with the new image URL returned from asset upload
savedBuilding = await apiFetch(`/spaces/buildings/${savedBuilding.id}`, {
method: 'PATCH',
body: JSON.stringify({
imageUrl: assetResponse.imageUrl
})
});
}
// Add floors if any were defined during creation // Add floors if any were defined during creation
for (const floor of floors) { for (const floor of floors) {
await apiFetch(`/spaces/buildings/${savedBuilding.id}/floors`, { await apiFetch(`/spaces/buildings/${savedBuilding.id}/floors`, {
@ -78,6 +140,7 @@
} }
} }
imageFile = null;
dispatch('save'); dispatch('save');
close(); close();
} catch (e: any) { } catch (e: any) {
@ -173,11 +236,25 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Header Image URL</label> <label class="block text-sm font-medium text-gray-700 mb-1">Building Image</label>
<div class="mt-1 flex items-center space-x-4">
<input
type="file"
accept="image/*"
bind:this={fileInput}
on:change={handleFileChange}
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
/>
</div>
<p class="mt-2 text-sm text-gray-500">Upload an image for this building (max 10MB).</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Header Image URL (Optional)</label>
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<div class="w-16 h-16 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center overflow-hidden"> <div class="w-16 h-16 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center overflow-hidden">
{#if imageUrl} {#if previewUrl}
<img src={imageUrl} alt="Preview" class="w-full h-full object-cover" /> <img src={previewUrl} alt="Preview" class="w-full h-full object-cover" />
{:else} {:else}
<Building2 class="text-gray-400" size={32} /> <Building2 class="text-gray-400" size={32} />
{/if} {/if}
@ -185,6 +262,7 @@
<input <input
type="text" type="text"
bind:value={imageUrl} bind:value={imageUrl}
on:input={handleUrlChange}
placeholder="https://images.unsplash.com/..." placeholder="https://images.unsplash.com/..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
/> />

View File

@ -3,6 +3,7 @@
import interact from 'interactjs'; import interact from 'interactjs';
import { Laptop, Bath, Coffee, Users, Save, Upload } from 'lucide-svelte'; import { Laptop, Bath, Coffee, Users, Save, Upload } from 'lucide-svelte';
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
import { resolveAssetUrl } from '$lib/utils/url';
export let floorId: string; export let floorId: string;
export let initialSpaces: any[] = []; export let initialSpaces: any[] = [];
@ -228,24 +229,29 @@
const file = target.files?.[0]; const file = target.files?.[0];
if (!file) return; if (!file) return;
// In a real app, we would upload to S3/Cloudinary and get a URL if (file.size > 10 * 1024 * 1024) {
// For this stage, we'll use a data URL as a placeholder or mock the upload alert("File size exceeds 10MB limit");
const reader = new FileReader(); target.value = "";
reader.onload = async (e) => { return;
const url = e.target?.result as string; }
planImageUrl = url;
const formData = new FormData();
// Save to backend formData.append('file', file);
try {
await apiFetch(`/spaces/floor/${floorId}/plan`, { try {
method: 'PATCH', // Upload binary asset
body: JSON.stringify({ planImageUrl: url }) const updatedFloor = await apiFetch<any>(`/assets/floor/${floorId}/plan`, {
}); method: 'POST',
} catch (err) { body: formData
console.error('Failed to update floor plan', err); });
// Update local plan image URL (which should be the served asset URL)
planImageUrl = updatedFloor.planImageUrl;
alert('Floorplan uploaded successfully!');
} catch (err: any) {
console.error('Failed to upload floor plan', err);
alert(err.message || 'Failed to upload floor plan');
} }
};
reader.readAsDataURL(file);
} }
async function saveLayout() { async function saveLayout() {
@ -350,7 +356,7 @@
<div <div
bind:this={container} bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden floorplan-container" class="relative bg-white shadow-xl rounded-lg overflow-hidden floorplan-container"
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({planImageUrl}); background-size: cover; background-repeat: no-repeat; background-position: center;" style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({resolveAssetUrl(planImageUrl)}); background-size: cover; background-repeat: no-repeat; background-position: center;"
> >
{#if !planImageUrl} {#if !planImageUrl}
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none"> <div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none">

View File

@ -0,0 +1,15 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
export function resolveAssetUrl(url: string | null | undefined): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('blob:') || url.startsWith('data:')) {
return url;
}
// If it starts with /assets/, prepend API_BASE_URL
if (url.startsWith('/assets/')) {
// Check if it's already a full URL (though startsWith /assets/ implies relative)
return `${API_BASE_URL}${url}`;
}
// Otherwise, return as is (could be a legacy relative path or something else)
return url;
}

View File

@ -3,6 +3,7 @@
import { token, user } from "$lib/stores/auth"; import { token, user } from "$lib/stores/auth";
import { organization, loadOrganization } from "$lib/stores/organization"; import { organization, loadOrganization } from "$lib/stores/organization";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { resolveAssetUrl } from "$lib/utils/url";
onMount(async () => { onMount(async () => {
if ($user?.organizationId) { if ($user?.organizationId) {
@ -19,7 +20,7 @@
<nav class="bg-white shadow-sm px-4 py-3 flex justify-between items-center"> <nav class="bg-white shadow-sm px-4 py-3 flex justify-between items-center">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
{#if $organization?.logoUrl} {#if $organization?.logoUrl}
<img src={$organization.logoUrl} alt="Logo" class="w-8 h-8 rounded-lg object-contain" /> <img src={resolveAssetUrl($organization.logoUrl)} alt="Logo" class="w-8 h-8 rounded-lg object-contain" />
{:else} {:else}
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center"> <div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
<span class="text-white font-bold">{$organization?.name?.charAt(0) || 'H'}</span> <span class="text-white font-bold">{$organization?.name?.charAt(0) || 'H'}</span>

View File

@ -3,13 +3,20 @@
import { user } from "$lib/stores/auth"; import { user } from "$lib/stores/auth";
import { apiFetch } from "$lib/api/client"; import { apiFetch } from "$lib/api/client";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { resolveAssetUrl } from "$lib/utils/url";
let name = ""; let name = "";
let logoUrl = ""; let logoUrl = "";
let logoFile: File | null = null;
let loading = false; let loading = false;
let success = false; let success = false;
let error = ""; let error = "";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
let logoUrlInput: HTMLInputElement;
let logoFileInput: HTMLInputElement;
onMount(async () => { onMount(async () => {
if ($user?.organizationId) { if ($user?.organizationId) {
await loadOrganization($user.organizationId); await loadOrganization($user.organizationId);
@ -25,12 +32,26 @@
success = false; success = false;
error = ""; error = "";
try { try {
let finalLogoUrl = logoUrl;
if (logoFile) {
const formData = new FormData();
formData.append('file', logoFile);
const assetResponse = await apiFetch<any>(`/assets/organization/${$user?.organizationId}/logo`, {
method: 'POST',
body: formData,
});
// Update local logoUrl with the actual URL from the backend
finalLogoUrl = assetResponse.logoUrl;
logoUrl = finalLogoUrl;
}
const updated = await apiFetch<any>(`/organizations/${$user?.organizationId}`, { const updated = await apiFetch<any>(`/organizations/${$user?.organizationId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ name, logoUrl: logoUrl || null }), body: JSON.stringify({ name, logoUrl: finalLogoUrl || "" }),
}); });
organization.set(updated); organization.set(updated);
success = true; success = true;
logoFile = null;
} catch (e: any) { } catch (e: any) {
error = e.message || "Failed to update organization"; error = e.message || "Failed to update organization";
} finally { } finally {
@ -38,8 +59,38 @@
} }
} }
function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
error = "File size exceeds 10MB limit";
target.value = "";
return;
}
logoFile = file;
// If a file is selected, clear the logoUrl to avoid ambiguity
logoUrl = "";
error = "";
}
}
function handleUrlChange() {
// If the user starts typing a URL, clear the file selection
if (logoFile) {
logoFile = null;
if (logoFileInput) {
logoFileInput.value = "";
}
}
}
// Reactive preview URL
$: previewUrl = logoFile ? URL.createObjectURL(logoFile) : resolveAssetUrl(logoUrl);
function handleRemoveLogo() { function handleRemoveLogo() {
logoUrl = ""; logoUrl = "";
logoFile = null;
} }
</script> </script>
@ -61,18 +112,34 @@
</div> </div>
<div> <div>
<label for="logoUrl" class="block text-sm font-medium text-gray-700">Logo URL</label> <label for="logoFile" class="block text-sm font-medium text-gray-700">Logo</label>
<div class="mt-1 flex items-center space-x-4">
<input
id="logoFile"
type="file"
accept="image/*"
bind:this={logoFileInput}
on:change={handleFileChange}
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
/>
</div>
<p class="mt-2 text-sm text-gray-500">Upload a logo for your organization (max 10MB).</p>
</div>
<div>
<label for="logoUrl" class="block text-sm font-medium text-gray-700">Logo URL (Optional)</label>
<input <input
id="logoUrl" id="logoUrl"
type="url" type="url"
bind:value={logoUrl} bind:value={logoUrl}
on:input={handleUrlChange}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="https://example.com/logo.png" placeholder="https://example.com/logo.png"
/> />
<p class="mt-2 text-sm text-gray-500">Provide a URL for your organization's logo.</p> <p class="mt-2 text-sm text-gray-500">Or provide a URL for your organization's logo.</p>
</div> </div>
{#if logoUrl} {#if previewUrl}
<div class="mt-4"> <div class="mt-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-gray-700">Logo Preview:</p> <p class="text-sm font-medium text-gray-700">Logo Preview:</p>
@ -85,7 +152,7 @@
</button> </button>
</div> </div>
<div class="w-20 h-20 border rounded-lg flex items-center justify-center overflow-hidden bg-gray-50"> <div class="w-20 h-20 border rounded-lg flex items-center justify-center overflow-hidden bg-gray-50">
<img src={logoUrl} alt="Logo preview" class="max-w-full max-h-full object-contain" /> <img src={previewUrl} alt="Logo preview" class="max-w-full max-h-full object-contain" />
</div> </div>
</div> </div>
{/if} {/if}

View File

@ -5,6 +5,7 @@
import { apiFetch } from "$lib/api/client"; import { apiFetch } from "$lib/api/client";
import { Building2, ArrowRight, Plus, Edit2 } from "lucide-svelte"; import { Building2, ArrowRight, Plus, Edit2 } from "lucide-svelte";
import BuildingEditorModal from "$lib/components/admin/BuildingEditorModal.svelte"; import BuildingEditorModal from "$lib/components/admin/BuildingEditorModal.svelte";
import { resolveAssetUrl } from "$lib/utils/url";
let buildings = []; let buildings = [];
let loading = true; let loading = true;
@ -71,7 +72,7 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow group relative"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow group relative">
{#if building.imageUrl} {#if building.imageUrl}
<div class="h-32 w-full overflow-hidden"> <div class="h-32 w-full overflow-hidden">
<img src={building.imageUrl} alt={building.name} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" /> <img src={resolveAssetUrl(building.imageUrl)} alt={building.name} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div> </div>
{/if} {/if}

10
package-lock.json generated
View File

@ -44,6 +44,7 @@
"@nestjs/testing": "^10.4.22", "@nestjs/testing": "^10.4.22",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^22.19.15", "@types/node": "^22.19.15",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",
@ -3519,6 +3520,15 @@
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
}, },
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"dev": true,
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.15", "version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",