mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	Feature - Add upload functionality on Web (#231)
* Added file selector * Extract metadata to upload files to the web * Added request for uploading * Generate jpeg/Webp thumbnail for asset uploaded without thumbnail data * Added generating thumbnail for video and WebSocket broadcast after thumbnail is generated * Added video length extraction * Added Uploading Panel * Added upload progress store and styling the uploaded asset * Added condition to only show upload panel when there is upload in progress * Remove asset from the upload list after successfully uploading * Added WebSocket to listen to upload event on the web * Added mechanism to check for existing assets before uploading on the web * Added test workflow * Update readme
This commit is contained in:
		
							
								
								
									
										17
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | name: Test | ||||||
|  | on: | ||||||
|  |   pull_request: | ||||||
|  |   push: { branches: master } | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   test-server-e2e: | ||||||
|  |     name: Run test suite | ||||||
|  |  | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |  | ||||||
|  |     steps: | ||||||
|  |       - name: Checkout code | ||||||
|  |         uses: actions/checkout@v2 | ||||||
|  |  | ||||||
|  |       - name: Run Immich Server 2E2 Test | ||||||
|  |         run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich_server_test | ||||||
							
								
								
									
										47
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								README.md
									
									
									
									
									
								
							| @@ -8,7 +8,7 @@ | |||||||
|     <img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndPublishIOSToTestFlight.svg?style=for-the-badge&label=iOS&logo=teamcity&logoColor=000000&labelColor=ececec" alt="iOS Build"/> |     <img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndPublishIOSToTestFlight.svg?style=for-the-badge&label=iOS&logo=teamcity&logoColor=000000&labelColor=ececec" alt="iOS Build"/> | ||||||
|   </a> |   </a> | ||||||
|   <a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main"> |   <a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main"> | ||||||
|     <img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Server Docker&logo=docker&labelColor=ececec" /> |     <img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Github Action&logo=github&labelColor=ececec&logoColor=000000" /> | ||||||
|   </a> |   </a> | ||||||
|   <a href="https://discord.gg/rxnyVTXGbM"> |   <a href="https://discord.gg/rxnyVTXGbM"> | ||||||
|     <img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/> |     <img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/> | ||||||
| @@ -25,7 +25,7 @@ | |||||||
|  |  | ||||||
| # Immich | # Immich | ||||||
|  |  | ||||||
| Self-hosted photo and video backup solution directly from your mobile phone. | **High performance self-hosted photo and video backup solution.** | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -33,7 +33,7 @@ Loading ~4000 images/videos | |||||||
|  |  | ||||||
| ## Screenshots | ## Screenshots | ||||||
|  |  | ||||||
| ### Mobile client | ### Mobile | ||||||
| <p align="left"> | <p align="left"> | ||||||
|   <img src="design/login-screen.png" width="150" title="Login With Custom URL"> |   <img src="design/login-screen.png" width="150" title="Login With Custom URL"> | ||||||
|   <img src="design/backup-screen.png" width="150" title="Backup Setting Info"> |   <img src="design/backup-screen.png" width="150" title="Backup Setting Info"> | ||||||
| @@ -44,9 +44,10 @@ Loading ~4000 images/videos | |||||||
|   <img src="design/nsc6.png" width="150" title="EXIF Info"> |   <img src="design/nsc6.png" width="150" title="EXIF Info"> | ||||||
| </p> | </p> | ||||||
|  |  | ||||||
| ### Web client | ### Web | ||||||
| <p align="center"> | <p align="left"> | ||||||
|   <img src="design/dashboard_photos.jpeg" width="100%" title="Home Dashboard"> |   <img src="design/web-home.jpeg"  width="49%" title="Home Dashboard"> | ||||||
|  |   <img src="design/web-detail.jpeg" width="49%" title="Detail"> | ||||||
| </p> | </p> | ||||||
|  |  | ||||||
| # Note | # Note | ||||||
| @@ -57,24 +58,20 @@ This project is under heavy development, there will be continuous functions, fea | |||||||
|  |  | ||||||
| # Features  | # Features  | ||||||
|  |  | ||||||
| - Upload and view assets (videos/images). | |  | Mobile | Web | | ||||||
| - Auto Backup. | | - | - | - | | ||||||
| - Download asset to local device. | | Upload and view videos and photos | Yes | Yes | ||||||
| - Multi-user supported. | | Auto backup when app is opened | Yes | N/A | ||||||
| - Quick navigation with drag scroll bar. | | Selective album(s) for backup | Yes | N/A | ||||||
| - Support HEIC/HEIF Backup. | | Download photos and videos to local device | Yes | Yes | ||||||
| - Extract and display EXIF info. | | Multi-user support | Yes | Yes | ||||||
| - Real-time render from multi-device upload event. | | Shared Albums | Yes | No | ||||||
| - Image Tagging/Classification based on ImageNet dataset | | Quick navigation with draggable scrollbar | Yes | Yes | ||||||
| - Object detection based on COCO SSD. | | Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes | ||||||
| - Search assets based on tags and exif data (lens, make, model, orientation) | | Metadata view (EXIF, map) | Yes | Yes | ||||||
| - [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month) | | Search by metadata, objects and image tags | Yes | No | ||||||
| - Show asset's location information on map (OpenStreetMap). | | Administrative functions (user management) | No | Yes | ||||||
| - Show curated places on the search page |  | ||||||
| - Show curated objects on the search page |  | ||||||
| - Shared album with users on the same server |  | ||||||
| - Selective backup - albums can be included and excluded during the backup process. |  | ||||||
| - Web interface is available for administrative tasks (creating new users) and viewing assets on the server - additional features are coming. |  | ||||||
|  |  | ||||||
| # System Requirement | # System Requirement | ||||||
|  |  | ||||||
| @@ -97,7 +94,7 @@ You can use docker compose for development and testing out the application, ther | |||||||
| 3. **PostgreSQL** - Main database of the application | 3. **PostgreSQL** - Main database of the application | ||||||
| 4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue. | 4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue. | ||||||
| 5. **Nginx** - Load balancing and optimized file uploading. | 5. **Nginx** - Load balancing and optimized file uploading. | ||||||
| 6. **TensorFlow** - Object Detection and Image Classification. | 6. **TensorFlow** - Object Detection (COCO SSD) and Image Classification (ImageNet). | ||||||
|  |  | ||||||
| ## Step 1: Populate .env file | ## Step 1: Populate .env file | ||||||
|  |  | ||||||
|   | |||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 154 KiB | 
							
								
								
									
										
											BIN
										
									
								
								design/web-admin.jpeg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								design/web-admin.jpeg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 46 KiB | 
							
								
								
									
										
											BIN
										
									
								
								design/web-detail.jpeg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								design/web-detail.jpeg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 145 KiB | 
							
								
								
									
										
											BIN
										
									
								
								design/web-home.jpeg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								design/web-home.jpeg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 206 KiB | 
| @@ -60,6 +60,7 @@ services: | |||||||
|       - NODE_ENV=development |       - NODE_ENV=development | ||||||
|     depends_on: |     depends_on: | ||||||
|       - database |       - database | ||||||
|  |       - immich-server | ||||||
|     networks: |     networks: | ||||||
|       - immich-network |       - immich-network | ||||||
|  |  | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ import { | |||||||
|   Delete, |   Delete, | ||||||
|   Logger, |   Logger, | ||||||
|   Patch, |   Patch, | ||||||
|  |   HttpCode, | ||||||
| } from '@nestjs/common'; | } from '@nestjs/common'; | ||||||
| import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; | import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; | ||||||
| import { AssetService } from './asset.service'; | import { AssetService } from './asset.service'; | ||||||
| @@ -76,6 +77,10 @@ export class AssetController { | |||||||
|             { asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true }, |             { asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true }, | ||||||
|             { jobId: savedAsset.id }, |             { jobId: savedAsset.id }, | ||||||
|           ); |           ); | ||||||
|  |  | ||||||
|  |           this.wsCommunicateionGateway.server | ||||||
|  |             .to(savedAsset.userId) | ||||||
|  |             .emit('on_upload_success', JSON.stringify(assetWithThumbnail)); | ||||||
|         } else { |         } else { | ||||||
|           await this.assetUploadedQueue.add( |           await this.assetUploadedQueue.add( | ||||||
|             'asset-uploaded', |             'asset-uploaded', | ||||||
| @@ -83,8 +88,6 @@ export class AssetController { | |||||||
|             { jobId: savedAsset.id }, |             { jobId: savedAsset.id }, | ||||||
|           ); |           ); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset)); |  | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         Logger.error(`Error receiving upload file ${e}`); |         Logger.error(`Error receiving upload file ${e}`); | ||||||
|       } |       } | ||||||
| @@ -171,4 +174,20 @@ export class AssetController { | |||||||
|  |  | ||||||
|     return result; |     return result; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Check duplicated asset before uploading - for Web upload used | ||||||
|  |    */ | ||||||
|  |   @Post('/check') | ||||||
|  |   @HttpCode(200) | ||||||
|  |   async checkDuplicateAsset( | ||||||
|  |     @GetAuthUser() authUser: AuthUserDto, | ||||||
|  |     @Body(ValidationPipe) { deviceAssetId }: { deviceAssetId: string }, | ||||||
|  |   ) { | ||||||
|  |     const res = await this.assetService.checkDuplicatedAsset(authUser, deviceAssetId); | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       isExist: res, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -24,6 +24,6 @@ import { CommunicationModule } from '../communication/communication.module'; | |||||||
|   ], |   ], | ||||||
|   controllers: [AssetController], |   controllers: [AssetController], | ||||||
|   providers: [AssetService, BackgroundTaskService], |   providers: [AssetService, BackgroundTaskService], | ||||||
|   exports: [], |   exports: [AssetService], | ||||||
| }) | }) | ||||||
| export class AssetModule {} | export class AssetModule {} | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common'; | import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common'; | ||||||
| import { InjectRepository } from '@nestjs/typeorm'; | import { InjectRepository } from '@nestjs/typeorm'; | ||||||
| import { Repository } from 'typeorm'; | import { IsNull, Not, Repository } from 'typeorm'; | ||||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||||
| import { CreateAssetDto } from './dto/create-asset.dto'; | import { CreateAssetDto } from './dto/create-asset.dto'; | ||||||
| import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; | import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; | ||||||
| @@ -72,6 +72,7 @@ export class AssetService { | |||||||
|       return await this.assetRepository.find({ |       return await this.assetRepository.find({ | ||||||
|         where: { |         where: { | ||||||
|           userId: authUser.id, |           userId: authUser.id, | ||||||
|  |           resizePath: Not(IsNull()), | ||||||
|         }, |         }, | ||||||
|         relations: ['exifInfo'], |         relations: ['exifInfo'], | ||||||
|         order: { |         order: { | ||||||
| @@ -381,4 +382,15 @@ export class AssetService { | |||||||
|       [authUser.id], |       [authUser.id], | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) { | ||||||
|  |     const res = await this.assetRepository.findOne({ | ||||||
|  |       where: { | ||||||
|  |         deviceAssetId, | ||||||
|  |         userId: authUser.id, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     return res ? true : false; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -6,8 +6,9 @@ import { Logger } from '@nestjs/common'; | |||||||
| import { InjectRepository } from '@nestjs/typeorm'; | import { InjectRepository } from '@nestjs/typeorm'; | ||||||
| import { UserEntity } from '@app/database/entities/user.entity'; | import { UserEntity } from '@app/database/entities/user.entity'; | ||||||
| import { Repository } from 'typeorm'; | import { Repository } from 'typeorm'; | ||||||
|  | import { query } from 'express'; | ||||||
|  |  | ||||||
| @WebSocketGateway() | @WebSocketGateway({ cors: true }) | ||||||
| export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect { | export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect { | ||||||
|   constructor( |   constructor( | ||||||
|     private immichJwtService: ImmichJwtService, |     private immichJwtService: ImmichJwtService, | ||||||
| @@ -21,12 +22,15 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco | |||||||
|   handleDisconnect(client: Socket) { |   handleDisconnect(client: Socket) { | ||||||
|     client.leave(client.nsp.name); |     client.leave(client.nsp.name); | ||||||
|  |  | ||||||
|     Logger.log(`Client ${client.id} disconnected`); |     Logger.log(`Client ${client.id} disconnected from Websocket`, 'WebsocketConnectionEvent'); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   async handleConnection(client: Socket, ...args: any[]) { |   async handleConnection(client: Socket, ...args: any[]) { | ||||||
|     Logger.log(`New websocket connection: ${client.id}`, 'NewWebSocketConnection'); |     try { | ||||||
|  |       Logger.log(`New websocket connection: ${client.id}`, 'WebsocketConnectionEvent'); | ||||||
|  |  | ||||||
|       const accessToken = client.handshake.headers.authorization.split(' ')[1]; |       const accessToken = client.handshake.headers.authorization.split(' ')[1]; | ||||||
|  |  | ||||||
|       const res = await this.immichJwtService.validateToken(accessToken); |       const res = await this.immichJwtService.validateToken(accessToken); | ||||||
|  |  | ||||||
|       if (!res.status) { |       if (!res.status) { | ||||||
| @@ -43,5 +47,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       client.join(user.id); |       client.join(user.id); | ||||||
|  |     } catch (e) { | ||||||
|  |       // Logger.error(`Error establish websocket conneciton ${e}`, 'HandleWebscoketConnection'); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,10 +1,13 @@ | |||||||
| import { Logger } from '@nestjs/common'; | import { Logger } from '@nestjs/common'; | ||||||
| import { NestFactory } from '@nestjs/core'; | import { NestFactory } from '@nestjs/core'; | ||||||
|  | import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware'; | ||||||
| import { MicroservicesModule } from './microservices.module'; | import { MicroservicesModule } from './microservices.module'; | ||||||
|  |  | ||||||
| async function bootstrap() { | async function bootstrap() { | ||||||
|   const app = await NestFactory.create(MicroservicesModule); |   const app = await NestFactory.create(MicroservicesModule); | ||||||
|  |  | ||||||
|  |   app.useWebSocketAdapter(new RedisIoAdapter(app)); | ||||||
|  |  | ||||||
|   await app.listen(3000, () => { |   await app.listen(3000, () => { | ||||||
|     if (process.env.NODE_ENV == 'development') { |     if (process.env.NODE_ENV == 'development') { | ||||||
|       Logger.log('Running Immich Microservices in DEVELOPMENT environment', 'ImmichMicroservice'); |       Logger.log('Running Immich Microservices in DEVELOPMENT environment', 'ImmichMicroservice'); | ||||||
|   | |||||||
| @@ -11,6 +11,9 @@ import { AssetUploadedProcessor } from './processors/asset-uploaded.processor'; | |||||||
| import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor'; | import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor'; | ||||||
| import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; | import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; | ||||||
| import { VideoTranscodeProcessor } from './processors/video-transcode.processor'; | import { VideoTranscodeProcessor } from './processors/video-transcode.processor'; | ||||||
|  | import { AssetModule } from '../../immich/src/api-v1/asset/asset.module'; | ||||||
|  | import { CommunicationGateway } from '../../immich/src/api-v1/communication/communication.gateway'; | ||||||
|  | import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module'; | ||||||
|  |  | ||||||
| @Module({ | @Module({ | ||||||
|   imports: [ |   imports: [ | ||||||
| @@ -56,6 +59,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor' | |||||||
|         removeOnFail: false, |         removeOnFail: false, | ||||||
|       }, |       }, | ||||||
|     }), |     }), | ||||||
|  |     CommunicationModule, | ||||||
|   ], |   ], | ||||||
|   controllers: [], |   controllers: [], | ||||||
|   providers: [ |   providers: [ | ||||||
|   | |||||||
| @@ -46,6 +46,7 @@ export class AssetUploadedProcessor { | |||||||
|       await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() }); |       await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() }); | ||||||
|     } else { |     } else { | ||||||
|       // Generate Thumbnail -> Then generate webp, tag image and detect object |       // Generate Thumbnail -> Then generate webp, tag image and detect object | ||||||
|  |       await this.thumbnailGeneratorQueue.add('generate-jpeg-thumbnail', { asset }, { jobId: randomUUID() }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Video Conversion |     // Video Conversion | ||||||
| @@ -63,5 +64,10 @@ export class AssetUploadedProcessor { | |||||||
|         { jobId: randomUUID() }, |         { jobId: randomUUID() }, | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Extract video duration if uploaded from the web | ||||||
|  |     if (asset.type == AssetType.VIDEO && asset.duration == '0:00:00.000000') { | ||||||
|  |       await this.metadataExtractionQueue.add('extract-video-length', { asset }, { jobId: randomUUID() }); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -12,6 +12,8 @@ import { Logger } from '@nestjs/common'; | |||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
| import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; | import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; | ||||||
| import { ConfigService } from '@nestjs/config'; | import { ConfigService } from '@nestjs/config'; | ||||||
|  | import ffmpeg from 'fluent-ffmpeg'; | ||||||
|  | // import moment from 'moment'; | ||||||
|  |  | ||||||
| @Processor('metadata-extraction-queue') | @Processor('metadata-extraction-queue') | ||||||
| export class MetadataExtractionProcessor { | export class MetadataExtractionProcessor { | ||||||
| @@ -129,4 +131,27 @@ export class MetadataExtractionProcessor { | |||||||
|       Logger.error(`Failed to trigger object detection pipe line ${error.toString()}`); |       Logger.error(`Failed to trigger object detection pipe line ${error.toString()}`); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   @Process({ name: 'extract-video-length', concurrency: 2 }) | ||||||
|  |   async extractVideoLength(job: Job) { | ||||||
|  |     const { asset }: { asset: AssetEntity } = job.data; | ||||||
|  |  | ||||||
|  |     ffmpeg.ffprobe(asset.originalPath, async (err, data) => { | ||||||
|  |       if (!err) { | ||||||
|  |         if (data.format.duration) { | ||||||
|  |           const videoDurationInSecond = parseInt(data.format.duration.toString(), 0); | ||||||
|  |  | ||||||
|  |           const hours = Math.floor(videoDurationInSecond / 3600); | ||||||
|  |           const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60); | ||||||
|  |           const seconds = videoDurationInSecond - hours * 3600 - minutes * 60; | ||||||
|  |  | ||||||
|  |           const durationString = `${hours}:${minutes < 10 ? '0' + minutes.toString() : minutes}:${ | ||||||
|  |             seconds < 10 ? '0' + seconds.toString() : seconds | ||||||
|  |           }.000000`; | ||||||
|  |  | ||||||
|  |           await this.assetRepository.update({ id: asset.id }, { duration: durationString }); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,22 +1,82 @@ | |||||||
| import { Process, Processor } from '@nestjs/bull'; | import { InjectQueue, Process, Processor } from '@nestjs/bull'; | ||||||
| import { Job } from 'bull'; | import { Job, Queue } from 'bull'; | ||||||
| import { AssetEntity } from '@app/database/entities/asset.entity'; | import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; | ||||||
| import { Repository } from 'typeorm/repository/Repository'; | import { Repository } from 'typeorm/repository/Repository'; | ||||||
| import { InjectRepository } from '@nestjs/typeorm'; | import { InjectRepository } from '@nestjs/typeorm'; | ||||||
| import sharp from 'sharp'; | import sharp from 'sharp'; | ||||||
|  | import { existsSync, mkdirSync } from 'node:fs'; | ||||||
|  | import { randomUUID } from 'node:crypto'; | ||||||
|  | import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway'; | ||||||
|  | import ffmpeg from 'fluent-ffmpeg'; | ||||||
|  | import { Logger } from '@nestjs/common'; | ||||||
|  |  | ||||||
| @Processor('thumbnail-generator-queue') | @Processor('thumbnail-generator-queue') | ||||||
| export class ThumbnailGeneratorProcessor { | export class ThumbnailGeneratorProcessor { | ||||||
|   constructor( |   constructor( | ||||||
|     @InjectRepository(AssetEntity) |     @InjectRepository(AssetEntity) | ||||||
|     private assetRepository: Repository<AssetEntity>, |     private assetRepository: Repository<AssetEntity>, | ||||||
|  |  | ||||||
|  |     @InjectQueue('thumbnail-generator-queue') | ||||||
|  |     private thumbnailGeneratorQueue: Queue, | ||||||
|  |  | ||||||
|  |     private wsCommunicateionGateway: CommunicationGateway, | ||||||
|   ) {} |   ) {} | ||||||
|  |  | ||||||
|   @Process('generate-jpeg-thumbnail') |   @Process('generate-jpeg-thumbnail') | ||||||
|   async generateJPEGThumbnail(job: Job) { |   async generateJPEGThumbnail(job: Job) { | ||||||
|     const { asset }: { asset: AssetEntity } = job.data; |     const { asset }: { asset: AssetEntity } = job.data; | ||||||
|  |  | ||||||
|     console.log(asset); |     const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`; | ||||||
|  |  | ||||||
|  |     if (!existsSync(resizePath)) { | ||||||
|  |       mkdirSync(resizePath, { recursive: true }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const temp = asset.originalPath.split('/'); | ||||||
|  |     const originalFilename = temp[temp.length - 1].split('.')[0]; | ||||||
|  |     const jpegThumbnailPath = resizePath + originalFilename + '.jpeg'; | ||||||
|  |  | ||||||
|  |     if (asset.type == AssetType.IMAGE) { | ||||||
|  |       sharp(asset.originalPath) | ||||||
|  |         .resize(1440, 2560, { fit: 'inside' }) | ||||||
|  |         .jpeg() | ||||||
|  |         .toFile(jpegThumbnailPath, async (err, info) => { | ||||||
|  |           if (!err) { | ||||||
|  |             await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); | ||||||
|  |  | ||||||
|  |             // Update resize path to send to generate webp queue | ||||||
|  |             asset.resizePath = jpegThumbnailPath; | ||||||
|  |  | ||||||
|  |             await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() }); | ||||||
|  |             this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset)); | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (asset.type == AssetType.VIDEO) { | ||||||
|  |       ffmpeg(asset.originalPath) | ||||||
|  |         .outputOptions(['-ss 00:00:01.000', '-frames:v 1']) | ||||||
|  |         .output(jpegThumbnailPath) | ||||||
|  |         .on('start', () => { | ||||||
|  |           Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail'); | ||||||
|  |         }) | ||||||
|  |         .on('error', (error, b, c) => { | ||||||
|  |           Logger.error(`Cannot Generate Video Thumbnail ${error}`, 'generateJPEGThumbnail'); | ||||||
|  |           // reject(); | ||||||
|  |         }) | ||||||
|  |         .on('end', async () => { | ||||||
|  |           Logger.log(`Generating Video Thumbnail Success ${asset.id}`, 'generateJPEGThumbnail'); | ||||||
|  |           await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); | ||||||
|  |  | ||||||
|  |           // Update resize path to send to generate webp queue | ||||||
|  |           asset.resizePath = jpegThumbnailPath; | ||||||
|  |  | ||||||
|  |           await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() }); | ||||||
|  |  | ||||||
|  |           this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset)); | ||||||
|  |         }) | ||||||
|  |         .run(); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @Process({ name: 'generate-webp-thumbnail', concurrency: 2 }) |   @Process({ name: 'generate-webp-thumbnail', concurrency: 2 }) | ||||||
|   | |||||||
| @@ -42,7 +42,7 @@ export class VideoTranscodeProcessor { | |||||||
|         .outputOptions(['-crf 23', '-preset ultrafast', '-vcodec libx264', '-acodec mp3', '-vf scale=1280:-2']) |         .outputOptions(['-crf 23', '-preset ultrafast', '-vcodec libx264', '-acodec mp3', '-vf scale=1280:-2']) | ||||||
|         .output(savedEncodedPath) |         .output(savedEncodedPath) | ||||||
|         .on('start', () => { |         .on('start', () => { | ||||||
|           Logger.log('Start Converting', 'mp4Conversion'); |           Logger.log('Start Converting Video', 'mp4Conversion'); | ||||||
|         }) |         }) | ||||||
|         .on('error', (error, b, c) => { |         .on('error', (error, b, c) => { | ||||||
|           Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion'); |           Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion'); | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -55,6 +55,7 @@ | |||||||
|         "@types/bull": "^3.15.7", |         "@types/bull": "^3.15.7", | ||||||
|         "@types/cron": "^2.0.0", |         "@types/cron": "^2.0.0", | ||||||
|         "@types/express": "^4.17.13", |         "@types/express": "^4.17.13", | ||||||
|  |         "@types/fluent-ffmpeg": "^2.1.20", | ||||||
|         "@types/imagemin": "^8.0.0", |         "@types/imagemin": "^8.0.0", | ||||||
|         "@types/jest": "27.0.2", |         "@types/jest": "27.0.2", | ||||||
|         "@types/lodash": "^4.14.178", |         "@types/lodash": "^4.14.178", | ||||||
| @@ -2195,6 +2196,15 @@ | |||||||
|         "@types/range-parser": "*" |         "@types/range-parser": "*" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/@types/fluent-ffmpeg": { | ||||||
|  |       "version": "2.1.20", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz", | ||||||
|  |       "integrity": "sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==", | ||||||
|  |       "dev": true, | ||||||
|  |       "dependencies": { | ||||||
|  |         "@types/node": "*" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/@types/graceful-fs": { |     "node_modules/@types/graceful-fs": { | ||||||
|       "version": "4.1.5", |       "version": "4.1.5", | ||||||
|       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", |       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", | ||||||
| @@ -12803,6 +12813,15 @@ | |||||||
|         "@types/range-parser": "*" |         "@types/range-parser": "*" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "@types/fluent-ffmpeg": { | ||||||
|  |       "version": "2.1.20", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz", | ||||||
|  |       "integrity": "sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==", | ||||||
|  |       "dev": true, | ||||||
|  |       "requires": { | ||||||
|  |         "@types/node": "*" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "@types/graceful-fs": { |     "@types/graceful-fs": { | ||||||
|       "version": "4.1.5", |       "version": "4.1.5", | ||||||
|       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", |       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", | ||||||
|   | |||||||
| @@ -68,6 +68,7 @@ | |||||||
|     "@types/bull": "^3.15.7", |     "@types/bull": "^3.15.7", | ||||||
|     "@types/cron": "^2.0.0", |     "@types/cron": "^2.0.0", | ||||||
|     "@types/express": "^4.17.13", |     "@types/express": "^4.17.13", | ||||||
|  |     "@types/fluent-ffmpeg": "^2.1.20", | ||||||
|     "@types/imagemin": "^8.0.0", |     "@types/imagemin": "^8.0.0", | ||||||
|     "@types/jest": "27.0.2", |     "@types/jest": "27.0.2", | ||||||
|     "@types/lodash": "^4.14.178", |     "@types/lodash": "^4.14.178", | ||||||
|   | |||||||
							
								
								
									
										295
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										295
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -10,11 +10,12 @@ | |||||||
| 			"dependencies": { | 			"dependencies": { | ||||||
| 				"axios": "^0.27.2", | 				"axios": "^0.27.2", | ||||||
| 				"cookie": "^0.4.2", | 				"cookie": "^0.4.2", | ||||||
|  | 				"exifr": "^7.1.3", | ||||||
| 				"leaflet": "^1.8.0", | 				"leaflet": "^1.8.0", | ||||||
| 				"lodash": "^4.17.21", | 				"lodash": "^4.17.21", | ||||||
| 				"lodash-es": "^4.17.21", | 				"lodash-es": "^4.17.21", | ||||||
| 				"markdown-it": "^13.0.1", |  | ||||||
| 				"moment": "^2.29.3", | 				"moment": "^2.29.3", | ||||||
|  | 				"socket.io-client": "^4.5.1", | ||||||
| 				"svelte-material-icons": "^2.0.2" | 				"svelte-material-icons": "^2.0.2" | ||||||
| 			}, | 			}, | ||||||
| 			"devDependencies": { | 			"devDependencies": { | ||||||
| @@ -28,7 +29,7 @@ | |||||||
| 				"@types/leaflet": "^1.7.10", | 				"@types/leaflet": "^1.7.10", | ||||||
| 				"@types/lodash": "^4.14.182", | 				"@types/lodash": "^4.14.182", | ||||||
| 				"@types/lodash-es": "^4.17.6", | 				"@types/lodash-es": "^4.17.6", | ||||||
| 				"@types/markdown-it": "^12.2.3", | 				"@types/socket.io-client": "^3.0.0", | ||||||
| 				"@typescript-eslint/eslint-plugin": "^5.10.1", | 				"@typescript-eslint/eslint-plugin": "^5.10.1", | ||||||
| 				"@typescript-eslint/parser": "^5.10.1", | 				"@typescript-eslint/parser": "^5.10.1", | ||||||
| 				"autoprefixer": "^10.4.7", | 				"autoprefixer": "^10.4.7", | ||||||
| @@ -140,6 +141,11 @@ | |||||||
| 				"node": ">= 8.0.0" | 				"node": ">= 8.0.0" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
|  | 		"node_modules/@socket.io/component-emitter": { | ||||||
|  | 			"version": "3.1.0", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", | ||||||
|  | 			"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" | ||||||
|  | 		}, | ||||||
| 		"node_modules/@sveltejs/adapter-auto": { | 		"node_modules/@sveltejs/adapter-auto": { | ||||||
| 			"version": "1.0.0-next.40", | 			"version": "1.0.0-next.40", | ||||||
| 			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz", | 			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz", | ||||||
| @@ -293,12 +299,6 @@ | |||||||
| 				"@types/geojson": "*" | 				"@types/geojson": "*" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 		"node_modules/@types/linkify-it": { |  | ||||||
| 			"version": "3.0.2", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", |  | ||||||
| 			"integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", |  | ||||||
| 			"dev": true |  | ||||||
| 		}, |  | ||||||
| 		"node_modules/@types/lodash": { | 		"node_modules/@types/lodash": { | ||||||
| 			"version": "4.14.182", | 			"version": "4.14.182", | ||||||
| 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", | 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", | ||||||
| @@ -314,22 +314,6 @@ | |||||||
| 				"@types/lodash": "*" | 				"@types/lodash": "*" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 		"node_modules/@types/markdown-it": { |  | ||||||
| 			"version": "12.2.3", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", |  | ||||||
| 			"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", |  | ||||||
| 			"dev": true, |  | ||||||
| 			"dependencies": { |  | ||||||
| 				"@types/linkify-it": "*", |  | ||||||
| 				"@types/mdurl": "*" |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 		"node_modules/@types/mdurl": { |  | ||||||
| 			"version": "1.0.2", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", |  | ||||||
| 			"integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==", |  | ||||||
| 			"dev": true |  | ||||||
| 		}, |  | ||||||
| 		"node_modules/@types/node": { | 		"node_modules/@types/node": { | ||||||
| 			"version": "17.0.32", | 			"version": "17.0.32", | ||||||
| 			"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz", | 			"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz", | ||||||
| @@ -351,6 +335,16 @@ | |||||||
| 				"@types/node": "*" | 				"@types/node": "*" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
|  | 		"node_modules/@types/socket.io-client": { | ||||||
|  | 			"version": "3.0.0", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-3.0.0.tgz", | ||||||
|  | 			"integrity": "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==", | ||||||
|  | 			"deprecated": "This is a stub types definition. socket.io-client provides its own type definitions, so you do not need this installed.", | ||||||
|  | 			"dev": true, | ||||||
|  | 			"dependencies": { | ||||||
|  | 				"socket.io-client": "*" | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
| 		"node_modules/@typescript-eslint/eslint-plugin": { | 		"node_modules/@typescript-eslint/eslint-plugin": { | ||||||
| 			"version": "5.23.0", | 			"version": "5.23.0", | ||||||
| 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz", | 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz", | ||||||
| @@ -650,7 +644,8 @@ | |||||||
| 		"node_modules/argparse": { | 		"node_modules/argparse": { | ||||||
| 			"version": "2.0.1", | 			"version": "2.0.1", | ||||||
| 			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", | 			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", | ||||||
| 			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" | 			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", | ||||||
|  | 			"dev": true | ||||||
| 		}, | 		}, | ||||||
| 		"node_modules/array-union": { | 		"node_modules/array-union": { | ||||||
| 			"version": "2.1.0", | 			"version": "2.1.0", | ||||||
| @@ -933,7 +928,6 @@ | |||||||
| 			"version": "4.3.4", | 			"version": "4.3.4", | ||||||
| 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", | 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", | ||||||
| 			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", | 			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", | ||||||
| 			"dev": true, |  | ||||||
| 			"dependencies": { | 			"dependencies": { | ||||||
| 				"ms": "2.1.2" | 				"ms": "2.1.2" | ||||||
| 			}, | 			}, | ||||||
| @@ -1043,15 +1037,24 @@ | |||||||
| 			"integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==", | 			"integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==", | ||||||
| 			"dev": true | 			"dev": true | ||||||
| 		}, | 		}, | ||||||
| 		"node_modules/entities": { | 		"node_modules/engine.io-client": { | ||||||
| 			"version": "3.0.1", | 			"version": "6.2.2", | ||||||
| 			"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", | 			"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.2.tgz", | ||||||
| 			"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", | 			"integrity": "sha512-8ZQmx0LQGRTYkHuogVZuGSpDqYZtCM/nv8zQ68VZ+JkOpazJ7ICdsSpaO6iXwvaU30oFg5QJOJWj8zWqhbKjkQ==", | ||||||
| 			"engines": { | 			"dependencies": { | ||||||
| 				"node": ">=0.12" | 				"@socket.io/component-emitter": "~3.1.0", | ||||||
|  | 				"debug": "~4.3.1", | ||||||
|  | 				"engine.io-parser": "~5.0.3", | ||||||
|  | 				"ws": "~8.2.3", | ||||||
|  | 				"xmlhttprequest-ssl": "~2.0.0" | ||||||
|  | 			} | ||||||
| 		}, | 		}, | ||||||
| 			"funding": { | 		"node_modules/engine.io-parser": { | ||||||
| 				"url": "https://github.com/fb55/entities?sponsor=1" | 			"version": "5.0.4", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz", | ||||||
|  | 			"integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==", | ||||||
|  | 			"engines": { | ||||||
|  | 				"node": ">=10.0.0" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 		"node_modules/es6-promise": { | 		"node_modules/es6-promise": { | ||||||
| @@ -1673,6 +1676,11 @@ | |||||||
| 				"node": ">=0.10.0" | 				"node": ">=0.10.0" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
|  | 		"node_modules/exifr": { | ||||||
|  | 			"version": "7.1.3", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", | ||||||
|  | 			"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==" | ||||||
|  | 		}, | ||||||
| 		"node_modules/fast-deep-equal": { | 		"node_modules/fast-deep-equal": { | ||||||
| 			"version": "3.1.3", | 			"version": "3.1.3", | ||||||
| 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", | 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", | ||||||
| @@ -2112,14 +2120,6 @@ | |||||||
| 				"node": ">=10" | 				"node": ">=10" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 		"node_modules/linkify-it": { |  | ||||||
| 			"version": "4.0.1", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", |  | ||||||
| 			"integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", |  | ||||||
| 			"dependencies": { |  | ||||||
| 				"uc.micro": "^1.0.1" |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 		"node_modules/lodash": { | 		"node_modules/lodash": { | ||||||
| 			"version": "4.17.21", | 			"version": "4.17.21", | ||||||
| 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", | 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", | ||||||
| @@ -2160,26 +2160,6 @@ | |||||||
| 				"node": ">=12" | 				"node": ">=12" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 		"node_modules/markdown-it": { |  | ||||||
| 			"version": "13.0.1", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz", |  | ||||||
| 			"integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==", |  | ||||||
| 			"dependencies": { |  | ||||||
| 				"argparse": "^2.0.1", |  | ||||||
| 				"entities": "~3.0.1", |  | ||||||
| 				"linkify-it": "^4.0.1", |  | ||||||
| 				"mdurl": "^1.0.1", |  | ||||||
| 				"uc.micro": "^1.0.5" |  | ||||||
| 			}, |  | ||||||
| 			"bin": { |  | ||||||
| 				"markdown-it": "bin/markdown-it.js" |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 		"node_modules/mdurl": { |  | ||||||
| 			"version": "1.0.1", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", |  | ||||||
| 			"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" |  | ||||||
| 		}, |  | ||||||
| 		"node_modules/merge2": { | 		"node_modules/merge2": { | ||||||
| 			"version": "1.4.1", | 			"version": "1.4.1", | ||||||
| 			"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", | 			"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", | ||||||
| @@ -2289,8 +2269,7 @@ | |||||||
| 		"node_modules/ms": { | 		"node_modules/ms": { | ||||||
| 			"version": "2.1.2", | 			"version": "2.1.2", | ||||||
| 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | ||||||
| 			"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", | 			"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" | ||||||
| 			"dev": true |  | ||||||
| 		}, | 		}, | ||||||
| 		"node_modules/nanoid": { | 		"node_modules/nanoid": { | ||||||
| 			"version": "3.3.4", | 			"version": "3.3.4", | ||||||
| @@ -2820,6 +2799,32 @@ | |||||||
| 				"node": ">=8" | 				"node": ">=8" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
|  | 		"node_modules/socket.io-client": { | ||||||
|  | 			"version": "4.5.1", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.1.tgz", | ||||||
|  | 			"integrity": "sha512-e6nLVgiRYatS+AHXnOnGi4ocOpubvOUCGhyWw8v+/FxW8saHkinG6Dfhi9TU0Kt/8mwJIAASxvw6eujQmjdZVA==", | ||||||
|  | 			"dependencies": { | ||||||
|  | 				"@socket.io/component-emitter": "~3.1.0", | ||||||
|  | 				"debug": "~4.3.2", | ||||||
|  | 				"engine.io-client": "~6.2.1", | ||||||
|  | 				"socket.io-parser": "~4.2.0" | ||||||
|  | 			}, | ||||||
|  | 			"engines": { | ||||||
|  | 				"node": ">=10.0.0" | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
|  | 		"node_modules/socket.io-parser": { | ||||||
|  | 			"version": "4.2.0", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.0.tgz", | ||||||
|  | 			"integrity": "sha512-tLfmEwcEwnlQTxFB7jibL/q2+q8dlVQzj4JdRLJ/W/G1+Fu9VSxCx1Lo+n1HvXxKnM//dUuD0xgiA7tQf57Vng==", | ||||||
|  | 			"dependencies": { | ||||||
|  | 				"@socket.io/component-emitter": "~3.1.0", | ||||||
|  | 				"debug": "~4.3.1" | ||||||
|  | 			}, | ||||||
|  | 			"engines": { | ||||||
|  | 				"node": ">=10.0.0" | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
| 		"node_modules/sorcery": { | 		"node_modules/sorcery": { | ||||||
| 			"version": "0.10.0", | 			"version": "0.10.0", | ||||||
| 			"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz", | 			"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz", | ||||||
| @@ -3187,11 +3192,6 @@ | |||||||
| 				"node": ">=4.2.0" | 				"node": ">=4.2.0" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 		"node_modules/uc.micro": { |  | ||||||
| 			"version": "1.0.6", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", |  | ||||||
| 			"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" |  | ||||||
| 		}, |  | ||||||
| 		"node_modules/uri-js": { | 		"node_modules/uri-js": { | ||||||
| 			"version": "4.4.1", | 			"version": "4.4.1", | ||||||
| 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", | 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", | ||||||
| @@ -3293,6 +3293,34 @@ | |||||||
| 			"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", | 			"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", | ||||||
| 			"dev": true | 			"dev": true | ||||||
| 		}, | 		}, | ||||||
|  | 		"node_modules/ws": { | ||||||
|  | 			"version": "8.2.3", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", | ||||||
|  | 			"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", | ||||||
|  | 			"engines": { | ||||||
|  | 				"node": ">=10.0.0" | ||||||
|  | 			}, | ||||||
|  | 			"peerDependencies": { | ||||||
|  | 				"bufferutil": "^4.0.1", | ||||||
|  | 				"utf-8-validate": "^5.0.2" | ||||||
|  | 			}, | ||||||
|  | 			"peerDependenciesMeta": { | ||||||
|  | 				"bufferutil": { | ||||||
|  | 					"optional": true | ||||||
|  | 				}, | ||||||
|  | 				"utf-8-validate": { | ||||||
|  | 					"optional": true | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
|  | 		"node_modules/xmlhttprequest-ssl": { | ||||||
|  | 			"version": "2.0.0", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", | ||||||
|  | 			"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", | ||||||
|  | 			"engines": { | ||||||
|  | 				"node": ">=0.4.0" | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
| 		"node_modules/xtend": { | 		"node_modules/xtend": { | ||||||
| 			"version": "4.0.2", | 			"version": "4.0.2", | ||||||
| 			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", | 			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", | ||||||
| @@ -3395,6 +3423,11 @@ | |||||||
| 				"picomatch": "^2.2.2" | 				"picomatch": "^2.2.2" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
|  | 		"@socket.io/component-emitter": { | ||||||
|  | 			"version": "3.1.0", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", | ||||||
|  | 			"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" | ||||||
|  | 		}, | ||||||
| 		"@sveltejs/adapter-auto": { | 		"@sveltejs/adapter-auto": { | ||||||
| 			"version": "1.0.0-next.40", | 			"version": "1.0.0-next.40", | ||||||
| 			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz", | 			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz", | ||||||
| @@ -3525,12 +3558,6 @@ | |||||||
| 				"@types/geojson": "*" | 				"@types/geojson": "*" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 		"@types/linkify-it": { |  | ||||||
| 			"version": "3.0.2", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", |  | ||||||
| 			"integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", |  | ||||||
| 			"dev": true |  | ||||||
| 		}, |  | ||||||
| 		"@types/lodash": { | 		"@types/lodash": { | ||||||
| 			"version": "4.14.182", | 			"version": "4.14.182", | ||||||
| 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", | 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", | ||||||
| @@ -3546,22 +3573,6 @@ | |||||||
| 				"@types/lodash": "*" | 				"@types/lodash": "*" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 		"@types/markdown-it": { |  | ||||||
| 			"version": "12.2.3", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", |  | ||||||
| 			"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", |  | ||||||
| 			"dev": true, |  | ||||||
| 			"requires": { |  | ||||||
| 				"@types/linkify-it": "*", |  | ||||||
| 				"@types/mdurl": "*" |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 		"@types/mdurl": { |  | ||||||
| 			"version": "1.0.2", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", |  | ||||||
| 			"integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==", |  | ||||||
| 			"dev": true |  | ||||||
| 		}, |  | ||||||
| 		"@types/node": { | 		"@types/node": { | ||||||
| 			"version": "17.0.32", | 			"version": "17.0.32", | ||||||
| 			"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz", | 			"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz", | ||||||
| @@ -3583,6 +3594,15 @@ | |||||||
| 				"@types/node": "*" | 				"@types/node": "*" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
|  | 		"@types/socket.io-client": { | ||||||
|  | 			"version": "3.0.0", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-3.0.0.tgz", | ||||||
|  | 			"integrity": "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==", | ||||||
|  | 			"dev": true, | ||||||
|  | 			"requires": { | ||||||
|  | 				"socket.io-client": "*" | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
| 		"@typescript-eslint/eslint-plugin": { | 		"@typescript-eslint/eslint-plugin": { | ||||||
| 			"version": "5.23.0", | 			"version": "5.23.0", | ||||||
| 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz", | 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz", | ||||||
| @@ -3762,7 +3782,8 @@ | |||||||
| 		"argparse": { | 		"argparse": { | ||||||
| 			"version": "2.0.1", | 			"version": "2.0.1", | ||||||
| 			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", | 			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", | ||||||
| 			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" | 			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", | ||||||
|  | 			"dev": true | ||||||
| 		}, | 		}, | ||||||
| 		"array-union": { | 		"array-union": { | ||||||
| 			"version": "2.1.0", | 			"version": "2.1.0", | ||||||
| @@ -3947,7 +3968,6 @@ | |||||||
| 			"version": "4.3.4", | 			"version": "4.3.4", | ||||||
| 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", | 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", | ||||||
| 			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", | 			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", | ||||||
| 			"dev": true, |  | ||||||
| 			"requires": { | 			"requires": { | ||||||
| 				"ms": "2.1.2" | 				"ms": "2.1.2" | ||||||
| 			} | 			} | ||||||
| @@ -4028,10 +4048,22 @@ | |||||||
| 			"integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==", | 			"integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==", | ||||||
| 			"dev": true | 			"dev": true | ||||||
| 		}, | 		}, | ||||||
| 		"entities": { | 		"engine.io-client": { | ||||||
| 			"version": "3.0.1", | 			"version": "6.2.2", | ||||||
| 			"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", | 			"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.2.tgz", | ||||||
| 			"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==" | 			"integrity": "sha512-8ZQmx0LQGRTYkHuogVZuGSpDqYZtCM/nv8zQ68VZ+JkOpazJ7ICdsSpaO6iXwvaU30oFg5QJOJWj8zWqhbKjkQ==", | ||||||
|  | 			"requires": { | ||||||
|  | 				"@socket.io/component-emitter": "~3.1.0", | ||||||
|  | 				"debug": "~4.3.1", | ||||||
|  | 				"engine.io-parser": "~5.0.3", | ||||||
|  | 				"ws": "~8.2.3", | ||||||
|  | 				"xmlhttprequest-ssl": "~2.0.0" | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
|  | 		"engine.io-parser": { | ||||||
|  | 			"version": "5.0.4", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz", | ||||||
|  | 			"integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==" | ||||||
| 		}, | 		}, | ||||||
| 		"es6-promise": { | 		"es6-promise": { | ||||||
| 			"version": "3.3.1", | 			"version": "3.3.1", | ||||||
| @@ -4399,6 +4431,11 @@ | |||||||
| 			"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", | 			"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", | ||||||
| 			"dev": true | 			"dev": true | ||||||
| 		}, | 		}, | ||||||
|  | 		"exifr": { | ||||||
|  | 			"version": "7.1.3", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", | ||||||
|  | 			"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==" | ||||||
|  | 		}, | ||||||
| 		"fast-deep-equal": { | 		"fast-deep-equal": { | ||||||
| 			"version": "3.1.3", | 			"version": "3.1.3", | ||||||
| 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", | 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", | ||||||
| @@ -4729,14 +4766,6 @@ | |||||||
| 			"integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==", | 			"integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==", | ||||||
| 			"dev": true | 			"dev": true | ||||||
| 		}, | 		}, | ||||||
| 		"linkify-it": { |  | ||||||
| 			"version": "4.0.1", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", |  | ||||||
| 			"integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", |  | ||||||
| 			"requires": { |  | ||||||
| 				"uc.micro": "^1.0.1" |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 		"lodash": { | 		"lodash": { | ||||||
| 			"version": "4.17.21", | 			"version": "4.17.21", | ||||||
| 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", | 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", | ||||||
| @@ -4771,23 +4800,6 @@ | |||||||
| 				"sourcemap-codec": "^1.4.8" | 				"sourcemap-codec": "^1.4.8" | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 		"markdown-it": { |  | ||||||
| 			"version": "13.0.1", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz", |  | ||||||
| 			"integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==", |  | ||||||
| 			"requires": { |  | ||||||
| 				"argparse": "^2.0.1", |  | ||||||
| 				"entities": "~3.0.1", |  | ||||||
| 				"linkify-it": "^4.0.1", |  | ||||||
| 				"mdurl": "^1.0.1", |  | ||||||
| 				"uc.micro": "^1.0.5" |  | ||||||
| 			} |  | ||||||
| 		}, |  | ||||||
| 		"mdurl": { |  | ||||||
| 			"version": "1.0.1", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", |  | ||||||
| 			"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" |  | ||||||
| 		}, |  | ||||||
| 		"merge2": { | 		"merge2": { | ||||||
| 			"version": "1.4.1", | 			"version": "1.4.1", | ||||||
| 			"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", | 			"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", | ||||||
| @@ -4867,8 +4879,7 @@ | |||||||
| 		"ms": { | 		"ms": { | ||||||
| 			"version": "2.1.2", | 			"version": "2.1.2", | ||||||
| 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | ||||||
| 			"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", | 			"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" | ||||||
| 			"dev": true |  | ||||||
| 		}, | 		}, | ||||||
| 		"nanoid": { | 		"nanoid": { | ||||||
| 			"version": "3.3.4", | 			"version": "3.3.4", | ||||||
| @@ -5199,6 +5210,26 @@ | |||||||
| 			"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", | 			"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", | ||||||
| 			"dev": true | 			"dev": true | ||||||
| 		}, | 		}, | ||||||
|  | 		"socket.io-client": { | ||||||
|  | 			"version": "4.5.1", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.1.tgz", | ||||||
|  | 			"integrity": "sha512-e6nLVgiRYatS+AHXnOnGi4ocOpubvOUCGhyWw8v+/FxW8saHkinG6Dfhi9TU0Kt/8mwJIAASxvw6eujQmjdZVA==", | ||||||
|  | 			"requires": { | ||||||
|  | 				"@socket.io/component-emitter": "~3.1.0", | ||||||
|  | 				"debug": "~4.3.2", | ||||||
|  | 				"engine.io-client": "~6.2.1", | ||||||
|  | 				"socket.io-parser": "~4.2.0" | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
|  | 		"socket.io-parser": { | ||||||
|  | 			"version": "4.2.0", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.0.tgz", | ||||||
|  | 			"integrity": "sha512-tLfmEwcEwnlQTxFB7jibL/q2+q8dlVQzj4JdRLJ/W/G1+Fu9VSxCx1Lo+n1HvXxKnM//dUuD0xgiA7tQf57Vng==", | ||||||
|  | 			"requires": { | ||||||
|  | 				"@socket.io/component-emitter": "~3.1.0", | ||||||
|  | 				"debug": "~4.3.1" | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
| 		"sorcery": { | 		"sorcery": { | ||||||
| 			"version": "0.10.0", | 			"version": "0.10.0", | ||||||
| 			"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz", | 			"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz", | ||||||
| @@ -5436,11 +5467,6 @@ | |||||||
| 			"integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", | 			"integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", | ||||||
| 			"dev": true | 			"dev": true | ||||||
| 		}, | 		}, | ||||||
| 		"uc.micro": { |  | ||||||
| 			"version": "1.0.6", |  | ||||||
| 			"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", |  | ||||||
| 			"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" |  | ||||||
| 		}, |  | ||||||
| 		"uri-js": { | 		"uri-js": { | ||||||
| 			"version": "4.4.1", | 			"version": "4.4.1", | ||||||
| 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", | 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", | ||||||
| @@ -5506,6 +5532,17 @@ | |||||||
| 			"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", | 			"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", | ||||||
| 			"dev": true | 			"dev": true | ||||||
| 		}, | 		}, | ||||||
|  | 		"ws": { | ||||||
|  | 			"version": "8.2.3", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", | ||||||
|  | 			"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", | ||||||
|  | 			"requires": {} | ||||||
|  | 		}, | ||||||
|  | 		"xmlhttprequest-ssl": { | ||||||
|  | 			"version": "2.0.0", | ||||||
|  | 			"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", | ||||||
|  | 			"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" | ||||||
|  | 		}, | ||||||
| 		"xtend": { | 		"xtend": { | ||||||
| 			"version": "4.0.2", | 			"version": "4.0.2", | ||||||
| 			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", | 			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ | |||||||
| 		"@types/leaflet": "^1.7.10", | 		"@types/leaflet": "^1.7.10", | ||||||
| 		"@types/lodash": "^4.14.182", | 		"@types/lodash": "^4.14.182", | ||||||
| 		"@types/lodash-es": "^4.17.6", | 		"@types/lodash-es": "^4.17.6", | ||||||
|  | 		"@types/socket.io-client": "^3.0.0", | ||||||
| 		"@typescript-eslint/eslint-plugin": "^5.10.1", | 		"@typescript-eslint/eslint-plugin": "^5.10.1", | ||||||
| 		"@typescript-eslint/parser": "^5.10.1", | 		"@typescript-eslint/parser": "^5.10.1", | ||||||
| 		"autoprefixer": "^10.4.7", | 		"autoprefixer": "^10.4.7", | ||||||
| @@ -43,10 +44,12 @@ | |||||||
| 	"dependencies": { | 	"dependencies": { | ||||||
| 		"axios": "^0.27.2", | 		"axios": "^0.27.2", | ||||||
| 		"cookie": "^0.4.2", | 		"cookie": "^0.4.2", | ||||||
|  | 		"exifr": "^7.1.3", | ||||||
| 		"leaflet": "^1.8.0", | 		"leaflet": "^1.8.0", | ||||||
| 		"lodash": "^4.17.21", | 		"lodash": "^4.17.21", | ||||||
| 		"lodash-es": "^4.17.21", | 		"lodash-es": "^4.17.21", | ||||||
| 		"moment": "^2.29.3", | 		"moment": "^2.29.3", | ||||||
|  | 		"socket.io-client": "^4.5.1", | ||||||
| 		"svelte-material-icons": "^2.0.2" | 		"svelte-material-icons": "^2.0.2" | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -91,7 +91,7 @@ | |||||||
| 			<Close size="24" color="#232323" /> | 			<Close size="24" color="#232323" /> | ||||||
| 		</button> | 		</button> | ||||||
|  |  | ||||||
| 		<p class="text-black text-lg">Info</p> | 		<p class="text-immich-fg text-lg">Info</p> | ||||||
| 	</div> | 	</div> | ||||||
|  |  | ||||||
| 	<div class="px-4 py-4"> | 	<div class="px-4 py-4"> | ||||||
|   | |||||||
| @@ -2,16 +2,19 @@ | |||||||
| 	import { goto } from '$app/navigation'; | 	import { goto } from '$app/navigation'; | ||||||
| 	import { page } from '$app/stores'; | 	import { page } from '$app/stores'; | ||||||
| 	import type { ImmichUser } from '$lib/models/immich-user'; | 	import type { ImmichUser } from '$lib/models/immich-user'; | ||||||
| 	import { onMount } from 'svelte'; | 	import { createEventDispatcher, onMount } from 'svelte'; | ||||||
| 	import { fade } from 'svelte/transition'; | 	import { fade, fly, slide } from 'svelte/transition'; | ||||||
| 	import { postRequest } from '../../api'; | 	import { postRequest } from '../../api'; | ||||||
| 	import { serverEndpoint } from '../../constants'; | 	import { serverEndpoint } from '../../constants'; | ||||||
|  | 	import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte'; | ||||||
| 	import { clickOutside } from './click-outside'; | 	import { clickOutside } from './click-outside'; | ||||||
|  |  | ||||||
| 	export let user: ImmichUser; | 	export let user: ImmichUser; | ||||||
|  |  | ||||||
| 	let shouldShowAccountInfo = false; | 	let shouldShowAccountInfo = false; | ||||||
| 	let shouldShowProfileImage = false; | 	let shouldShowProfileImage = false; | ||||||
|  |  | ||||||
|  | 	const dispatch = createEventDispatcher(); | ||||||
| 	let shouldShowAccountInfoPanel = false; | 	let shouldShowAccountInfoPanel = false; | ||||||
| 	onMount(async () => { | 	onMount(async () => { | ||||||
| 		const res = await fetch(`${serverEndpoint}/user/profile-image/${user.id}`, { method: 'GET' }); | 		const res = await fetch(`${serverEndpoint}/user/profile-image/${user.id}`, { method: 'GET' }); | ||||||
| @@ -41,7 +44,7 @@ | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <section id="dashboard-navbar" class="fixed w-screen  z-[100] bg-immich-bg text-sm"> | <section id="dashboard-navbar" class="fixed w-screen  z-[100] bg-immich-bg text-sm"> | ||||||
| 	<div class="flex border place-items-center px-6 py-2 "> | 	<div class="flex border-b place-items-center px-6 py-2 "> | ||||||
| 		<a class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos"> | 		<a class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos"> | ||||||
| 			<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" /> | 			<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" /> | ||||||
| 			<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1> | 			<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1> | ||||||
| @@ -49,13 +52,21 @@ | |||||||
| 		<div class="flex-1 ml-24"> | 		<div class="flex-1 ml-24"> | ||||||
| 			<input class="w-[50%] border rounded-md bg-gray-200 px-8 py-4" placeholder="Search - Coming soon" /> | 			<input class="w-[50%] border rounded-md bg-gray-200 px-8 py-4" placeholder="Search - Coming soon" /> | ||||||
| 		</div> | 		</div> | ||||||
|  | 		<section class="flex gap-4 place-items-center"> | ||||||
| 		<section class="flex gap-6 place-items-center"> | 			{#if $page.url.pathname !== '/admin'} | ||||||
| 			<!-- <div>Upload</div> --> | 				<button | ||||||
|  | 					in:fly={{ x: 50, duration: 250 }} | ||||||
|  | 					on:click={() => dispatch('uploadClicked')} | ||||||
|  | 					class="flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium" | ||||||
|  | 				> | ||||||
|  | 					<TrayArrowUp size="20" /> | ||||||
|  | 					<span> Upload </span> | ||||||
|  | 				</button> | ||||||
|  | 			{/if} | ||||||
|  |  | ||||||
| 			{#if user.isAdmin} | 			{#if user.isAdmin} | ||||||
| 				<button | 				<button | ||||||
| 					class={`hover:text-immich-primary font-medium ${ | 					class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium ${ | ||||||
| 						$page.url.pathname == '/admin' && 'text-immich-primary underline' | 						$page.url.pathname == '/admin' && 'text-immich-primary underline' | ||||||
| 					}`} | 					}`} | ||||||
| 					on:click={navigateToAdmin}>Administration</button | 					on:click={navigateToAdmin}>Administration</button | ||||||
|   | |||||||
							
								
								
									
										191
									
								
								web/src/lib/components/shared/upload-panel.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								web/src/lib/components/shared/upload-panel.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,191 @@ | |||||||
|  | <script lang="ts"> | ||||||
|  | 	import { quartInOut } from 'svelte/easing'; | ||||||
|  | 	import { scale, fade } from 'svelte/transition'; | ||||||
|  | 	import { uploadAssetsStore } from '$lib/stores/upload'; | ||||||
|  | 	import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte'; | ||||||
|  | 	import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte'; | ||||||
|  | 	import type { UploadAsset } from '$lib/models/upload-asset'; | ||||||
|  |  | ||||||
|  | 	let showDetail = true; | ||||||
|  |  | ||||||
|  | 	let uploadLength = 0; | ||||||
|  |  | ||||||
|  | 	const showUploadImageThumbnail = async (a: UploadAsset) => { | ||||||
|  | 		const extension = a.fileExtension.toLowerCase(); | ||||||
|  |  | ||||||
|  | 		if (extension == 'jpeg' || extension == 'jpg' || extension == 'png') { | ||||||
|  | 			try { | ||||||
|  | 				const imgData = await a.file.arrayBuffer(); | ||||||
|  | 				const arrayBufferView = new Uint8Array(imgData); | ||||||
|  | 				const blob = new Blob([arrayBufferView], { type: 'image/jpeg' }); | ||||||
|  | 				const urlCreator = window.URL || window.webkitURL; | ||||||
|  | 				const imageUrl = urlCreator.createObjectURL(blob); | ||||||
|  | 				const img: any = document.getElementById(`${a.id}`); | ||||||
|  | 				img.src = imageUrl; | ||||||
|  | 			} catch (e) {} | ||||||
|  | 		} | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	function getSizeInHumanReadableFormat(sizeInByte: number) { | ||||||
|  | 		const pepibyte = 1.126 * Math.pow(10, 15); | ||||||
|  | 		const tebibyte = 1.1 * Math.pow(10, 12); | ||||||
|  | 		const gibibyte = 1.074 * Math.pow(10, 9); | ||||||
|  | 		const mebibyte = 1.049 * Math.pow(10, 6); | ||||||
|  | 		const kibibyte = 1024; | ||||||
|  | 		// Pebibyte | ||||||
|  | 		if (sizeInByte >= pepibyte) { | ||||||
|  | 			// Pe | ||||||
|  | 			return `${(sizeInByte / pepibyte).toFixed(1)}PB`; | ||||||
|  | 		} else if (tebibyte <= sizeInByte && sizeInByte < pepibyte) { | ||||||
|  | 			// Te | ||||||
|  | 			return `${(sizeInByte / tebibyte).toFixed(1)}TB`; | ||||||
|  | 		} else if (gibibyte <= sizeInByte && sizeInByte < tebibyte) { | ||||||
|  | 			// Gi | ||||||
|  | 			return `${(sizeInByte / gibibyte).toFixed(1)}GB`; | ||||||
|  | 		} else if (mebibyte <= sizeInByte && sizeInByte < gibibyte) { | ||||||
|  | 			// Mega | ||||||
|  | 			return `${(sizeInByte / mebibyte).toFixed(1)}MB`; | ||||||
|  | 		} else if (kibibyte <= sizeInByte && sizeInByte < mebibyte) { | ||||||
|  | 			// Kibi | ||||||
|  | 			return `${(sizeInByte / kibibyte).toFixed(1)}KB`; | ||||||
|  | 		} else { | ||||||
|  | 			return `${sizeInByte}B`; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Reactive action to get thumbnail image of upload asset whenever there is a new one added to the list | ||||||
|  | 	$: { | ||||||
|  | 		if ($uploadAssetsStore.length != uploadLength) { | ||||||
|  | 			$uploadAssetsStore.map((asset) => { | ||||||
|  | 				showUploadImageThumbnail(asset); | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			uploadLength = $uploadAssetsStore.length; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	$: { | ||||||
|  | 		if (showDetail) { | ||||||
|  | 			$uploadAssetsStore.map((asset) => { | ||||||
|  | 				showUploadImageThumbnail(asset); | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	let isUploading = false; | ||||||
|  |  | ||||||
|  | 	uploadAssetsStore.isUploading.subscribe((value) => (isUploading = value)); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | {#if isUploading} | ||||||
|  | 	<div | ||||||
|  | 		in:fade={{ duration: 250 }} | ||||||
|  | 		out:fade={{ duration: 250, delay: 1000 }} | ||||||
|  | 		class="absolute right-6 bottom-6 z-[10000]" | ||||||
|  | 	> | ||||||
|  | 		{#if showDetail} | ||||||
|  | 			<div | ||||||
|  | 				in:scale={{ duration: 250, easing: quartInOut }} | ||||||
|  | 				class="bg-gray-200 p-4 text-sm w-[300px] rounded-lg shadow-sm border " | ||||||
|  | 			> | ||||||
|  | 				<div class="flex justify-between place-item-center mb-4"> | ||||||
|  | 					<p class="text-xs text-gray-500">UPLOADING {$uploadAssetsStore.length}</p> | ||||||
|  | 					<button | ||||||
|  | 						on:click={() => (showDetail = false)} | ||||||
|  | 						class="w-[20px] h-[20px] bg-gray-50 rounded-full flex place-items-center place-content-center transition-colors hover:bg-gray-100" | ||||||
|  | 					> | ||||||
|  | 						<WindowMinimize /> | ||||||
|  | 					</button> | ||||||
|  | 				</div> | ||||||
|  |  | ||||||
|  | 				<div id="upload-item-list" class="max-h-[400px] overflow-y-auto pr-2 rounded-lg"> | ||||||
|  | 					{#each $uploadAssetsStore as uploadAsset} | ||||||
|  | 						<div | ||||||
|  | 							in:fade={{ duration: 250 }} | ||||||
|  | 							out:fade={{ duration: 100 }} | ||||||
|  | 							class="text-xs mt-3 rounded-lg bg-immich-bg grid grid-cols-[70px_auto] gap-2 h-[70px]" | ||||||
|  | 						> | ||||||
|  | 							<div class="relative"> | ||||||
|  | 								<img | ||||||
|  | 									in:fade={{ duration: 250 }} | ||||||
|  | 									id={`${uploadAsset.id}`} | ||||||
|  | 									src="/immich-logo.svg" | ||||||
|  | 									alt="" | ||||||
|  | 									class="h-[70px] w-[70px] object-cover rounded-tl-lg rounded-bl-lg " | ||||||
|  | 								/> | ||||||
|  |  | ||||||
|  | 								<div class="bottom-0 left-0 absolute w-full h-[25px] bg-immich-primary/30"> | ||||||
|  | 									<p | ||||||
|  | 										class="absolute bottom-1 right-1 object-right-bottom text-gray-50/95 font-semibold stroke-immich-primary uppercase" | ||||||
|  | 									> | ||||||
|  | 										.{uploadAsset.fileExtension} | ||||||
|  | 									</p> | ||||||
|  | 								</div> | ||||||
|  | 							</div> | ||||||
|  |  | ||||||
|  | 							<div class="p-2 pr-4 flex flex-col justify-between"> | ||||||
|  | 								<input | ||||||
|  | 									disabled | ||||||
|  | 									class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2" | ||||||
|  | 									value={`[${getSizeInHumanReadableFormat(uploadAsset.file.size)}] ${uploadAsset.file.name}`} | ||||||
|  | 								/> | ||||||
|  |  | ||||||
|  | 								<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative"> | ||||||
|  | 									<div | ||||||
|  | 										class="bg-immich-primary h-[15px] rounded-md transition-all" | ||||||
|  | 										style={`width: ${uploadAsset.progress}%`} | ||||||
|  | 									/> | ||||||
|  | 									<p class="absolute h-full w-full text-center top-0 text-[10px] ">{uploadAsset.progress}/100</p> | ||||||
|  | 								</div> | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 					{/each} | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  | 		{:else} | ||||||
|  | 			<div class="rounded-full"> | ||||||
|  | 				<button | ||||||
|  | 					in:scale={{ duration: 250, easing: quartInOut }} | ||||||
|  | 					on:click={() => (showDetail = true)} | ||||||
|  | 					class="absolute -top-4 -left-4 text-xs rounded-full w-10 h-10 p-5 flex place-items-center place-content-center bg-immich-primary text-gray-200" | ||||||
|  | 				> | ||||||
|  | 					{$uploadAssetsStore.length} | ||||||
|  | 				</button> | ||||||
|  | 				<button | ||||||
|  | 					in:scale={{ duration: 250, easing: quartInOut }} | ||||||
|  | 					on:click={() => (showDetail = true)} | ||||||
|  | 					class="bg-gray-300 p-5 rounded-full w-16 h-16 flex place-items-center place-content-center text-sm shadow-lg " | ||||||
|  | 				> | ||||||
|  | 					<div class="animate-pulse"> | ||||||
|  | 						<CloudUploadOutline size="30" color="#4250af" /> | ||||||
|  | 					</div> | ||||||
|  | 				</button> | ||||||
|  | 			</div> | ||||||
|  | 		{/if} | ||||||
|  | 	</div> | ||||||
|  | {/if} | ||||||
|  |  | ||||||
|  | <style> | ||||||
|  | 	/* width */ | ||||||
|  | 	#upload-item-list::-webkit-scrollbar { | ||||||
|  | 		width: 5px; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/* Track */ | ||||||
|  | 	#upload-item-list::-webkit-scrollbar-track { | ||||||
|  | 		background: #f1f1f1; | ||||||
|  | 		border-radius: 16px; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/* Handle */ | ||||||
|  | 	#upload-item-list::-webkit-scrollbar-thumb { | ||||||
|  | 		background: #4250af68; | ||||||
|  | 		border-radius: 16px; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/* Handle on hover */ | ||||||
|  | 	#upload-item-list::-webkit-scrollbar-thumb:hover { | ||||||
|  | 		background: #4250afad; | ||||||
|  | 		border-radius: 16px; | ||||||
|  | 	} | ||||||
|  | </style> | ||||||
							
								
								
									
										6
									
								
								web/src/lib/models/upload-asset.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								web/src/lib/models/upload-asset.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | export type UploadAsset = { | ||||||
|  | 	id: string; | ||||||
|  | 	file: File; | ||||||
|  | 	progress: number; | ||||||
|  | 	fileExtension: string; | ||||||
|  | }; | ||||||
| @@ -1,33 +1,29 @@ | |||||||
| import { writable, derived } from 'svelte/store'; | import { writable, derived } from 'svelte/store'; | ||||||
| import { getRequest } from '$lib/api'; | import { getRequest } from '$lib/api'; | ||||||
| import type { ImmichAsset } from '$lib/models/immich-asset' | import type { ImmichAsset } from '$lib/models/immich-asset'; | ||||||
| import lodash from 'lodash-es'; | import lodash from 'lodash-es'; | ||||||
| import moment from 'moment'; | import moment from 'moment'; | ||||||
|  |  | ||||||
| export const assets = writable<ImmichAsset[]>([]); | export const assets = writable<ImmichAsset[]>([]); | ||||||
|  |  | ||||||
| export const assetsGroupByDate = derived(assets, ($assets) => { | export const assetsGroupByDate = derived(assets, ($assets) => { | ||||||
|  |  | ||||||
| 	try { | 	try { | ||||||
|     return lodash.chain($assets) | 		return lodash | ||||||
|  | 			.chain($assets) | ||||||
| 			.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD')) | 			.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD')) | ||||||
| 			.sortBy((group) => $assets.indexOf(group[0])) | 			.sortBy((group) => $assets.indexOf(group[0])) | ||||||
| 			.value(); | 			.value(); | ||||||
| 	} catch (e) { | 	} catch (e) { | ||||||
|     console.log("error deriving state assets", e) | 		console.log('error deriving state assets', e); | ||||||
|     return [] | 		return []; | ||||||
| 	} | 	} | ||||||
|  | }); | ||||||
| }) |  | ||||||
|  |  | ||||||
| export const flattenAssetGroupByDate = derived(assetsGroupByDate, ($assetsGroupByDate) => { | export const flattenAssetGroupByDate = derived(assetsGroupByDate, ($assetsGroupByDate) => { | ||||||
| 	return $assetsGroupByDate.flat(); | 	return $assetsGroupByDate.flat(); | ||||||
| }) | }); | ||||||
|  |  | ||||||
| export const getAssetsInfo = async (accessToken: string) => { | export const getAssetsInfo = async (accessToken: string) => { | ||||||
| 	const res = await getRequest('asset', accessToken); | 	const res = await getRequest('asset', accessToken); | ||||||
|  |  | ||||||
| 	assets.set(res); | 	assets.set(res); | ||||||
|  | }; | ||||||
| } |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										45
									
								
								web/src/lib/stores/upload.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								web/src/lib/stores/upload.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | |||||||
|  | import { writable, derived } from 'svelte/store'; | ||||||
|  | import type { UploadAsset } from '../models/upload-asset'; | ||||||
|  |  | ||||||
|  | function createUploadStore() { | ||||||
|  | 	const uploadAssets = writable<Array<UploadAsset>>([]); | ||||||
|  |  | ||||||
|  | 	const { subscribe } = uploadAssets; | ||||||
|  |  | ||||||
|  | 	const isUploading = derived(uploadAssets, ($uploadAssets) => { | ||||||
|  | 		return $uploadAssets.length > 0 ? true : false; | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	const addNewUploadAsset = (newAsset: UploadAsset) => { | ||||||
|  | 		uploadAssets.update((currentSet) => [...currentSet, newAsset]); | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	const updateProgress = (id: string, progress: number) => { | ||||||
|  | 		uploadAssets.update((uploadingAssets) => { | ||||||
|  | 			return uploadingAssets.map((asset) => { | ||||||
|  | 				if (asset.id == id) { | ||||||
|  | 					return { | ||||||
|  | 						...asset, | ||||||
|  | 						progress: progress, | ||||||
|  | 					}; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				return asset; | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	const removeUploadAsset = (id: string) => { | ||||||
|  | 		uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id)); | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	return { | ||||||
|  | 		subscribe, | ||||||
|  | 		isUploading, | ||||||
|  | 		addNewUploadAsset, | ||||||
|  | 		updateProgress, | ||||||
|  | 		removeUploadAsset, | ||||||
|  | 	}; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const uploadAssetsStore = createUploadStore(); | ||||||
							
								
								
									
										30
									
								
								web/src/lib/stores/websocket.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								web/src/lib/stores/websocket.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | import { Socket, io } from 'socket.io-client'; | ||||||
|  | import { serverEndpoint } from '../constants'; | ||||||
|  | import type { ImmichAsset } from '../models/immich-asset'; | ||||||
|  | import { assets } from './assets'; | ||||||
|  |  | ||||||
|  | export const openWebsocketConnection = (accessToken: string) => { | ||||||
|  | 	const websocket = io(serverEndpoint, { | ||||||
|  | 		transports: ['polling'], | ||||||
|  | 		reconnection: true, | ||||||
|  | 		forceNew: true, | ||||||
|  | 		autoConnect: true, | ||||||
|  | 		extraHeaders: { | ||||||
|  | 			Authorization: 'Bearer ' + accessToken, | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	listenToEvent(websocket); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const listenToEvent = (socket: Socket) => { | ||||||
|  | 	socket.on('on_upload_success', (data) => { | ||||||
|  | 		const newUploadedAsset: ImmichAsset = JSON.parse(data); | ||||||
|  |  | ||||||
|  | 		assets.update((assets) => [...assets, newUploadedAsset]); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	socket.on('error', (e) => { | ||||||
|  | 		console.log('Websocket Error', e); | ||||||
|  | 	}); | ||||||
|  | }; | ||||||
							
								
								
									
										113
									
								
								web/src/lib/utils/file-uploader.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								web/src/lib/utils/file-uploader.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | |||||||
|  | import * as exifr from 'exifr'; | ||||||
|  | import { serverEndpoint } from '../constants'; | ||||||
|  | import { uploadAssetsStore } from '$lib/stores/upload'; | ||||||
|  | import type { UploadAsset } from '../models/upload-asset'; | ||||||
|  |  | ||||||
|  | export async function fileUploader(asset: File, accessToken: string) { | ||||||
|  | 	const assetType = asset.type.split('/')[0].toUpperCase(); | ||||||
|  | 	const temp = asset.name.split('.'); | ||||||
|  | 	const fileExtension = temp[temp.length - 1]; | ||||||
|  | 	const formData = new FormData(); | ||||||
|  |  | ||||||
|  | 	try { | ||||||
|  | 		let exifData = null; | ||||||
|  |  | ||||||
|  | 		if (assetType !== 'VIDEO') { | ||||||
|  | 			exifData = await exifr.parse(asset); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const createdAt = | ||||||
|  | 			exifData && exifData.DateTimeOriginal != null | ||||||
|  | 				? new Date(exifData.DateTimeOriginal).toISOString() | ||||||
|  | 				: new Date(asset.lastModified).toISOString(); | ||||||
|  |  | ||||||
|  | 		const deviceAssetId = 'web' + '-' + asset.name + '-' + asset.lastModified; | ||||||
|  |  | ||||||
|  | 		// Create and add Unique ID of asset on the device | ||||||
|  | 		formData.append('deviceAssetId', deviceAssetId); | ||||||
|  |  | ||||||
|  | 		// Get device id - for web -> use WEB | ||||||
|  | 		formData.append('deviceId', 'WEB'); | ||||||
|  |  | ||||||
|  | 		// Get asset type | ||||||
|  | 		formData.append('assetType', assetType); | ||||||
|  |  | ||||||
|  | 		// Get Asset Created Date | ||||||
|  | 		formData.append('createdAt', createdAt); | ||||||
|  |  | ||||||
|  | 		// Get Asset Modified At | ||||||
|  | 		formData.append('modifiedAt', new Date(asset.lastModified).toISOString()); | ||||||
|  |  | ||||||
|  | 		// Set Asset is Favorite to false | ||||||
|  | 		formData.append('isFavorite', 'false'); | ||||||
|  |  | ||||||
|  | 		// Get asset duration | ||||||
|  | 		formData.append('duration', '0:00:00.000000'); | ||||||
|  |  | ||||||
|  | 		// Get asset file extension | ||||||
|  | 		formData.append('fileExtension', '.' + fileExtension); | ||||||
|  |  | ||||||
|  | 		// Get asset binary data. | ||||||
|  | 		formData.append('assetData', asset); | ||||||
|  |  | ||||||
|  | 		// Check if asset upload on server before performing upload | ||||||
|  | 		const res = await fetch(serverEndpoint + '/asset/check', { | ||||||
|  | 			method: 'POST', | ||||||
|  | 			body: JSON.stringify({ deviceAssetId }), | ||||||
|  | 			headers: { | ||||||
|  | 				Authorization: 'Bearer ' + accessToken, | ||||||
|  | 				'Content-Type': 'application/json', | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (res.status === 200) { | ||||||
|  | 			const { isExist } = await res.json(); | ||||||
|  |  | ||||||
|  | 			if (isExist) { | ||||||
|  | 				return; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const request = new XMLHttpRequest(); | ||||||
|  |  | ||||||
|  | 		request.upload.onloadstart = () => { | ||||||
|  | 			const newUploadAsset: UploadAsset = { | ||||||
|  | 				id: deviceAssetId, | ||||||
|  | 				file: asset, | ||||||
|  | 				progress: 0, | ||||||
|  | 				fileExtension: fileExtension, | ||||||
|  | 			}; | ||||||
|  |  | ||||||
|  | 			uploadAssetsStore.addNewUploadAsset(newUploadAsset); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		request.upload.onload = () => { | ||||||
|  | 			setTimeout(() => { | ||||||
|  | 				uploadAssetsStore.removeUploadAsset(deviceAssetId); | ||||||
|  | 			}, 2500); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		// listen for `error` event | ||||||
|  | 		request.upload.onerror = () => { | ||||||
|  | 			uploadAssetsStore.removeUploadAsset(deviceAssetId); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		// listen for `abort` event | ||||||
|  | 		request.upload.onabort = () => { | ||||||
|  | 			uploadAssetsStore.removeUploadAsset(deviceAssetId); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		// listen for `progress` event | ||||||
|  | 		request.upload.onprogress = (event) => { | ||||||
|  | 			const percentComplete = Math.floor((event.loaded / event.total) * 100); | ||||||
|  | 			uploadAssetsStore.updateProgress(deviceAssetId, percentComplete); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		request.open('POST', `${serverEndpoint}/asset/upload`); | ||||||
|  | 		request.setRequestHeader('Authorization', `Bearer ${accessToken}`); | ||||||
|  |  | ||||||
|  | 		request.send(formData); | ||||||
|  | 	} catch (e) { | ||||||
|  | 		console.log('error uploading file ', e); | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -22,8 +22,8 @@ | |||||||
| 	import { blur } from 'svelte/transition'; | 	import { blur } from 'svelte/transition'; | ||||||
|  |  | ||||||
| 	import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte'; | 	import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte'; | ||||||
| 	import FullScreenModal from '../lib/components/shared/full-screen-modal.svelte'; | 	import AnnouncementBox from '$lib/components/shared/announcement-box.svelte'; | ||||||
| 	import AnnouncementBox from '../lib/components/shared/announcement-box.svelte'; | 	import UploadPanel from '$lib/components/shared/upload-panel.svelte'; | ||||||
|  |  | ||||||
| 	export let url: string; | 	export let url: string; | ||||||
| 	export let shouldShowAnnouncement: boolean; | 	export let shouldShowAnnouncement: boolean; | ||||||
| @@ -36,7 +36,7 @@ | |||||||
| 		<div transition:blur={{ duration: 250 }}> | 		<div transition:blur={{ duration: 250 }}> | ||||||
| 			<slot /> | 			<slot /> | ||||||
| 			<DownloadPanel /> | 			<DownloadPanel /> | ||||||
|  | 			<UploadPanel /> | ||||||
| 			{#if shouldShowAnnouncement} | 			{#if shouldShowAnnouncement} | ||||||
| 				<AnnouncementBox {localVersion} {remoteVersion} on:close={() => (shouldShowAnnouncement = false)} /> | 				<AnnouncementBox {localVersion} {remoteVersion} on:close={() => (shouldShowAnnouncement = false)} /> | ||||||
| 			{/if} | 			{/if} | ||||||
|   | |||||||
| @@ -35,6 +35,7 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	import { serverEndpoint } from '$lib/constants'; | 	import { serverEndpoint } from '$lib/constants'; | ||||||
| 	import { goto } from '$app/navigation'; | 	import { goto } from '$app/navigation'; | ||||||
|  | 	import { onMount } from 'svelte'; | ||||||
|  |  | ||||||
| 	export let isAdminUserExist: boolean; | 	export let isAdminUserExist: boolean; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -41,6 +41,8 @@ | |||||||
| 	import AssetViewer from '../../lib/components/asset-viewer/asset-viewer.svelte'; | 	import AssetViewer from '../../lib/components/asset-viewer/asset-viewer.svelte'; | ||||||
| 	import DownloadPanel from '../../lib/components/asset-viewer/download-panel.svelte'; | 	import DownloadPanel from '../../lib/components/asset-viewer/download-panel.svelte'; | ||||||
| 	import StatusBox from '../../lib/components/shared/status-box.svelte'; | 	import StatusBox from '../../lib/components/shared/status-box.svelte'; | ||||||
|  | 	import { fileUploader } from '../../lib/utils/file-uploader'; | ||||||
|  | 	import { openWebsocketConnection } from '../../lib/stores/websocket'; | ||||||
|  |  | ||||||
| 	export let user: ImmichUser; | 	export let user: ImmichUser; | ||||||
| 	let selectedAction: AppSideBarSelection; | 	let selectedAction: AppSideBarSelection; | ||||||
| @@ -64,6 +66,8 @@ | |||||||
|  |  | ||||||
| 		if ($session.user) { | 		if ($session.user) { | ||||||
| 			await getAssetsInfo($session.user.accessToken); | 			await getAssetsInfo($session.user.accessToken); | ||||||
|  |  | ||||||
|  | 			openWebsocketConnection($session.user.accessToken); | ||||||
| 		} | 		} | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| @@ -79,7 +83,34 @@ | |||||||
| 		currentViewAssetIndex = $flattenAssetGroupByDate.findIndex((a) => a.id == assetId); | 		currentViewAssetIndex = $flattenAssetGroupByDate.findIndex((a) => a.id == assetId); | ||||||
| 		currentSelectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex]; | 		currentSelectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex]; | ||||||
| 		isShowAsset = true; | 		isShowAsset = true; | ||||||
| 		// pushState(assetId); | 	}; | ||||||
|  |  | ||||||
|  | 	const uploadClickedHandler = async () => { | ||||||
|  | 		if ($session.user) { | ||||||
|  | 			try { | ||||||
|  | 				let fileSelector = document.createElement('input'); | ||||||
|  |  | ||||||
|  | 				fileSelector.type = 'file'; | ||||||
|  | 				fileSelector.multiple = true; | ||||||
|  | 				fileSelector.accept = 'image/*,video/*,.heic,.heif'; | ||||||
|  |  | ||||||
|  | 				fileSelector.onchange = async (e: any) => { | ||||||
|  | 					const files = Array.from<File>(e.target.files); | ||||||
|  |  | ||||||
|  | 					const acceptedFile = files.filter( | ||||||
|  | 						(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image', | ||||||
|  | 					); | ||||||
|  |  | ||||||
|  | 					for (const asset of acceptedFile) { | ||||||
|  | 						await fileUploader(asset, $session.user!.accessToken); | ||||||
|  | 					} | ||||||
|  | 				}; | ||||||
|  |  | ||||||
|  | 				fileSelector.click(); | ||||||
|  | 			} catch (e) { | ||||||
|  | 				console.log('Error seelcting file', e); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
| 	}; | 	}; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| @@ -88,10 +119,10 @@ | |||||||
| </svelte:head> | </svelte:head> | ||||||
|  |  | ||||||
| <section> | <section> | ||||||
| 	<NavigationBar {user} /> | 	<NavigationBar {user} on:uploadClicked={uploadClickedHandler} /> | ||||||
| </section> | </section> | ||||||
|  |  | ||||||
| <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen"> | <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg"> | ||||||
| 	<!-- Sidebar --> | 	<!-- Sidebar --> | ||||||
| 	<section id="sidebar" class="flex flex-col gap-4 pt-8 pr-6"> | 	<section id="sidebar" class="flex flex-col gap-4 pt-8 pr-6"> | ||||||
| 		<SideBarButton | 		<SideBarButton | ||||||
| @@ -111,7 +142,7 @@ | |||||||
|  |  | ||||||
| 	<!-- Main Section --> | 	<!-- Main Section --> | ||||||
| 	<section class="overflow-y-auto relative"> | 	<section class="overflow-y-auto relative"> | ||||||
| 		<section id="assets-content" class="relative pt-8 pl-4"> | 		<section id="assets-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg"> | ||||||
| 			<section id="image-grid" class="flex flex-wrap gap-14"> | 			<section id="image-grid" class="flex flex-wrap gap-14"> | ||||||
| 				{#each $assetsGroupByDate as assetsInDateGroup, groupIndex} | 				{#each $assetsGroupByDate as assetsInDateGroup, groupIndex} | ||||||
| 					<!-- Asset Group By Date --> | 					<!-- Asset Group By Date --> | ||||||
| @@ -121,7 +152,7 @@ | |||||||
| 						on:mouseleave={() => (isMouseOverGroup = false)} | 						on:mouseleave={() => (isMouseOverGroup = false)} | ||||||
| 					> | 					> | ||||||
| 						<!-- Date group title --> | 						<!-- Date group title --> | ||||||
| 						<p class="font-medium text-sm text-black mb-2 flex place-items-center h-6"> | 						<p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6"> | ||||||
| 							{#if selectedGroupThumbnail === groupIndex && isMouseOverGroup} | 							{#if selectedGroupThumbnail === groupIndex && isMouseOverGroup} | ||||||
| 								<div | 								<div | ||||||
| 									in:fly={{ x: -24, duration: 200, opacity: 0.5 }} | 									in:fly={{ x: -24, duration: 200, opacity: 0.5 }} | ||||||
| @@ -136,7 +167,7 @@ | |||||||
| 						</p> | 						</p> | ||||||
|  |  | ||||||
| 						<!-- Image grid --> | 						<!-- Image grid --> | ||||||
| 						<div class="flex flex-wrap gap-1"> | 						<div class="flex flex-wrap gap-[2px]"> | ||||||
| 							{#each assetsInDateGroup as asset} | 							{#each assetsInDateGroup as asset} | ||||||
| 								<ImmichThumbnail | 								<ImmichThumbnail | ||||||
| 									{asset} | 									{asset} | ||||||
|   | |||||||
| @@ -5,6 +5,10 @@ module.exports = { | |||||||
| 			colors: { | 			colors: { | ||||||
| 				'immich-primary': '#4250af', | 				'immich-primary': '#4250af', | ||||||
| 				'immich-bg': '#f6f8fe', | 				'immich-bg': '#f6f8fe', | ||||||
|  | 				'immich-fg': 'black', | ||||||
|  |  | ||||||
|  | 				// 'immich-bg': '#121212', | ||||||
|  | 				// 'immich-fg': '#D0D0D0', | ||||||
| 			}, | 			}, | ||||||
| 			fontFamily: { | 			fontFamily: { | ||||||
| 				'immich-title': ['Snowburst One', 'cursive'], | 				'immich-title': ['Snowburst One', 'cursive'], | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user