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
parent
b618c10cdd
commit
2bab9da7c8
|
|
@ -4,12 +4,23 @@
|
|||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Fix ownership checks and improve delete booking button UI - Update `isOwner` logic to account for nested user objects in reservation data. - 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 - Implement organization settings page (`+page.svelte`) for updating organization name and logo. - Create backend APIs for retrieving and updating organization details with role-based access control. - Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS. - Update Prisma schema to include `logoUrl` field for organizations. - 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$/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/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/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>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
|
|
@ -131,7 +142,7 @@
|
|||
<updated>1774643312599</updated>
|
||||
<workItem from="1774643313611" duration="74000" />
|
||||
<workItem from="1774643422240" duration="5278000" />
|
||||
<workItem from="1774706361502" duration="5853000" />
|
||||
<workItem from="1774706361502" duration="7821000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services - Set up SvelteKit for the web interface with Tailwind CSS. - Build the API using NestJS with Prisma ORM for database interaction. - Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. - Create shared data models for consistent typing across services. - Establish basic routing and layouts for web and API services.">
|
||||
<option name="closed" value="true" />
|
||||
|
|
@ -237,7 +248,15 @@
|
|||
<option name="project" value="LOCAL" />
|
||||
<updated>1774711743510</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="14" />
|
||||
<task id="LOCAL-00014" summary="Add organization management with logo support - Implement organization settings page (`+page.svelte`) for updating organization name and logo. - Create backend APIs for retrieving and updating organization details with role-based access control. - Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS. - Update Prisma schema to include `logoUrl` field for organizations. - 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 />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
|
|
@ -258,6 +277,8 @@
|
|||
<MESSAGE value="Add building and floor management with admin editor modal - Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors. - Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation. - Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations. - Update Prisma schema to include building descriptions and image URLs, and floor names. - Enhance dashboard with building management functionality for admin users." />
|
||||
<MESSAGE value="Enhance real-time space status updates and handling - Improve WebSocket connection reliability with updated transport options. - Introduce logic to prioritize real-time updates over initial data for live views. - Add logging for better debugging of WebSocket events and data propagation. - Update UI components (`SpaceCard`, `FloorplanViewer`) to avoid overwrites from stale data. - Refactor date handling for consistent behavior across components." />
|
||||
<MESSAGE value="Fix ownership checks and improve delete booking button UI - Update `isOwner` logic to account for nested user objects in reservation data. - Enhance delete button styling for better visibility and usability." />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Fix ownership checks and improve delete booking button UI - Update `isOwner` logic to account for nested user objects in reservation data. - Enhance delete button styling for better visibility and usability." />
|
||||
<MESSAGE value="Add organization management with logo support - Introduce `OrganizationsModule` with related logic in `AppModule`. - Update Prisma schema to include `logoUrl` field for organizations. - Enhance seeds to include default organization logo. - Update layout to dynamically display organization details, including logo and name. - Add UI support for organization-based navigation in admin routes." />
|
||||
<MESSAGE value="Add organization management with logo support - Implement organization settings page (`+page.svelte`) for updating organization name and logo. - Create backend APIs for retrieving and updating organization details with role-based access control. - Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS. - Update Prisma schema to include `logoUrl` field for organizations. - Enhance navigation to dynamically display organization logo and name." />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Add organization management with logo support - Implement organization settings page (`+page.svelte`) for updating organization name and logo. - Create backend APIs for retrieving and updating organization details with role-based access control. - Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS. - Update Prisma schema to include `logoUrl` field for organizations. - Enhance navigation to dynamically display organization logo and name." />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -50,6 +50,7 @@
|
|||
"@nestjs/testing": "^10.4.22",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.19.15",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// apps/api/prisma/schema.prisma
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
|
@ -8,6 +7,96 @@ datasource db {
|
|||
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 {
|
||||
ADMIN
|
||||
SUPERVISOR
|
||||
|
|
@ -19,76 +108,3 @@ enum SpaceType {
|
|||
MEETING_ROOM
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { EventsModule } from './events/events.module';
|
|||
import { ReportsModule } from './reports/reports.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { OrganizationsModule } from './organizations/organizations.module';
|
||||
import { AssetsModule } from './assets/assets.module';
|
||||
import { LoggerMiddleware } from './common/middleware/logger.middleware';
|
||||
|
||||
@Module({
|
||||
|
|
@ -23,6 +24,7 @@ import { LoggerMiddleware } from './common/middleware/logger.middleware';
|
|||
ReportsModule,
|
||||
UsersModule,
|
||||
OrganizationsModule,
|
||||
AssetsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export class UpdateOrganizationDto {
|
|||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
// @IsUrl()
|
||||
@IsString()
|
||||
logoUrl?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,18 @@ export class OrganizationsService {
|
|||
}
|
||||
|
||||
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({
|
||||
where: { id },
|
||||
data: updateOrganizationDto,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export class CreateBuildingDto {
|
|||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
// @IsUrl()
|
||||
@IsString()
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ export class UpdateBuildingDto {
|
|||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
// @IsUrl()
|
||||
@IsString()
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
where: { id },
|
||||
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({
|
||||
where: { id },
|
||||
data,
|
||||
|
|
@ -116,9 +134,16 @@ export class SpacesService {
|
|||
}
|
||||
|
||||
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({
|
||||
where: { id: floorId },
|
||||
data: { planImageUrl },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,14 @@ export async function apiFetch<T>(
|
|||
const fetchFn = options.fetch ?? fetch;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(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)
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('token');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { io, Socket } from 'socket.io-client';
|
||||
import { Laptop, Bath, Coffee, Users, AlertCircle } from 'lucide-svelte';
|
||||
import BookingModal from './BookingModal.svelte';
|
||||
import { resolveAssetUrl } from '$lib/utils/url';
|
||||
|
||||
export let floorId: string;
|
||||
export let spaces: any[] = [];
|
||||
|
|
@ -141,7 +142,7 @@
|
|||
<div
|
||||
bind:this={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)}
|
||||
{@const status = spaceStatus[space.id] || { isOccupied: false }}
|
||||
|
|
@ -160,7 +161,7 @@
|
|||
{#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">
|
||||
{#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}
|
||||
<span class="text-[8px] font-bold text-yellow-700">
|
||||
{status.reservation?.user?.email?.charAt(0).toUpperCase()}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { createEventDispatcher } from 'svelte';
|
||||
import { X, Plus, Trash2, Building2 } from 'lucide-svelte';
|
||||
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 show = false;
|
||||
|
|
@ -11,11 +12,15 @@
|
|||
let name = building?.name || '';
|
||||
let description = building?.description || '';
|
||||
let imageUrl = building?.imageUrl || '';
|
||||
let imageFile: File | null = null;
|
||||
let fileInput: HTMLInputElement;
|
||||
let floors = building?.floors ? JSON.parse(JSON.stringify(building.floors)) : [];
|
||||
let loading = false;
|
||||
let error = '';
|
||||
let showDangerZone = false;
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
$: {
|
||||
if (show && building) {
|
||||
name = building.name;
|
||||
|
|
@ -26,22 +31,62 @@
|
|||
name = '';
|
||||
description = '';
|
||||
imageUrl = '';
|
||||
imageFile = null;
|
||||
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() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
let savedBuilding;
|
||||
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}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: description || null,
|
||||
imageUrl: imageUrl || null
|
||||
imageUrl: imageUrl || ""
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -60,15 +105,32 @@
|
|||
}
|
||||
}
|
||||
} else {
|
||||
savedBuilding = await apiFetch('/spaces/buildings', {
|
||||
savedBuilding = await apiFetch<any>('/spaces/buildings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
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
|
||||
for (const floor of floors) {
|
||||
await apiFetch(`/spaces/buildings/${savedBuilding.id}/floors`, {
|
||||
|
|
@ -78,6 +140,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
imageFile = null;
|
||||
dispatch('save');
|
||||
close();
|
||||
} catch (e: any) {
|
||||
|
|
@ -173,11 +236,25 @@
|
|||
</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="w-16 h-16 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||
{#if imageUrl}
|
||||
<img src={imageUrl} alt="Preview" class="w-full h-full object-cover" />
|
||||
{#if previewUrl}
|
||||
<img src={previewUrl} alt="Preview" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<Building2 class="text-gray-400" size={32} />
|
||||
{/if}
|
||||
|
|
@ -185,6 +262,7 @@
|
|||
<input
|
||||
type="text"
|
||||
bind:value={imageUrl}
|
||||
on:input={handleUrlChange}
|
||||
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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import interact from 'interactjs';
|
||||
import { Laptop, Bath, Coffee, Users, Save, Upload } from 'lucide-svelte';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { resolveAssetUrl } from '$lib/utils/url';
|
||||
|
||||
export let floorId: string;
|
||||
export let initialSpaces: any[] = [];
|
||||
|
|
@ -228,24 +229,29 @@
|
|||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// In a real app, we would upload to S3/Cloudinary and get a URL
|
||||
// For this stage, we'll use a data URL as a placeholder or mock the upload
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const url = e.target?.result as string;
|
||||
planImageUrl = url;
|
||||
|
||||
// Save to backend
|
||||
try {
|
||||
await apiFetch(`/spaces/floor/${floorId}/plan`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ planImageUrl: url })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to update floor plan', err);
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert("File size exceeds 10MB limit");
|
||||
target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
// Upload binary asset
|
||||
const updatedFloor = await apiFetch<any>(`/assets/floor/${floorId}/plan`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
// 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() {
|
||||
|
|
@ -350,7 +356,7 @@
|
|||
<div
|
||||
bind:this={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}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { token, user } from "$lib/stores/auth";
|
||||
import { organization, loadOrganization } from "$lib/stores/organization";
|
||||
import { onMount } from "svelte";
|
||||
import { resolveAssetUrl } from "$lib/utils/url";
|
||||
|
||||
onMount(async () => {
|
||||
if ($user?.organizationId) {
|
||||
|
|
@ -19,7 +20,7 @@
|
|||
<nav class="bg-white shadow-sm px-4 py-3 flex justify-between items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
{#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}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,20 @@
|
|||
import { user } from "$lib/stores/auth";
|
||||
import { apiFetch } from "$lib/api/client";
|
||||
import { onMount } from "svelte";
|
||||
import { resolveAssetUrl } from "$lib/utils/url";
|
||||
|
||||
let name = "";
|
||||
let logoUrl = "";
|
||||
let logoFile: File | null = null;
|
||||
let loading = false;
|
||||
let success = false;
|
||||
let error = "";
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
let logoUrlInput: HTMLInputElement;
|
||||
let logoFileInput: HTMLInputElement;
|
||||
|
||||
onMount(async () => {
|
||||
if ($user?.organizationId) {
|
||||
await loadOrganization($user.organizationId);
|
||||
|
|
@ -25,12 +32,26 @@
|
|||
success = false;
|
||||
error = "";
|
||||
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}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name, logoUrl: logoUrl || null }),
|
||||
body: JSON.stringify({ name, logoUrl: finalLogoUrl || "" }),
|
||||
});
|
||||
organization.set(updated);
|
||||
success = true;
|
||||
logoFile = null;
|
||||
} catch (e: any) {
|
||||
error = e.message || "Failed to update organization";
|
||||
} 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() {
|
||||
logoUrl = "";
|
||||
logoFile = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -61,18 +112,34 @@
|
|||
</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
|
||||
id="logoUrl"
|
||||
type="url"
|
||||
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"
|
||||
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>
|
||||
|
||||
{#if logoUrl}
|
||||
{#if previewUrl}
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-medium text-gray-700">Logo Preview:</p>
|
||||
|
|
@ -85,7 +152,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<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>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { apiFetch } from "$lib/api/client";
|
||||
import { Building2, ArrowRight, Plus, Edit2 } from "lucide-svelte";
|
||||
import BuildingEditorModal from "$lib/components/admin/BuildingEditorModal.svelte";
|
||||
import { resolveAssetUrl } from "$lib/utils/url";
|
||||
|
||||
let buildings = [];
|
||||
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">
|
||||
{#if building.imageUrl}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
"@nestjs/testing": "^10.4.22",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.19.15",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
|
|
@ -3519,6 +3520,15 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"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": {
|
||||
"version": "22.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||
|
|
|
|||
Loading…
Reference in New Issue