mirror of
https://github.com/immich-app/immich.git
synced 2024-11-24 08:52:28 +02: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:
parent
b7603fd150
commit
1e3464fe47
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
|
49
README.md
49
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.**
|
||||||
|
|
||||||
![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)
|
![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)
|
||||||
|
|
||||||
@ -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
|
||||||
@ -55,26 +56,22 @@ Loading ~4000 images/videos
|
|||||||
|
|
||||||
This project is under heavy development, there will be continuous functions, features and api changes.
|
This project is under heavy development, there will be continuous functions, features and api changes.
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
|
| | Mobile | Web |
|
||||||
|
| - | - | - |
|
||||||
|
| Upload and view videos and photos | Yes | Yes
|
||||||
|
| Auto backup when app is opened | Yes | N/A
|
||||||
|
| Selective album(s) for backup | Yes | N/A
|
||||||
|
| Download photos and videos to local device | Yes | Yes
|
||||||
|
| Multi-user support | Yes | Yes
|
||||||
|
| Shared Albums | Yes | No
|
||||||
|
| Quick navigation with draggable scrollbar | Yes | Yes
|
||||||
|
| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
|
||||||
|
| Metadata view (EXIF, map) | Yes | Yes
|
||||||
|
| Search by metadata, objects and image tags | Yes | No
|
||||||
|
| Administrative functions (user management) | No | Yes
|
||||||
|
|
||||||
- Upload and view assets (videos/images).
|
|
||||||
- Auto Backup.
|
|
||||||
- Download asset to local device.
|
|
||||||
- Multi-user supported.
|
|
||||||
- Quick navigation with drag scroll bar.
|
|
||||||
- Support HEIC/HEIF Backup.
|
|
||||||
- Extract and display EXIF info.
|
|
||||||
- Real-time render from multi-device upload event.
|
|
||||||
- Image Tagging/Classification based on ImageNet dataset
|
|
||||||
- Object detection based on COCO SSD.
|
|
||||||
- Search assets based on tags and exif data (lens, make, model, orientation)
|
|
||||||
- [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month)
|
|
||||||
- Show asset's location information on map (OpenStreetMap).
|
|
||||||
- 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,27 +22,33 @@ 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 {
|
||||||
const accessToken = client.handshake.headers.authorization.split(' ')[1];
|
Logger.log(`New websocket connection: ${client.id}`, 'WebsocketConnectionEvent');
|
||||||
const res = await this.immichJwtService.validateToken(accessToken);
|
|
||||||
|
|
||||||
if (!res.status) {
|
const accessToken = client.handshake.headers.authorization.split(' ')[1];
|
||||||
client.emit('error', 'unauthorized');
|
|
||||||
client.disconnect();
|
const res = await this.immichJwtService.validateToken(accessToken);
|
||||||
return;
|
|
||||||
|
if (!res.status) {
|
||||||
|
client.emit('error', 'unauthorized');
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userRepository.findOne({ where: { id: res.userId } });
|
||||||
|
if (!user) {
|
||||||
|
client.emit('error', 'unauthorized');
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.join(user.id);
|
||||||
|
} catch (e) {
|
||||||
|
// Logger.error(`Error establish websocket conneciton ${e}`, 'HandleWebscoketConnection');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findOne({ where: { id: res.userId } });
|
|
||||||
if (!user) {
|
|
||||||
client.emit('error', 'unauthorized');
|
|
||||||
client.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.join(user.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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==",
|
||||||
|
"dependencies": {
|
||||||
|
"@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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/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==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=10.0.0"
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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
|
||||||
return lodash.chain($assets)
|
.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'],
|
||||||
|
Loading…
Reference in New Issue
Block a user