Add user profile management with avatar upload and update support
- Create `+page.svelte` for managing user profiles, including name, avatar, and password updates. - Add `Avatar.svelte` component for rendering user avatars with Gravatar and fallback initials support. - Implement backend APIs for fetching and updating user profiles (`/users/profile`). - Add support for avatar uploads with file validation (max size: 5MB). - Update `FloorplanViewer.svelte` to display user names and avatars in reservations. - Introduce utilities for Gravatar URLs and user avatar management (`getGravatarUrl`, `getInitials`).main
parent
2bab9da7c8
commit
e80984faa1
|
|
@ -4,22 +4,17 @@
|
|||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<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.">
|
||||
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="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.">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="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/api/src/auth/auth.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/auth/auth.service.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/api/src/users/dto/update-user.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/users/dto/update-user.dto.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/api/src/users/users.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/users/users.controller.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/api/src/users/users.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/users/users.service.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/apps/web/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/package.json" 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" />
|
||||
|
|
@ -142,7 +137,7 @@
|
|||
<updated>1774643312599</updated>
|
||||
<workItem from="1774643313611" duration="74000" />
|
||||
<workItem from="1774643422240" duration="5278000" />
|
||||
<workItem from="1774706361502" duration="7821000" />
|
||||
<workItem from="1774706361502" duration="8461000" />
|
||||
</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" />
|
||||
|
|
@ -256,7 +251,15 @@
|
|||
<option name="project" value="LOCAL" />
|
||||
<updated>1774712424451</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="15" />
|
||||
<task id="LOCAL-00015" summary="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.">
|
||||
<option name="closed" value="true" />
|
||||
<created>1774714222515</created>
|
||||
<option name="number" value="00015" />
|
||||
<option name="presentableId" value="LOCAL-00015" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1774714222515</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="16" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
|
|
@ -279,6 +282,7 @@
|
|||
<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." />
|
||||
<MESSAGE value="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." />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="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." />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -55,7 +55,8 @@ model Asset {
|
|||
updatedAt DateTime @updatedAt
|
||||
building Building?
|
||||
floor Floor?
|
||||
organization Organization?
|
||||
organization Organization?
|
||||
user User?
|
||||
}
|
||||
|
||||
model Space {
|
||||
|
|
@ -77,12 +78,16 @@ model User {
|
|||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
password String
|
||||
name String?
|
||||
avatarUrl String?
|
||||
avatarId String? @unique
|
||||
role Role @default(EMPLOYEE)
|
||||
organizationId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
reservations Reservation[]
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
avatar Asset? @relation(fields: [avatarId], references: [id])
|
||||
}
|
||||
|
||||
model Reservation {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,24 @@ export class AssetsController {
|
|||
return this.assetsService.uploadFloorPlan(id, file);
|
||||
}
|
||||
|
||||
@Post('user/avatar')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadUserAvatar(
|
||||
@Req() req,
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 5 }), // 5MB
|
||||
new FileTypeValidator({ fileType: 'image/(jpeg|png|webp)' }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
return this.assetsService.uploadUserAvatar(req.user.id, file);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getAsset(@Param('id') id: string, @Res() res: Response) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,23 @@ export class AssetsService {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
async uploadUserAvatar(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.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
avatarId: asset.id,
|
||||
avatarUrl: `/assets/${asset.id}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationLogo(id: string) {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ export class AuthService {
|
|||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarId: user.avatarId,
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ export class ReservationsService {
|
|||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
avatarId: true,
|
||||
role: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -236,6 +239,9 @@ export class ReservationsService {
|
|||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
avatarId: true,
|
||||
role: true,
|
||||
}
|
||||
},
|
||||
|
|
@ -282,6 +288,9 @@ export class ReservationsService {
|
|||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
avatarId: true,
|
||||
role: true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validato
|
|||
import { Role } from '@prisma/client';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatarUrl?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
|
|
|||
|
|
@ -8,26 +8,39 @@ import { UpdateUserDto } from './dto/update-user.dto';
|
|||
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(Role.ADMIN)
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get('profile')
|
||||
getProfile(@Req() req) {
|
||||
return this.usersService.findOne(req.user.id, req.user.organizationId);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
updateProfile(@Req() req, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.usersService.updateProfile(req.user.id, req.user.organizationId, updateUserDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(Role.ADMIN)
|
||||
findAll(@Req() req) {
|
||||
return this.usersService.findAll(req.user.organizationId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles(Role.ADMIN)
|
||||
findOne(@Param('id') id: string, @Req() req) {
|
||||
return this.usersService.findOne(id, req.user.organizationId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles(Role.ADMIN)
|
||||
update(@Param('id') id: string, @Req() req, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.usersService.update(id, req.user.organizationId, updateUserDto);
|
||||
}
|
||||
|
||||
@Post('invite')
|
||||
@Roles(Role.ADMIN)
|
||||
createInvite(@Req() req, @Body('role') role?: string) {
|
||||
return this.usersService.createInvite(req.user.organizationId, role);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ export class UsersService {
|
|||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
avatarId: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
|
|
@ -31,6 +34,9 @@ export class UsersService {
|
|||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
avatarId: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
|
|
@ -58,12 +64,21 @@ export class UsersService {
|
|||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
avatarId: true,
|
||||
role: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateProfile(id: string, organizationId: string, updateUserDto: UpdateUserDto) {
|
||||
// Basic user info update - can't change role via profile update
|
||||
const { role, ...updateData } = updateUserDto;
|
||||
return this.update(id, organizationId, updateData);
|
||||
}
|
||||
|
||||
async createInvite(organizationId: string, role: string = 'EMPLOYEE') {
|
||||
const payload = { organizationId, role, type: 'INVITE' };
|
||||
const token = this.jwtService.sign(payload, { expiresIn: '7d' });
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"import-meta-resolve": "^4.2.0",
|
||||
"interactjs": "^1.10.27",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts">
|
||||
import { resolveAssetUrl } from '$lib/utils/url';
|
||||
import { getGravatarUrl, getInitials } from '$lib/utils/avatar';
|
||||
|
||||
export let user: {
|
||||
name?: string | null;
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
export let size: 'sm' | 'md' | 'lg' = 'md';
|
||||
export let className: string = '';
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'w-6 h-6 text-[8px]',
|
||||
md: 'w-10 h-10 text-xs',
|
||||
lg: 'w-20 h-20 text-xl'
|
||||
};
|
||||
|
||||
let imgError = false;
|
||||
let gravatarUrl = getGravatarUrl(user.email, size === 'lg' ? 200 : 100);
|
||||
|
||||
$: initials = getInitials(user.name || undefined, user.email);
|
||||
</script>
|
||||
|
||||
<div class="relative inline-flex items-center justify-center overflow-hidden rounded-full bg-gray-100 border border-gray-200 shadow-sm {sizeMap[size]} {className}">
|
||||
{#if user.avatarUrl && !imgError}
|
||||
<img
|
||||
src={resolveAssetUrl(user.avatarUrl)}
|
||||
alt={user.name || user.email}
|
||||
class="w-full h-full object-cover relative z-10"
|
||||
on:error={() => imgError = true}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !user.avatarUrl || imgError}
|
||||
<img
|
||||
src={gravatarUrl}
|
||||
alt={user.name || user.email}
|
||||
class="w-full h-full object-cover relative z-10"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<span class="font-bold text-gray-600 uppercase">
|
||||
{initials}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 Avatar from './Avatar.svelte';
|
||||
import { resolveAssetUrl } from '$lib/utils/url';
|
||||
|
||||
export let floorId: string;
|
||||
|
|
@ -159,14 +160,12 @@
|
|||
</div>
|
||||
|
||||
{#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={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()}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="absolute -top-4 -left-4 shadow-sm">
|
||||
<Avatar
|
||||
user={status.reservation.user}
|
||||
size="sm"
|
||||
className="border-yellow-500 bg-white"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -177,7 +176,7 @@
|
|||
</span>
|
||||
{#if status.isOccupied && space.type === 'DESK'}
|
||||
<span class="text-[7px] text-gray-500 truncate max-w-[40px]">
|
||||
{status.reservation?.user?.email.split('@')[0]}
|
||||
{status.reservation?.user?.name || status.reservation?.user?.email.split('@')[0]}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import CryptoJS from 'crypto-js';
|
||||
|
||||
export function getGravatarUrl(email: string, size = 100): string {
|
||||
if (!email) return `https://www.gravatar.com/avatar/00000000000000000000000000000000?s=${size}&d=mp`;
|
||||
const hash = CryptoJS.MD5(email.trim().toLowerCase()).toString();
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
|
||||
}
|
||||
|
||||
export function getInitials(name?: string, email?: string): string {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return name.trim().substring(0, 2).toUpperCase();
|
||||
}
|
||||
if (email) {
|
||||
return email.trim().substring(0, 2).toUpperCase();
|
||||
}
|
||||
return '??';
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import { organization, loadOrganization } from "$lib/stores/organization";
|
||||
import { onMount } from "svelte";
|
||||
import { resolveAssetUrl } from "$lib/utils/url";
|
||||
import Avatar from "$lib/components/Avatar.svelte";
|
||||
|
||||
onMount(async () => {
|
||||
if ($user?.organizationId) {
|
||||
|
|
@ -37,8 +38,8 @@
|
|||
{/if}
|
||||
<a href="/dashboard" class="text-gray-600 hover:text-indigo-600 font-medium">Dashboard</a>
|
||||
<a href="/dashboard/my-bookings" class="text-gray-600 hover:text-indigo-600 font-medium">My Bookings</a>
|
||||
<a href="/login" class="bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-md hover:bg-indigo-100 font-medium flex items-center space-x-2">
|
||||
<div class="w-5 h-5 bg-indigo-200 rounded-full text-[10px] flex items-center justify-center font-bold">{$user?.email?.charAt(0).toUpperCase()}</div>
|
||||
<a href="/dashboard/profile" class="bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-md hover:bg-indigo-100 font-medium flex items-center space-x-2 transition-all">
|
||||
<Avatar user={$user} size="sm" />
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { user } from '$lib/stores/auth';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import { User, Mail, Shield, Briefcase, Save, Key, UserCircle } from 'lucide-svelte';
|
||||
|
||||
let name = $user?.name || '';
|
||||
let avatarUrl = $user?.avatarUrl || '';
|
||||
let password = '';
|
||||
let confirmPassword = '';
|
||||
|
||||
let loading = false;
|
||||
let uploadingAvatar = false;
|
||||
let message = '';
|
||||
let error = '';
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
onMount(async () => {
|
||||
// Refresh user data from server
|
||||
try {
|
||||
const freshUser: any = await apiFetch('/users/profile');
|
||||
user.set(freshUser);
|
||||
name = freshUser.name || '';
|
||||
avatarUrl = freshUser.avatarUrl || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch profile', e);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleUpdateProfile() {
|
||||
loading = true;
|
||||
message = '';
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const updateData: any = { name: name || null, avatarUrl: avatarUrl || null };
|
||||
if (password) {
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
if (password.length < 6) {
|
||||
throw new Error('Password must be at least 6 characters');
|
||||
}
|
||||
updateData.password = password;
|
||||
}
|
||||
|
||||
const updatedUser: any = await apiFetch('/users/profile', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
user.set(updatedUser);
|
||||
password = '';
|
||||
confirmPassword = '';
|
||||
message = 'Profile updated successfully!';
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Failed to update profile';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAvatarUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
const file = input.files[0];
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
uploadingAvatar = true;
|
||||
error = '';
|
||||
message = '';
|
||||
|
||||
try {
|
||||
const updatedUser: any = await apiFetch('/assets/user/avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
user.set(updatedUser);
|
||||
avatarUrl = updatedUser.avatarUrl || '';
|
||||
message = 'Avatar uploaded successfully!';
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Failed to upload avatar';
|
||||
} finally {
|
||||
uploadingAvatar = false;
|
||||
// Reset input
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto p-6">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">My Profile</h1>
|
||||
<p class="text-gray-500 mt-2">Manage your account information and preferences.</p>
|
||||
</div>
|
||||
|
||||
{#if message}
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 text-green-700 rounded-lg flex items-center gap-3">
|
||||
<div class="bg-green-100 p-1 rounded-full">
|
||||
<Save size={16} />
|
||||
</div>
|
||||
{message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg flex items-center gap-3">
|
||||
<div class="bg-red-100 p-1 rounded-full">
|
||||
<Shield size={16} />
|
||||
</div>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<!-- Profile Sidebar -->
|
||||
<div class="md:col-span-1">
|
||||
<div class="bg-white rounded-xl shadow-sm border p-6 flex flex-col items-center text-center">
|
||||
<div class="relative group">
|
||||
<Avatar user={$user} size="lg" className="mb-4 ring-4 ring-blue-50" />
|
||||
<button
|
||||
on:click={() => fileInput.click()}
|
||||
disabled={uploadingAvatar}
|
||||
class="absolute inset-0 mb-4 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if uploadingAvatar}
|
||||
<div class="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
{:else}
|
||||
<UserCircle size={32} class="text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
bind:this={fileInput}
|
||||
on:change={handleAvatarUpload}
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900">{$user?.name || 'No Name Set'}</h2>
|
||||
<p class="text-gray-500 text-sm mb-4">{$user?.email}</p>
|
||||
|
||||
<div class="w-full border-t pt-4 mt-2 space-y-3">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-600">
|
||||
<Shield size={16} class="text-gray-400" />
|
||||
<span class="capitalize">Role: {$user?.role}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm text-gray-600 text-left">
|
||||
<UserCircle size={16} class="text-gray-400 shrink-0" />
|
||||
<span class="truncate">ID: {$user?.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="md:col-span-2 space-y-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="p-6 border-b bg-gray-50">
|
||||
<h3 class="font-bold text-gray-900 flex items-center gap-2">
|
||||
<User size={18} class="text-blue-600" />
|
||||
Personal Information
|
||||
</h3>
|
||||
</div>
|
||||
<form on:submit|preventDefault={handleUpdateProfile} class="p-6 space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
class="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||
placeholder="Your full name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-500 mb-1">Email (Read-only)</label>
|
||||
<div class="flex items-center gap-2 p-2 bg-gray-50 border rounded-lg text-gray-500">
|
||||
<Mail size={16} />
|
||||
<span>{$user?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="avatarUrl" class="block text-sm font-medium text-gray-700 mb-1">Profile Picture URL</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="avatarUrl"
|
||||
type="text"
|
||||
bind:value={avatarUrl}
|
||||
class="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all font-mono text-sm"
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => fileInput.click()}
|
||||
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-all flex items-center gap-2"
|
||||
>
|
||||
<UserCircle size={18} />
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-500 mt-1">Provide a URL or upload a file. Leave empty to use Gravatar/initials.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-6 border-t mt-6">
|
||||
<h3 class="font-bold text-gray-900 flex items-center gap-2 mb-4">
|
||||
<Key size={18} class="text-blue-600" />
|
||||
Security
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
class="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 mb-1">Confirm New Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
class="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-500 mt-1">Leave blank if you don't want to change your password.</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-6 mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-bold hover:bg-blue-700 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Saving...
|
||||
{:else}
|
||||
<Save size={18} />
|
||||
Save Changes
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -66,6 +66,8 @@
|
|||
"apps/web": {
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"import-meta-resolve": "^4.2.0",
|
||||
"interactjs": "^1.10.27",
|
||||
|
|
@ -3408,6 +3410,11 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/crypto-js": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
|
||||
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
|
|
@ -5615,6 +5622,11 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
||||
|
|
|
|||
Loading…
Reference in New Issue