mirror of
https://github.com/immich-app/immich.git
synced 2025-06-01 19:19:37 +02:00
Merge ce79fb3214
into 963dd3210a
This commit is contained in:
commit
91ffe68d46
23 changed files with 570 additions and 3 deletions
i18n
mobile/openapi
open-api
server
src
controllers
dtos
queries
repositories
services
test
web/src
lib
routes/(user)/utilities/large-assets/[[photos=photos]]/[[assetId=id]]
|
@ -1125,6 +1125,7 @@
|
|||
"language_setting_description": "Select your preferred language",
|
||||
"last_seen": "Last seen",
|
||||
"latest_version": "Latest Version",
|
||||
"large_assets": "Large Assets",
|
||||
"latitude": "Latitude",
|
||||
"leave": "Leave",
|
||||
"lens_model": "Lens model",
|
||||
|
|
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
|
@ -130,6 +130,7 @@ Class | Method | HTTP request | Description
|
|||
*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs |
|
||||
*JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs |
|
||||
*JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} |
|
||||
*LargeAssetsApi* | [**getLargeAssets**](doc//LargeAssetsApi.md#getlargeassets) | **GET** /large-assets |
|
||||
*LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries |
|
||||
*LibrariesApi* | [**deleteLibrary**](doc//LibrariesApi.md#deletelibrary) | **DELETE** /libraries/{id} |
|
||||
*LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries |
|
||||
|
@ -349,6 +350,7 @@ Class | Method | HTTP request | Description
|
|||
- [JobName](doc//JobName.md)
|
||||
- [JobSettingsDto](doc//JobSettingsDto.md)
|
||||
- [JobStatusDto](doc//JobStatusDto.md)
|
||||
- [LargeAssetsResponseDto](doc//LargeAssetsResponseDto.md)
|
||||
- [LibraryResponseDto](doc//LibraryResponseDto.md)
|
||||
- [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md)
|
||||
- [LicenseKeyDto](doc//LicenseKeyDto.md)
|
||||
|
|
2
mobile/openapi/lib/api.dart
generated
2
mobile/openapi/lib/api.dart
generated
|
@ -40,6 +40,7 @@ part 'api/download_api.dart';
|
|||
part 'api/duplicates_api.dart';
|
||||
part 'api/faces_api.dart';
|
||||
part 'api/jobs_api.dart';
|
||||
part 'api/large_assets_api.dart';
|
||||
part 'api/libraries_api.dart';
|
||||
part 'api/map_api.dart';
|
||||
part 'api/memories_api.dart';
|
||||
|
@ -144,6 +145,7 @@ part 'model/job_create_dto.dart';
|
|||
part 'model/job_name.dart';
|
||||
part 'model/job_settings_dto.dart';
|
||||
part 'model/job_status_dto.dart';
|
||||
part 'model/large_assets_response_dto.dart';
|
||||
part 'model/library_response_dto.dart';
|
||||
part 'model/library_stats_response_dto.dart';
|
||||
part 'model/license_key_dto.dart';
|
||||
|
|
67
mobile/openapi/lib/api/large_assets_api.dart
generated
Normal file
67
mobile/openapi/lib/api/large_assets_api.dart
generated
Normal file
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class LargeAssetsApi {
|
||||
LargeAssetsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Performs an HTTP 'GET /large-assets' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [num] take (required):
|
||||
Future<Response> getLargeAssetsWithHttpInfo(num take,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/large-assets';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
queryParams.addAll(_queryParams('', 'take', take));
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [num] take (required):
|
||||
Future<LargeAssetsResponseDto?> getLargeAssets(num take,) async {
|
||||
final response = await getLargeAssetsWithHttpInfo(take,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LargeAssetsResponseDto',) as LargeAssetsResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
|
@ -344,6 +344,8 @@ class ApiClient {
|
|||
return JobSettingsDto.fromJson(value);
|
||||
case 'JobStatusDto':
|
||||
return JobStatusDto.fromJson(value);
|
||||
case 'LargeAssetsResponseDto':
|
||||
return LargeAssetsResponseDto.fromJson(value);
|
||||
case 'LibraryResponseDto':
|
||||
return LibraryResponseDto.fromJson(value);
|
||||
case 'LibraryStatsResponseDto':
|
||||
|
|
99
mobile/openapi/lib/model/large_assets_response_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/large_assets_response_dto.dart
generated
Normal file
|
@ -0,0 +1,99 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class LargeAssetsResponseDto {
|
||||
/// Returns a new [LargeAssetsResponseDto] instance.
|
||||
LargeAssetsResponseDto({
|
||||
this.assets = const [],
|
||||
});
|
||||
|
||||
List<AssetResponseDto> assets;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is LargeAssetsResponseDto &&
|
||||
_deepEquality.equals(other.assets, assets);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assets.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'LargeAssetsResponseDto[assets=$assets]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assets'] = this.assets;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [LargeAssetsResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static LargeAssetsResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "LargeAssetsResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return LargeAssetsResponseDto(
|
||||
assets: AssetResponseDto.listFromJson(json[r'assets']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<LargeAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <LargeAssetsResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = LargeAssetsResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, LargeAssetsResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, LargeAssetsResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = LargeAssetsResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of LargeAssetsResponseDto-objects as value to a dart map
|
||||
static Map<String, List<LargeAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<LargeAssetsResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = LargeAssetsResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'assets',
|
||||
};
|
||||
}
|
||||
|
|
@ -3021,6 +3021,49 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/large-assets": {
|
||||
"get": {
|
||||
"operationId": "getLargeAssets",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "take",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LargeAssetsResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"LargeAssets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/libraries": {
|
||||
"get": {
|
||||
"operationId": "getAllLibraries",
|
||||
|
@ -10129,6 +10172,20 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LargeAssetsResponseDto": {
|
||||
"properties": {
|
||||
"assets": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assets"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LibraryResponseDto": {
|
||||
"properties": {
|
||||
"assetCount": {
|
||||
|
|
|
@ -640,6 +640,9 @@ export type JobCommandDto = {
|
|||
command: JobCommand;
|
||||
force?: boolean;
|
||||
};
|
||||
export type LargeAssetsResponseDto = {
|
||||
assets: AssetResponseDto[];
|
||||
};
|
||||
export type LibraryResponseDto = {
|
||||
assetCount: number;
|
||||
createdAt: string;
|
||||
|
@ -2268,6 +2271,18 @@ export function sendJobCommand({ id, jobCommandDto }: {
|
|||
body: jobCommandDto
|
||||
})));
|
||||
}
|
||||
export function getLargeAssets({ take }: {
|
||||
take: number;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: LargeAssetsResponseDto;
|
||||
}>(`/large-assets${QS.query(QS.explode({
|
||||
take
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
export function getAllLibraries(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
|
|
|
@ -9,6 +9,7 @@ import { DownloadController } from 'src/controllers/download.controller';
|
|||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||
import { FaceController } from 'src/controllers/face.controller';
|
||||
import { JobController } from 'src/controllers/job.controller';
|
||||
import { LargeAssetsController } from 'src/controllers/large-assets.controller';
|
||||
import { LibraryController } from 'src/controllers/library.controller';
|
||||
import { MapController } from 'src/controllers/map.controller';
|
||||
import { MemoryController } from 'src/controllers/memory.controller';
|
||||
|
@ -42,6 +43,7 @@ export const controllers = [
|
|||
AuthController,
|
||||
DownloadController,
|
||||
DuplicateController,
|
||||
LargeAssetsController,
|
||||
FaceController,
|
||||
JobController,
|
||||
LibraryController,
|
||||
|
|
19
server/src/controllers/large-assets.controller.ts
Normal file
19
server/src/controllers/large-assets.controller.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { GetLargeAssetsRequestDto, LargeAssetsResponseDto } from 'src/dtos/large-assets.dto';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { LargeAssetsService } from 'src/services/large-assets.service';
|
||||
|
||||
@ApiTags('LargeAssets')
|
||||
@Controller('large-assets')
|
||||
export class LargeAssetsController {
|
||||
constructor(private service: LargeAssetsService) {}
|
||||
|
||||
@Get()
|
||||
@Authenticated()
|
||||
getLargeAssets(@Auth() auth: AuthDto, @Query() dto: GetLargeAssetsRequestDto): Promise<LargeAssetsResponseDto> {
|
||||
console.log('take', dto);
|
||||
return this.service.getLargeAssets(auth, dto.take);
|
||||
}
|
||||
}
|
15
server/src/dtos/large-assets.dto.ts
Normal file
15
server/src/dtos/large-assets.dto.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Max, Min } from 'class-validator';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
|
||||
export class LargeAssetsResponseDto {
|
||||
assets!: AssetResponseDto[];
|
||||
}
|
||||
|
||||
export class GetLargeAssetsRequestDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
@Type(() => Number)
|
||||
take!: number;
|
||||
}
|
|
@ -231,6 +231,21 @@ where
|
|||
limit
|
||||
$3
|
||||
|
||||
-- AssetRepository.getLargeAssets
|
||||
select
|
||||
"assets".*,
|
||||
to_json("exif") as "exifInfo"
|
||||
from
|
||||
"assets"
|
||||
left join "exif" on "assets"."id" = "exif"."assetId"
|
||||
where
|
||||
"ownerId" = $1
|
||||
and "deletedAt" is null
|
||||
order by
|
||||
"exif"."fileSizeInByte" desc
|
||||
limit
|
||||
$2
|
||||
|
||||
-- AssetRepository.getTimeBuckets
|
||||
with
|
||||
"assets" as (
|
||||
|
|
|
@ -531,6 +531,19 @@ export class AssetRepository {
|
|||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, 5] })
|
||||
getLargeAssets(ownerId: string, take: number) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
.$call(withExif)
|
||||
.where('ownerId', '=', ownerId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('exif.fileSizeInByte', 'desc')
|
||||
.limit(take)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
|
||||
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
|
||||
return (
|
||||
|
|
|
@ -12,6 +12,7 @@ import { DatabaseService } from 'src/services/database.service';
|
|||
import { DownloadService } from 'src/services/download.service';
|
||||
import { DuplicateService } from 'src/services/duplicate.service';
|
||||
import { JobService } from 'src/services/job.service';
|
||||
import { LargeAssetsService } from 'src/services/large-assets.service';
|
||||
import { LibraryService } from 'src/services/library.service';
|
||||
import { MapService } from 'src/services/map.service';
|
||||
import { MediaService } from 'src/services/media.service';
|
||||
|
@ -54,6 +55,7 @@ export const services = [
|
|||
DatabaseService,
|
||||
DownloadService,
|
||||
DuplicateService,
|
||||
LargeAssetsService,
|
||||
JobService,
|
||||
LibraryService,
|
||||
MapService,
|
||||
|
|
14
server/src/services/large-assets.service.ts
Normal file
14
server/src/services/large-assets.service.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { LargeAssetsResponseDto } from 'src/dtos/large-assets.dto';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
||||
@Injectable()
|
||||
export class LargeAssetsService extends BaseService {
|
||||
async getLargeAssets(auth: AuthDto, take: number): Promise<LargeAssetsResponseDto> {
|
||||
const largeAssets = await this.assetRepository.getLargeAssets(auth.user.id, take);
|
||||
|
||||
return { assets: largeAssets.map((asset) => mapAsset(asset, { auth })) };
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import { Kysely } from 'kysely';
|
||||
import { DB } from 'src/db';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { LargeAssetsService } from 'src/services/large-assets.service';
|
||||
import { mediumFactory, newMediumService } from 'test/medium.factory';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
import { beforeEach, vitest } from 'vitest';
|
||||
|
||||
vitest.useFakeTimers();
|
||||
|
||||
describe(LargeAssetsService.name, () => {
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
let assetRepo: AssetRepository;
|
||||
let userRepo: UserRepository;
|
||||
|
||||
const createSut = (db?: Kysely<DB>) => {
|
||||
return newMediumService(LargeAssetsService, {
|
||||
database: db || defaultDatabase,
|
||||
repos: {
|
||||
asset: 'real',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
|
||||
assetRepo = new AssetRepository(defaultDatabase);
|
||||
userRepo = new UserRepository(defaultDatabase);
|
||||
});
|
||||
|
||||
beforeEach(() => {});
|
||||
|
||||
it('should work', () => {
|
||||
const { sut } = createSut();
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return assets', async () => {
|
||||
const { sut } = createSut();
|
||||
const user = mediumFactory.userInsert();
|
||||
await userRepo.create(user);
|
||||
|
||||
const assets = [];
|
||||
const sizes = [12_334, 599, 123_456];
|
||||
|
||||
for (let i = 0; i < sizes.length; i++) {
|
||||
const asset = mediumFactory.assetInsert({ ownerId: user.id });
|
||||
await assetRepo.create(asset);
|
||||
await assetRepo.upsertExif({ assetId: asset.id, fileSizeInByte: sizes[i] });
|
||||
|
||||
assets.push(asset);
|
||||
}
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
await expect(sut.getLargeAssets(auth, 50)).resolves.toEqual({
|
||||
assets: [
|
||||
expect.objectContaining({ id: assets[2].id }),
|
||||
expect.objectContaining({ id: assets[0].id }),
|
||||
expect.objectContaining({ id: assets[1].id }),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -35,6 +35,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
|||
getAllForUserFullSync: vitest.fn(),
|
||||
getChangedDeltaSync: vitest.fn(),
|
||||
getDuplicates: vitest.fn(),
|
||||
getLargeAssets: vitest.fn(),
|
||||
upsertFile: vitest.fn(),
|
||||
upsertFiles: vitest.fn(),
|
||||
deleteFiles: vitest.fn(),
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onViewAsset: (asset: AssetResponseDto) => void;
|
||||
}
|
||||
|
||||
let { asset, onViewAsset }: Props = $props();
|
||||
|
||||
let assetData = $derived(JSON.stringify(asset, null, 2));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="max-w-60 rounded-xl border-4 transition-colors font-semibold text-xs bg-gray-200 dark:bg-gray-800 border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<button type="button" onclick={() => onViewAsset(asset)} class="block relative w-full" aria-label={$t('keep')}>
|
||||
<!-- THUMBNAIL-->
|
||||
<img
|
||||
src={getAssetThumbnailUrl(asset.id)}
|
||||
alt={$getAltText(asset)}
|
||||
title={assetData}
|
||||
class="h-60 object-cover rounded-t-xl w-full"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<!-- OVERLAY CHIP -->
|
||||
{#if !!asset.libraryId}
|
||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-red-300/90">External</div>
|
||||
{/if}
|
||||
|
||||
<!-- FAVORITE ICON -->
|
||||
{#if asset.isFavorite}
|
||||
<div class="absolute bottom-2 start-2">
|
||||
<Icon path={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pl-2 pr-4 gap-2">
|
||||
<div class="grid gap-y-2 py-2 text-xs transition-colors dark:text-white">
|
||||
<div class="text-left text-ellipsis truncate">{asset.originalFileName}</div>
|
||||
<span>{getAssetResolution(asset)}</span>
|
||||
</div>
|
||||
<div class="dark:text-white text-lg font-bold whitespace-nowrap w-max">
|
||||
{getFileSize(asset, 1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { mdiContentDuplicate } from '@mdi/js';
|
||||
import { mdiContentDuplicate, mdiImageSizeSelectLarge } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
@ -17,4 +17,14 @@
|
|||
</span>
|
||||
{$t('review_duplicates')}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={AppRoute.SHOW_LARGE_ASSETS}
|
||||
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
|
||||
>
|
||||
<span
|
||||
><Icon path={mdiImageSizeSelectLarge} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
|
||||
</span>
|
||||
Show large assets
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -49,6 +49,7 @@ export enum AppRoute {
|
|||
|
||||
UTILITIES = '/utilities',
|
||||
DUPLICATES = '/utilities/duplicates',
|
||||
SHOW_LARGE_ASSETS = '/utilities/large-assets',
|
||||
|
||||
FOLDERS = '/folders',
|
||||
TAGS = '/tags',
|
||||
|
|
|
@ -304,9 +304,9 @@ export function isFlipped(orientation?: string | null) {
|
|||
return value && (isRotated270CW(value) || isRotated90CW(value));
|
||||
}
|
||||
|
||||
export function getFileSize(asset: AssetResponseDto): string {
|
||||
export function getFileSize(asset: AssetResponseDto, maxPrecision = 4): string {
|
||||
const size = asset.exifInfo?.fileSizeInByte || 0;
|
||||
return size > 0 ? getByteUnitString(size, undefined, 4) : 'Invalid Data';
|
||||
return size > 0 ? getByteUnitString(size, undefined, maxPrecision) : 'Invalid Data';
|
||||
}
|
||||
|
||||
export function getAssetResolution(asset: AssetResponseDto): string {
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
<script lang="ts">
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import LargeAssetData from '$lib/components/utilities-page/large-assets/large-asset-data.svelte';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data = $bindable() }: Props = $props();
|
||||
let assets = $state(data.assets);
|
||||
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
|
||||
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
|
||||
|
||||
const onNext = () => {
|
||||
const index = getAssetIndex($viewingAsset.id) + 1;
|
||||
if (index >= assets.length) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
setAsset(assets[index]);
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
const onPrevious = () => {
|
||||
const index = getAssetIndex($viewingAsset.id) - 1;
|
||||
if (index < 0) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
setAsset(assets[index]);
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
const onRandom = () => {
|
||||
if (assets.length <= 0) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
const index = Math.floor(Math.random() * assets.length);
|
||||
const asset = assets[index];
|
||||
setAsset(asset);
|
||||
return Promise.resolve(asset);
|
||||
};
|
||||
|
||||
const onAction = (payload: Action) => {
|
||||
if (payload.type == 'trash') {
|
||||
assets = assets.filter((a) => a.id != payload.asset.id);
|
||||
$showAssetViewer = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#if assets && data.assets.length > 0}
|
||||
{#each assets as asset (asset.id)}
|
||||
<LargeAssetData {asset} onViewAsset={(asset) => setAsset(asset)} />
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center">
|
||||
{$t('no_assets_to_show')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
|
||||
{#if $showAssetViewer}
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<Portal target="body">
|
||||
<AssetViewer
|
||||
asset={$viewingAsset}
|
||||
showNavigation={assets.length > 1}
|
||||
{onNext}
|
||||
{onPrevious}
|
||||
{onRandom}
|
||||
{onAction}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
{/await}
|
||||
{/if}
|
|
@ -0,0 +1,18 @@
|
|||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getLargeAssets } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
await authenticate();
|
||||
// const asset = await getAssetInfoFromParam(params);
|
||||
const assets = await getLargeAssets({ take: 200 });
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
assets: assets.assets,
|
||||
meta: {
|
||||
title: $t('large_assets'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
Loading…
Add table
Add a link
Reference in a new issue