mirror of
https://github.com/immich-app/immich.git
synced 2026-01-24 10:24:39 -08:00
Compare commits
1 Commits
push-zpwso
...
fix/foregr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ccc05feeb |
2
.github/workflows/docs-deploy.yml
vendored
2
.github/workflows/docs-deploy.yml
vendored
@@ -131,7 +131,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
||||
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
|
||||
|
||||
- name: Load parameters
|
||||
id: parameters
|
||||
|
||||
2
.github/workflows/docs-destroy.yml
vendored
2
.github/workflows/docs-destroy.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
||||
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
|
||||
|
||||
- name: Destroy Docs Subdomain
|
||||
env:
|
||||
|
||||
15
.github/workflows/test.yml
vendored
15
.github/workflows/test.yml
vendored
@@ -502,25 +502,14 @@ jobs:
|
||||
- name: Run e2e tests (web)
|
||||
env:
|
||||
CI: true
|
||||
run: npx playwright test --project=chromium
|
||||
run: npx playwright test
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive web results
|
||||
- name: Archive test results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-web-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
- name: Run ui tests (web)
|
||||
env:
|
||||
CI: true
|
||||
run: npx playwright test --project=ui
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive ui results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-ui-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
success-check-e2e:
|
||||
name: End-to-End Tests Success
|
||||
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||
|
||||
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
|
||||
testMatch: /.*\.e2e-spec\.ts/,
|
||||
workers: 1,
|
||||
},
|
||||
{
|
||||
name: 'ui',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testMatch: /.*\.ui-spec\.ts/,
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
},
|
||||
// {
|
||||
// name: 'parallel tests',
|
||||
// use: { ...devices['Desktop Chrome'] },
|
||||
// testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||
// fullyParallel: true,
|
||||
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
function imageLocator(page: Page) {
|
||||
return page.getByAltText('Image taken').locator('visible=true');
|
||||
}
|
||||
test.describe('Photo Viewer', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
@@ -23,44 +26,31 @@ test.describe('Photo Viewer', () => {
|
||||
|
||||
test('loads original photo when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await thumbnail.boundingBox();
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect(original).toBeInViewport();
|
||||
await expect(original).toHaveAttribute('src', /original/);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||
});
|
||||
|
||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||
await page.goto(`/photos/${rawAsset.id}`);
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await thumbnail.boundingBox();
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||
});
|
||||
|
||||
test('reloads photo when checksum changes', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const initialSrc = await thumbnail.getAttribute('src');
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const initialSrc = await imageLocator(page).getAttribute('src');
|
||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,8 +104,6 @@
|
||||
"image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning",
|
||||
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
|
||||
"image_preview_title": "Preview Settings",
|
||||
"image_progressive": "Progressive",
|
||||
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.",
|
||||
"image_quality": "Quality",
|
||||
"image_resolution": "Resolution",
|
||||
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
|
||||
@@ -1913,7 +1911,6 @@
|
||||
"search_filter_media_type_title": "Select media type",
|
||||
"search_filter_ocr": "Search by OCR",
|
||||
"search_filter_people_title": "Select people",
|
||||
"search_filter_star_rating": "Star Rating",
|
||||
"search_for": "Search for",
|
||||
"search_for_existing_person": "Search for existing person",
|
||||
"search_no_more_result": "No more results",
|
||||
@@ -2118,8 +2115,6 @@
|
||||
"skip_to_folders": "Skip to folders",
|
||||
"skip_to_tags": "Skip to tags",
|
||||
"slideshow": "Slideshow",
|
||||
"slideshow_repeat": "Repeat slideshow",
|
||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||
"slideshow_settings": "Slideshow settings",
|
||||
"sort_albums_by": "Sort albums by...",
|
||||
"sort_created": "Date created",
|
||||
|
||||
@@ -30,8 +30,3 @@ class MultiSelectToggleEvent extends Event {
|
||||
final bool isEnabled;
|
||||
const MultiSelectToggleEvent(this.isEnabled);
|
||||
}
|
||||
|
||||
// Map Events
|
||||
class MapMarkerReloadEvent extends Event {
|
||||
const MapMarkerReloadEvent();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ class ExifInfo {
|
||||
final String? orientation;
|
||||
final String? timeZone;
|
||||
final DateTime? dateTimeOriginal;
|
||||
final int? rating;
|
||||
|
||||
// GPS
|
||||
final double? latitude;
|
||||
@@ -47,7 +46,6 @@ class ExifInfo {
|
||||
this.orientation,
|
||||
this.timeZone,
|
||||
this.dateTimeOriginal,
|
||||
this.rating,
|
||||
this.isFlipped = false,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
@@ -73,7 +71,6 @@ class ExifInfo {
|
||||
other.orientation == orientation &&
|
||||
other.timeZone == timeZone &&
|
||||
other.dateTimeOriginal == dateTimeOriginal &&
|
||||
other.rating == rating &&
|
||||
other.latitude == latitude &&
|
||||
other.longitude == longitude &&
|
||||
other.city == city &&
|
||||
@@ -97,7 +94,6 @@ class ExifInfo {
|
||||
isFlipped.hashCode ^
|
||||
timeZone.hashCode ^
|
||||
dateTimeOriginal.hashCode ^
|
||||
rating.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode ^
|
||||
city.hashCode ^
|
||||
@@ -122,7 +118,6 @@ orientation: ${orientation ?? 'NA'},
|
||||
isFlipped: $isFlipped,
|
||||
timeZone: ${timeZone ?? 'NA'},
|
||||
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
||||
rating: ${rating ?? 'NA'},
|
||||
latitude: ${latitude ?? 'NA'},
|
||||
longitude: ${longitude ?? 'NA'},
|
||||
city: ${city ?? 'NA'},
|
||||
@@ -145,7 +140,6 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
||||
String? orientation,
|
||||
String? timeZone,
|
||||
DateTime? dateTimeOriginal,
|
||||
int? rating,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
@@ -167,7 +161,6 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
||||
orientation: orientation ?? this.orientation,
|
||||
timeZone: timeZone ?? this.timeZone,
|
||||
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
|
||||
rating: rating ?? this.rating,
|
||||
isFlipped: isFlipped ?? this.isFlipped,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:immich_mobile/domain/models/map.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
|
||||
@@ -12,8 +11,7 @@ class MapFactory {
|
||||
|
||||
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
|
||||
|
||||
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
|
||||
MapService(_mapRepository.remote(ownerIds, options));
|
||||
MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId));
|
||||
}
|
||||
|
||||
class MapService {
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||
|
||||
@@ -81,8 +82,8 @@ class TimelineFactory {
|
||||
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
||||
|
||||
TimelineService map(List<String> userIds, TimelineMapOptions options) =>
|
||||
TimelineService(_timelineRepository.map(userIds, options, groupBy));
|
||||
TimelineService map(String userId, LatLngBounds bounds) =>
|
||||
TimelineService(_timelineRepository.map(userId, bounds, groupBy));
|
||||
}
|
||||
|
||||
class TimelineService {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
|
||||
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/utils/isolate.dart';
|
||||
@@ -23,13 +22,8 @@ class BackgroundSyncManager {
|
||||
final SyncCallback? onHashingComplete;
|
||||
final SyncErrorCallback? onHashingError;
|
||||
|
||||
final SyncCallback? onCloudIdSyncStart;
|
||||
final SyncCallback? onCloudIdSyncComplete;
|
||||
final SyncErrorCallback? onCloudIdSyncError;
|
||||
|
||||
Cancelable<bool?>? _syncTask;
|
||||
Cancelable<void>? _syncWebsocketTask;
|
||||
Cancelable<void>? _cloudIdSyncTask;
|
||||
Cancelable<void>? _deviceAlbumSyncTask;
|
||||
Cancelable<void>? _linkedAlbumSyncTask;
|
||||
Cancelable<void>? _hashTask;
|
||||
@@ -44,9 +38,6 @@ class BackgroundSyncManager {
|
||||
this.onHashingStart,
|
||||
this.onHashingComplete,
|
||||
this.onHashingError,
|
||||
this.onCloudIdSyncStart,
|
||||
this.onCloudIdSyncComplete,
|
||||
this.onCloudIdSyncError,
|
||||
});
|
||||
|
||||
Future<void> cancel() async {
|
||||
@@ -64,12 +55,6 @@ class BackgroundSyncManager {
|
||||
_syncWebsocketTask?.cancel();
|
||||
_syncWebsocketTask = null;
|
||||
|
||||
if (_cloudIdSyncTask != null) {
|
||||
futures.add(_cloudIdSyncTask!.future);
|
||||
}
|
||||
_cloudIdSyncTask?.cancel();
|
||||
_cloudIdSyncTask = null;
|
||||
|
||||
if (_linkedAlbumSyncTask != null) {
|
||||
futures.add(_linkedAlbumSyncTask!.future);
|
||||
}
|
||||
@@ -216,25 +201,6 @@ class BackgroundSyncManager {
|
||||
_linkedAlbumSyncTask = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncCloudIds() {
|
||||
if (_cloudIdSyncTask != null) {
|
||||
return _cloudIdSyncTask!.future;
|
||||
}
|
||||
|
||||
onCloudIdSyncStart?.call();
|
||||
|
||||
_cloudIdSyncTask = runInIsolateGentle(computation: m.syncCloudIds);
|
||||
return _cloudIdSyncTask!
|
||||
.whenComplete(() {
|
||||
onCloudIdSyncComplete?.call();
|
||||
_cloudIdSyncTask = null;
|
||||
})
|
||||
.catchError((error) {
|
||||
onCloudIdSyncError?.call(error.toString());
|
||||
_cloudIdSyncTask = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
|
||||
|
||||
@@ -10,14 +10,13 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
// ignore: import_rule_openapi
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
|
||||
Future<void> syncCloudIds(ProviderContainer ref) async {
|
||||
Future<void> syncCloudIds(Ref ref) async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return;
|
||||
}
|
||||
@@ -35,14 +34,6 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
|
||||
}
|
||||
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
|
||||
|
||||
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
|
||||
try {
|
||||
await ref.read(syncStreamServiceProvider).sync();
|
||||
} catch (e, s) {
|
||||
logger.fine('Failed to complete remote sync before cloudId migration.', e, s);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
|
||||
@@ -151,7 +151,6 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
|
||||
domain.ExifInfo toDto() => domain.ExifInfo(
|
||||
fileSize: fileSize,
|
||||
dateTimeOriginal: dateTimeOriginal,
|
||||
rating: rating,
|
||||
timeZone: timeZone,
|
||||
make: make,
|
||||
model: model,
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/services/map.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class DriftMapRepository extends DriftDatabaseRepository {
|
||||
@@ -13,27 +12,9 @@ class DriftMapRepository extends DriftDatabaseRepository {
|
||||
|
||||
const DriftMapRepository(super._db) : _db = _db;
|
||||
|
||||
MapQuery remote(List<String> ownerIds, TimelineMapOptions options) => _mapQueryBuilder(
|
||||
assetFilter: (row) {
|
||||
Expression<bool> condition =
|
||||
row.deletedAt.isNull() &
|
||||
row.ownerId.isIn(ownerIds) &
|
||||
_db.remoteAssetEntity.visibility.isIn([
|
||||
AssetVisibility.timeline.index,
|
||||
if (options.includeArchived) AssetVisibility.archive.index,
|
||||
]);
|
||||
|
||||
if (options.onlyFavorites) {
|
||||
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
|
||||
}
|
||||
|
||||
return condition;
|
||||
},
|
||||
MapQuery remote(String ownerId) => _mapQueryBuilder(
|
||||
assetFilter: (row) =>
|
||||
row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId),
|
||||
);
|
||||
|
||||
MapQuery _mapQueryBuilder({Expression<bool> Function($RemoteAssetEntityTable row)? assetFilter}) {
|
||||
|
||||
@@ -255,12 +255,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateRating(String assetId, int rating) async {
|
||||
await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write(
|
||||
RemoteExifEntityCompanion(rating: Value(rating)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _db.managers.remoteAssetEntity.count();
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ class SearchApiRepository extends ApiRepository {
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
|
||||
rating: filter.rating.rating,
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
@@ -55,7 +54,6 @@ class SearchApiRepository extends ApiRepository {
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
|
||||
rating: filter.rating.rating,
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
|
||||
@@ -240,8 +240,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
rating: Value(exif.rating),
|
||||
projectionType: Value(exif.projectionType),
|
||||
lens: Value(exif.lensModel),
|
||||
width: Value(exif.exifImageWidth),
|
||||
height: Value(exif.exifImageHeight),
|
||||
);
|
||||
|
||||
batch.insert(
|
||||
|
||||
@@ -15,22 +15,6 @@ import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:stream_transform/stream_transform.dart';
|
||||
|
||||
class TimelineMapOptions {
|
||||
final LatLngBounds bounds;
|
||||
final bool onlyFavorites;
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
|
||||
const TimelineMapOptions({
|
||||
required this.bounds,
|
||||
this.onlyFavorites = false,
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
});
|
||||
}
|
||||
|
||||
class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
@@ -483,15 +467,15 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
||||
}
|
||||
|
||||
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
|
||||
bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
|
||||
assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
|
||||
TimelineQuery map(String userId, LatLngBounds bounds, GroupAssetsBy groupBy) => (
|
||||
bucketSource: () => _watchMapBucket(userId, bounds, groupBy: groupBy),
|
||||
assetSource: (offset, count) => _getMapBucketAssets(userId, bounds, offset: offset, count: count),
|
||||
origin: TimelineOrigin.map,
|
||||
);
|
||||
|
||||
Stream<List<Bucket>> _watchMapBucket(
|
||||
List<String> userId,
|
||||
TimelineMapOptions options, {
|
||||
String userId,
|
||||
LatLngBounds bounds, {
|
||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||
}) {
|
||||
if (groupBy == GroupAssetsBy.none) {
|
||||
@@ -512,26 +496,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.remoteAssetEntity.ownerId.isIn(userId) &
|
||||
_db.remoteExifEntity.inBounds(options.bounds) &
|
||||
_db.remoteAssetEntity.visibility.isIn([
|
||||
AssetVisibility.timeline.index,
|
||||
if (options.includeArchived) AssetVisibility.archive.index,
|
||||
]) &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteExifEntity.inBounds(bounds) &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull(),
|
||||
)
|
||||
..groupBy([dateExp])
|
||||
..orderBy([OrderingTerm.desc(dateExp)]);
|
||||
|
||||
if (options.onlyFavorites) {
|
||||
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
|
||||
}
|
||||
|
||||
return query.map((row) {
|
||||
final timeline = row.read(dateExp)!.truncateDate(groupBy);
|
||||
final assetCount = row.read(assetCountExp)!;
|
||||
@@ -540,8 +512,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
Future<List<BaseAsset>> _getMapBucketAssets(
|
||||
List<String> userId,
|
||||
TimelineMapOptions options, {
|
||||
String userId,
|
||||
LatLngBounds bounds, {
|
||||
required int offset,
|
||||
required int count,
|
||||
}) {
|
||||
@@ -554,26 +526,13 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.remoteAssetEntity.ownerId.isIn(userId) &
|
||||
_db.remoteExifEntity.inBounds(options.bounds) &
|
||||
_db.remoteAssetEntity.visibility.isIn([
|
||||
AssetVisibility.timeline.index,
|
||||
if (options.includeArchived) AssetVisibility.archive.index,
|
||||
]) &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteExifEntity.inBounds(bounds) &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull(),
|
||||
)
|
||||
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
|
||||
..limit(count, offset: offset);
|
||||
|
||||
if (options.onlyFavorites) {
|
||||
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
|
||||
}
|
||||
|
||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
||||
}
|
||||
|
||||
|
||||
@@ -126,41 +126,6 @@ class SearchDateFilter {
|
||||
int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode;
|
||||
}
|
||||
|
||||
class SearchRatingFilter {
|
||||
int? rating;
|
||||
SearchRatingFilter({this.rating});
|
||||
|
||||
SearchRatingFilter copyWith({int? rating}) {
|
||||
return SearchRatingFilter(rating: rating ?? this.rating);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{'rating': rating};
|
||||
}
|
||||
|
||||
factory SearchRatingFilter.fromMap(Map<String, dynamic> map) {
|
||||
return SearchRatingFilter(rating: map['rating'] != null ? map['rating'] as int : null);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SearchRatingFilter.fromJson(String source) =>
|
||||
SearchRatingFilter.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'SearchRatingFilter(rating: $rating)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchRatingFilter other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.rating == rating;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => rating.hashCode;
|
||||
}
|
||||
|
||||
class SearchDisplayFilters {
|
||||
bool isNotInAlbum = false;
|
||||
bool isArchive = false;
|
||||
@@ -218,7 +183,6 @@ class SearchFilter {
|
||||
SearchLocationFilter location;
|
||||
SearchCameraFilter camera;
|
||||
SearchDateFilter date;
|
||||
SearchRatingFilter rating;
|
||||
SearchDisplayFilters display;
|
||||
|
||||
// Enum
|
||||
@@ -236,7 +200,6 @@ class SearchFilter {
|
||||
required this.camera,
|
||||
required this.date,
|
||||
required this.display,
|
||||
required this.rating,
|
||||
required this.mediaType,
|
||||
});
|
||||
|
||||
@@ -257,7 +220,6 @@ class SearchFilter {
|
||||
display.isNotInAlbum == false &&
|
||||
display.isArchive == false &&
|
||||
display.isFavorite == false &&
|
||||
rating.rating == null &&
|
||||
mediaType == AssetType.other;
|
||||
}
|
||||
|
||||
@@ -273,7 +235,6 @@ class SearchFilter {
|
||||
SearchCameraFilter? camera,
|
||||
SearchDateFilter? date,
|
||||
SearchDisplayFilters? display,
|
||||
SearchRatingFilter? rating,
|
||||
AssetType? mediaType,
|
||||
}) {
|
||||
return SearchFilter(
|
||||
@@ -288,14 +249,13 @@ class SearchFilter {
|
||||
camera: camera ?? this.camera,
|
||||
date: date ?? this.date,
|
||||
display: display ?? this.display,
|
||||
rating: rating ?? this.rating,
|
||||
mediaType: mediaType ?? this.mediaType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType, assetId: $assetId)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -313,7 +273,6 @@ class SearchFilter {
|
||||
other.camera == camera &&
|
||||
other.date == date &&
|
||||
other.display == display &&
|
||||
other.rating == rating &&
|
||||
other.mediaType == mediaType;
|
||||
}
|
||||
|
||||
@@ -330,7 +289,6 @@ class SearchFilter {
|
||||
camera.hashCode ^
|
||||
date.hashCode ^
|
||||
display.hashCode ^
|
||||
rating.hashCode ^
|
||||
mediaType.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
_resumeBackup(backupProvider);
|
||||
}),
|
||||
_resumeBackup(backupProvider),
|
||||
backgroundManager.syncCloudIds(),
|
||||
]);
|
||||
} else {
|
||||
await backgroundManager.hashAssets();
|
||||
|
||||
@@ -113,7 +113,6 @@ class PlaceTile extends StatelessWidget {
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.other,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43,7 +43,6 @@ class SearchPage extends HookConsumerWidget {
|
||||
date: prefilter?.date ?? SearchDateFilter(),
|
||||
display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
mediaType: prefilter?.mediaType ?? AssetType.other,
|
||||
rating: prefilter?.rating ?? SearchRatingFilter(),
|
||||
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map_settings_sheet.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -11,16 +10,6 @@ class DriftMapPage extends StatelessWidget {
|
||||
|
||||
const DriftMapPage({super.key, this.initialLocation});
|
||||
|
||||
void onSettingsPressed(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
elevation: 0.0,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (_) => const DriftMapSettingsSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -29,8 +18,8 @@ class DriftMapPage extends StatelessWidget {
|
||||
children: [
|
||||
DriftMap(initialLocation: initialLocation),
|
||||
Positioned(
|
||||
left: 20,
|
||||
top: 70,
|
||||
left: 16,
|
||||
top: 60,
|
||||
child: IconButton.filled(
|
||||
color: Colors.white,
|
||||
onPressed: () => context.pop(),
|
||||
@@ -43,21 +32,6 @@ class DriftMapPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: 70,
|
||||
child: IconButton.filled(
|
||||
color: Colors.white,
|
||||
onPressed: () => onSettingsPressed(context),
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
style: IconButton.styleFrom(
|
||||
padding: const EdgeInsets.all(8),
|
||||
backgroundColor: Colors.indigo,
|
||||
shadowColor: Colors.black26,
|
||||
elevation: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
|
||||
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/feature_check.dart';
|
||||
@@ -31,7 +30,6 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar
|
||||
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/star_rating_picker.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftSearchPage extends HookConsumerWidget {
|
||||
@@ -50,7 +48,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
camera: preFilter?.camera ?? SearchCameraFilter(),
|
||||
date: preFilter?.date ?? SearchDateFilter(),
|
||||
display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: preFilter?.rating ?? SearchRatingFilter(),
|
||||
mediaType: preFilter?.mediaType ?? AssetType.other,
|
||||
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
||||
assetId: preFilter?.assetId,
|
||||
@@ -65,15 +62,10 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
||||
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||
|
||||
final isSearching = useState(false);
|
||||
|
||||
final isRatingEnabled = ref
|
||||
.watch(userMetadataPreferencesProvider)
|
||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||
|
||||
SnackBar searchInfoSnackBar(String message) {
|
||||
return SnackBar(
|
||||
content: Text(message, style: context.textTheme.labelLarge),
|
||||
@@ -377,35 +369,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// STAR RATING PICKER
|
||||
showStarRatingPicker() {
|
||||
handleOnSelected(SearchRatingFilter rating) {
|
||||
filter.value = filter.value.copyWith(rating: rating);
|
||||
|
||||
ratingCurrentFilterWidget.value = Text(
|
||||
'rating_count'.t(args: {'count': rating.rating!}),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(rating: SearchRatingFilter(rating: null));
|
||||
ratingCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'rating'.t(context: context),
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// DISPLAY OPTION
|
||||
showDisplayOptionPicker() {
|
||||
handleOnSelect(Map<DisplayOption, bool> value) {
|
||||
@@ -666,14 +629,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_media_type'.t(context: context),
|
||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||
),
|
||||
if (isRatingEnabled) ...[
|
||||
SearchFilterChip(
|
||||
icon: Icons.star_outline_rounded,
|
||||
onTap: showStarRatingPicker,
|
||||
label: 'search_filter_star_rating'.t(context: context),
|
||||
currentFilter: ratingCurrentFilterWidget.value,
|
||||
),
|
||||
],
|
||||
SearchFilterChip(
|
||||
icon: Icons.display_settings_outlined,
|
||||
onTap: showDisplayOptionPicker,
|
||||
|
||||
@@ -34,7 +34,6 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.image,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -118,6 +118,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
bool dragInProgress = false;
|
||||
bool shouldPopOnDrag = false;
|
||||
bool assetReloadRequested = false;
|
||||
double? initialScale;
|
||||
double previousExtent = _kBottomSheetMinimumExtent;
|
||||
Offset dragDownPosition = Offset.zero;
|
||||
int totalAssets = 0;
|
||||
@@ -263,6 +264,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
|
||||
controller.position = Offset(0, -verticalOffset);
|
||||
// Apply the zoom effect when the bottom sheet is showing
|
||||
initialScale = controller.scale;
|
||||
controller.scale = (controller.scale ?? 1.0) + 0.01;
|
||||
}
|
||||
}
|
||||
@@ -314,7 +316,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
hasDraggedDown = null;
|
||||
viewController?.animateMultiple(
|
||||
position: initialPhotoViewState.position,
|
||||
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
|
||||
scale: initialPhotoViewState.scale,
|
||||
rotation: initialPhotoViewState.rotation,
|
||||
);
|
||||
ref.read(assetViewerProvider.notifier).setOpacity(255);
|
||||
@@ -364,9 +366,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final maxScaleDistance = ctx.height * 0.5;
|
||||
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
||||
double? updatedScale;
|
||||
double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
|
||||
if (initialScale != null) {
|
||||
updatedScale = initialScale * (1.0 - scaleReduction);
|
||||
if (initialPhotoViewState.scale != null) {
|
||||
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
|
||||
}
|
||||
|
||||
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
|
||||
@@ -480,6 +481,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
|
||||
initialScale = viewController?.scale;
|
||||
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
|
||||
previousExtent = _kBottomSheetMinimumExtent;
|
||||
sheetCloseController = showBottomSheet(
|
||||
context: ctx,
|
||||
@@ -501,7 +504,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
void _handleSheetClose() {
|
||||
viewController?.animateMultiple(position: Offset.zero);
|
||||
viewController?.updateMultiple(scale: viewController?.initialScale);
|
||||
viewController?.updateMultiple(scale: initialScale);
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
|
||||
sheetCloseController = null;
|
||||
shouldPopOnDrag = false;
|
||||
|
||||
@@ -16,13 +16,11 @@ import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -206,9 +204,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
final isRatingEnabled = ref
|
||||
.watch(userMetadataPreferencesProvider)
|
||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||
|
||||
// Build file info tile based on asset type
|
||||
Widget buildFileInfoTile() {
|
||||
@@ -288,38 +283,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
// Rating bar
|
||||
if (isRatingEnabled) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'rating'.t(context: context).toUpperCase(),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
RatingBar(
|
||||
initialRating: exifInfo?.rating?.toDouble() ?? 0,
|
||||
filledColor: context.themeData.colorScheme.primary,
|
||||
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
|
||||
itemSize: 40,
|
||||
onRatingUpdate: (rating) async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
|
||||
},
|
||||
onClearRating: () async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Appears in (Albums)
|
||||
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
||||
// padding at the bottom to avoid cut-off
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
class RatingBar extends StatefulWidget {
|
||||
final double initialRating;
|
||||
final int itemCount;
|
||||
final double itemSize;
|
||||
final Color filledColor;
|
||||
final Color unfilledColor;
|
||||
final ValueChanged<int>? onRatingUpdate;
|
||||
final VoidCallback? onClearRating;
|
||||
final Widget? itemBuilder;
|
||||
final double starPadding;
|
||||
|
||||
const RatingBar({
|
||||
super.key,
|
||||
this.initialRating = 0.0,
|
||||
this.itemCount = 5,
|
||||
this.itemSize = 40.0,
|
||||
this.filledColor = Colors.amber,
|
||||
this.unfilledColor = Colors.grey,
|
||||
this.onRatingUpdate,
|
||||
this.onClearRating,
|
||||
this.itemBuilder,
|
||||
this.starPadding = 4.0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RatingBar> createState() => _RatingBarState();
|
||||
}
|
||||
|
||||
class _RatingBarState extends State<RatingBar> {
|
||||
late double _currentRating;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentRating = widget.initialRating;
|
||||
}
|
||||
|
||||
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
|
||||
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
|
||||
double dx = localPosition.dx;
|
||||
|
||||
if (isRTL) dx = totalWidth - dx;
|
||||
|
||||
double newRating;
|
||||
|
||||
if (dx <= 0) {
|
||||
newRating = 0;
|
||||
} else if (dx >= totalWidth) {
|
||||
newRating = widget.itemCount.toDouble();
|
||||
} else {
|
||||
double starWithPadding = widget.itemSize + widget.starPadding;
|
||||
int tappedIndex = (dx / starWithPadding).floor().clamp(0, widget.itemCount - 1);
|
||||
newRating = tappedIndex + 1.0;
|
||||
|
||||
if (isTap && newRating == _currentRating && _currentRating != 0) {
|
||||
newRating = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (_currentRating != newRating) {
|
||||
setState(() {
|
||||
_currentRating = newRating;
|
||||
});
|
||||
widget.onRatingUpdate?.call(newRating.round());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isRTL = Directionality.of(context) == TextDirection.rtl;
|
||||
final double visualAlignmentOffset = 5.0;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Transform.translate(
|
||||
offset: Offset(isRTL ? visualAlignmentOffset : -visualAlignmentOffset, 0),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (details) => _updateRating(details.localPosition, isRTL, isTap: true),
|
||||
onPanUpdate: (details) => _updateRating(details.localPosition, isRTL, isTap: false),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr,
|
||||
children: List.generate(widget.itemCount * 2 - 1, (i) {
|
||||
if (i.isOdd) {
|
||||
return SizedBox(width: widget.starPadding);
|
||||
}
|
||||
int index = i ~/ 2;
|
||||
bool filled = _currentRating > index;
|
||||
return widget.itemBuilder ??
|
||||
Icon(
|
||||
Icons.star_rounded,
|
||||
size: widget.itemSize,
|
||||
color: filled ? widget.filledColor : widget.unfilledColor,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_currentRating > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_currentRating = 0;
|
||||
});
|
||||
widget.onClearRating?.call();
|
||||
},
|
||||
child: Text(
|
||||
'rating_clear'.t(context: context),
|
||||
style: TextStyle(color: context.themeData.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class MapBottomSheet extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.75,
|
||||
maxChildSize: 0.9,
|
||||
shouldCloseOnMinExtent: false,
|
||||
resizeOnScroll: false,
|
||||
actions: [],
|
||||
@@ -38,13 +38,8 @@ class _ScopedMapTimeline extends StatelessWidget {
|
||||
throw Exception('User must be logged in to access archive');
|
||||
}
|
||||
|
||||
final users = ref.watch(mapStateProvider).withPartners
|
||||
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
|
||||
: [user.id];
|
||||
|
||||
final timelineService = ref
|
||||
.watch(timelineFactoryProvider)
|
||||
.map(users, ref.watch(mapStateProvider).toOptions());
|
||||
final bounds = ref.watch(mapStateProvider).bounds;
|
||||
final timelineService = ref.watch(timelineFactoryProvider).map(user.id, bounds);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
|
||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapState {
|
||||
final ThemeMode themeMode;
|
||||
final LatLngBounds bounds;
|
||||
final bool onlyFavorites;
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
|
||||
const MapState({
|
||||
this.themeMode = ThemeMode.system,
|
||||
required this.bounds,
|
||||
this.onlyFavorites = false,
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
});
|
||||
const MapState({required this.bounds});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant MapState other) {
|
||||
@@ -34,31 +15,9 @@ class MapState {
|
||||
@override
|
||||
int get hashCode => bounds.hashCode;
|
||||
|
||||
MapState copyWith({
|
||||
LatLngBounds? bounds,
|
||||
ThemeMode? themeMode,
|
||||
bool? onlyFavorites,
|
||||
bool? includeArchived,
|
||||
bool? withPartners,
|
||||
int? relativeDays,
|
||||
}) {
|
||||
return MapState(
|
||||
bounds: bounds ?? this.bounds,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
onlyFavorites: onlyFavorites ?? this.onlyFavorites,
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
withPartners: withPartners ?? this.withPartners,
|
||||
relativeDays: relativeDays ?? this.relativeDays,
|
||||
);
|
||||
MapState copyWith({LatLngBounds? bounds}) {
|
||||
return MapState(bounds: bounds ?? this.bounds);
|
||||
}
|
||||
|
||||
TimelineMapOptions toOptions() => TimelineMapOptions(
|
||||
bounds: bounds,
|
||||
onlyFavorites: onlyFavorites,
|
||||
includeArchived: includeArchived,
|
||||
withPartners: withPartners,
|
||||
relativeDays: relativeDays,
|
||||
);
|
||||
}
|
||||
|
||||
class MapStateNotifier extends Notifier<MapState> {
|
||||
@@ -72,50 +31,11 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
void switchTheme(ThemeMode mode) {
|
||||
// TODO: Remove this line when map theme provider is removed
|
||||
// Until then, keep both in sync as MapThemeOverride uses map state provider
|
||||
// ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapThemeMode, mode.index);
|
||||
ref.read(mapStateNotifierProvider.notifier).switchTheme(mode);
|
||||
state = state.copyWith(themeMode: mode);
|
||||
}
|
||||
|
||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapShowFavoriteOnly, isFavoriteOnly);
|
||||
state = state.copyWith(onlyFavorites: isFavoriteOnly);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void switchIncludeArchived(bool isIncludeArchived) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapIncludeArchived, isIncludeArchived);
|
||||
state = state.copyWith(includeArchived: isIncludeArchived);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void switchWithPartners(bool isWithPartners) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapwithPartners, isWithPartners);
|
||||
state = state.copyWith(withPartners: isWithPartners);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void setRelativeTime(int relativeDays) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapRelativeDate, relativeDays);
|
||||
state = state.copyWith(relativeDays: relativeDays);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
@override
|
||||
MapState build() {
|
||||
final appSettingsService = ref.read(appSettingsServiceProvider);
|
||||
return MapState(
|
||||
themeMode: ThemeMode.values[appSettingsService.getSetting(AppSettingsEnum.mapThemeMode)],
|
||||
onlyFavorites: appSettingsService.getSetting(AppSettingsEnum.mapShowFavoriteOnly),
|
||||
includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived),
|
||||
withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners),
|
||||
relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate),
|
||||
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
|
||||
);
|
||||
}
|
||||
MapState build() => MapState(
|
||||
// TODO: set default bounds
|
||||
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
|
||||
);
|
||||
}
|
||||
|
||||
// This provider watches the markers from the map service and serves the markers.
|
||||
|
||||
@@ -6,8 +6,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
@@ -53,19 +51,11 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
final _reloadMutex = AsyncMutex();
|
||||
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
|
||||
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventSubscription = EventStream.shared.listen<MapMarkerReloadEvent>(_onEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debouncer.dispose();
|
||||
bottomSheetOffset.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -73,8 +63,6 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
mapController = controller;
|
||||
}
|
||||
|
||||
void _onEvent(_) => _debouncer.run(() => setBounds(forceReload: true));
|
||||
|
||||
Future<void> onMapReady() async {
|
||||
final controller = mapController;
|
||||
if (controller == null) {
|
||||
@@ -110,7 +98,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
);
|
||||
}
|
||||
|
||||
_debouncer.run(() => setBounds(forceReload: true));
|
||||
_debouncer.run(setBounds);
|
||||
controller.addListener(onMapMoved);
|
||||
}
|
||||
|
||||
@@ -122,7 +110,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
_debouncer.run(setBounds);
|
||||
}
|
||||
|
||||
Future<void> setBounds({bool forceReload = false}) async {
|
||||
Future<void> setBounds() async {
|
||||
final controller = mapController;
|
||||
if (controller == null || !mounted) {
|
||||
return;
|
||||
@@ -139,7 +127,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
final bounds = await controller.getVisibleRegion();
|
||||
unawaited(
|
||||
_reloadMutex.run(() async {
|
||||
if (mounted && (ref.read(mapStateProvider.notifier).setBounds(bounds) || forceReload)) {
|
||||
if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) {
|
||||
final markers = await ref.read(mapMarkerProvider(bounds).future);
|
||||
await reloadMarkers(markers);
|
||||
}
|
||||
@@ -215,7 +203,7 @@ class _Map extends StatelessWidget {
|
||||
onMapCreated: onMapCreated,
|
||||
onStyleLoadedCallback: onMapReady,
|
||||
attributionButtonPosition: AttributionButtonPosition.topRight,
|
||||
attributionButtonMargins: const Point(8, kToolbarHeight),
|
||||
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -256,7 +244,7 @@ class _DynamicMyLocationButton extends StatelessWidget {
|
||||
valueListenable: bottomSheetOffset,
|
||||
builder: (context, offset, child) {
|
||||
return Positioned(
|
||||
right: 20,
|
||||
right: 16,
|
||||
bottom: context.height * (offset - 0.02) + context.padding.bottom,
|
||||
child: AnimatedOpacity(
|
||||
opacity: offset < 0.8 ? 1 : 0,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
|
||||
|
||||
class DriftMapSettingsSheet extends HookConsumerWidget {
|
||||
const DriftMapSettingsSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mapState = ref.watch(mapStateProvider);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
builder: (ctx, scrollController) => SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Card(
|
||||
elevation: 0.0,
|
||||
shadowColor: Colors.transparent,
|
||||
color: Colors.transparent,
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
MapThemePicker(
|
||||
themeMode: mapState.themeMode,
|
||||
onThemeChange: (mode) => ref.read(mapStateProvider.notifier).switchTheme(mode),
|
||||
),
|
||||
const Divider(height: 30, thickness: 1),
|
||||
MapSettingsListTile(
|
||||
title: "map_settings_only_show_favorites".t(context: context),
|
||||
selected: mapState.onlyFavorites,
|
||||
onChanged: (favoriteOnly) => ref.read(mapStateProvider.notifier).switchFavoriteOnly(favoriteOnly),
|
||||
),
|
||||
MapSettingsListTile(
|
||||
title: "map_settings_include_show_archived".t(context: context),
|
||||
selected: mapState.includeArchived,
|
||||
onChanged: (includeArchive) =>
|
||||
ref.read(mapStateProvider.notifier).switchIncludeArchived(includeArchive),
|
||||
),
|
||||
MapSettingsListTile(
|
||||
title: "map_settings_include_show_partners".t(context: context),
|
||||
selected: mapState.withPartners,
|
||||
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
|
||||
),
|
||||
MapTimeDropDown(
|
||||
relativeTime: mapState.relativeDays,
|
||||
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
@@ -156,11 +157,11 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
]);
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
_safeRun(syncCloudIds(_ref), "syncCloudIds"),
|
||||
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
|
||||
_resumeBackup();
|
||||
}),
|
||||
_resumeBackup(),
|
||||
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
]);
|
||||
} else {
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
@@ -172,6 +176,15 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
isAdmin: user.isAdmin,
|
||||
);
|
||||
|
||||
// TODO: Temporarily run cloud Id sync here until we have a better place to do it
|
||||
unawaited(
|
||||
_ref.read(backgroundSyncProvider).syncRemote().then((success) {
|
||||
if (success) {
|
||||
return syncCloudIds(_ref);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,6 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
|
||||
onHashingStart: syncStatusNotifier.startHashJob,
|
||||
onHashingComplete: syncStatusNotifier.completeHashJob,
|
||||
onHashingError: syncStatusNotifier.errorHashJob,
|
||||
onCloudIdSyncStart: syncStatusNotifier.startCloudIdSync,
|
||||
onCloudIdSyncComplete: syncStatusNotifier.completeCloudIdSync,
|
||||
onCloudIdSyncError: syncStatusNotifier.errorCloudIdSync,
|
||||
);
|
||||
ref.onDispose(manager.cancel);
|
||||
return manager;
|
||||
|
||||
@@ -359,22 +359,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> updateRating(ActionSource source, int rating) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
if (ids.length != 1) {
|
||||
_logger.warning('updateRating called with multiple assets, expected single asset');
|
||||
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for rating update');
|
||||
}
|
||||
|
||||
try {
|
||||
final isUpdated = await _service.updateRating(ids.first, rating);
|
||||
return ActionResult(count: 1, success: isUpdated);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to update rating for asset', error, stack);
|
||||
return ActionResult(count: 1, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> stack(String userId, ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/map.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/domain/services/map.service.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
final mapRepositoryProvider = Provider<DriftMapRepository>((ref) => DriftMapRepository(ref.watch(driftProvider)));
|
||||
@@ -15,11 +13,7 @@ final mapServiceProvider = Provider<MapService>(
|
||||
throw Exception('User must be logged in to access map');
|
||||
}
|
||||
|
||||
final users = ref.watch(mapStateProvider).withPartners
|
||||
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
|
||||
: [user.id];
|
||||
|
||||
final mapService = ref.watch(mapFactoryProvider).remote(users, ref.watch(mapStateProvider).toOptions());
|
||||
final mapService = ref.watch(mapFactoryProvider).remote(user.id);
|
||||
return mapService;
|
||||
},
|
||||
// Empty dependencies to inform the framework that this provider
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
final userMetadataRepository = Provider<DriftUserMetadataRepository>(
|
||||
(ref) => DriftUserMetadataRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final userMetadataProvider = FutureProvider<List<UserMetadata>>((ref) async {
|
||||
final repository = ref.watch(userMetadataRepository);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) return [];
|
||||
return repository.getUserMetadata(user.id);
|
||||
});
|
||||
|
||||
final userMetadataPreferencesProvider = FutureProvider<Preferences?>((ref) async {
|
||||
final metadataList = await ref.watch(userMetadataProvider.future);
|
||||
final metadataWithPrefs = metadataList.firstWhere((meta) => meta.preferences != null);
|
||||
return metadataWithPrefs.preferences;
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ class SyncStatusState {
|
||||
final SyncStatus remoteSyncStatus;
|
||||
final SyncStatus localSyncStatus;
|
||||
final SyncStatus hashJobStatus;
|
||||
final SyncStatus cloudIdSyncStatus;
|
||||
|
||||
final String? errorMessage;
|
||||
|
||||
@@ -29,7 +28,6 @@ class SyncStatusState {
|
||||
this.remoteSyncStatus = SyncStatus.idle,
|
||||
this.localSyncStatus = SyncStatus.idle,
|
||||
this.hashJobStatus = SyncStatus.idle,
|
||||
this.cloudIdSyncStatus = SyncStatus.idle,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@@ -37,14 +35,12 @@ class SyncStatusState {
|
||||
SyncStatus? remoteSyncStatus,
|
||||
SyncStatus? localSyncStatus,
|
||||
SyncStatus? hashJobStatus,
|
||||
SyncStatus? cloudIdSyncStatus,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return SyncStatusState(
|
||||
remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
|
||||
localSyncStatus: localSyncStatus ?? this.localSyncStatus,
|
||||
hashJobStatus: hashJobStatus ?? this.hashJobStatus,
|
||||
cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
@@ -52,7 +48,6 @@ class SyncStatusState {
|
||||
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
|
||||
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
|
||||
bool get isHashing => hashJobStatus == SyncStatus.syncing;
|
||||
bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
@@ -61,12 +56,11 @@ class SyncStatusState {
|
||||
other.remoteSyncStatus == remoteSyncStatus &&
|
||||
other.localSyncStatus == localSyncStatus &&
|
||||
other.hashJobStatus == hashJobStatus &&
|
||||
other.cloudIdSyncStatus == cloudIdSyncStatus &&
|
||||
other.errorMessage == errorMessage;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage);
|
||||
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, errorMessage);
|
||||
}
|
||||
|
||||
class SyncStatusNotifier extends Notifier<SyncStatusState> {
|
||||
@@ -77,7 +71,6 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
|
||||
remoteSyncStatus: SyncStatus.idle,
|
||||
localSyncStatus: SyncStatus.idle,
|
||||
hashJobStatus: SyncStatus.idle,
|
||||
cloudIdSyncStatus: SyncStatus.idle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,18 +109,6 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
|
||||
void startHashJob() => setHashJobStatus(SyncStatus.syncing);
|
||||
void completeHashJob() => setHashJobStatus(SyncStatus.success);
|
||||
void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error);
|
||||
|
||||
///
|
||||
/// Cloud ID Sync Job
|
||||
///
|
||||
|
||||
void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) {
|
||||
state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
|
||||
}
|
||||
|
||||
void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing);
|
||||
void completeCloudIdSync() => setCloudIdSyncStatus(SyncStatus.success);
|
||||
void errorCloudIdSync(String error) => setCloudIdSyncStatus(SyncStatus.error, error);
|
||||
}
|
||||
|
||||
final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new);
|
||||
|
||||
@@ -101,10 +101,6 @@ class AssetApiRepository extends ApiRepository {
|
||||
Future<void> updateDescription(String assetId, String description) {
|
||||
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
|
||||
}
|
||||
|
||||
Future<void> updateRating(String assetId, int rating) {
|
||||
return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackResponseDto {
|
||||
|
||||
@@ -214,14 +214,6 @@ class ActionService {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> updateRating(String assetId, int rating) async {
|
||||
// update remote first, then local to ensure consistency
|
||||
await _assetApiRepository.updateRating(assetId, rating);
|
||||
await _remoteAssetRepository.updateRating(assetId, rating);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> stack(String userId, List<String> remoteIds) async {
|
||||
final stack = await _assetApiRepository.stack(remoteIds);
|
||||
await _remoteAssetRepository.stack(userId, stack);
|
||||
|
||||
@@ -88,7 +88,6 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
|
||||
if (version < 20 && Store.isBetaTimelineEnabled) {
|
||||
await _syncLocalAlbumIsIosSharedAlbum(drift);
|
||||
await _backfillAssetExifWidthHeight(drift);
|
||||
}
|
||||
|
||||
if (targetVersion >= 12) {
|
||||
@@ -282,22 +281,6 @@ Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _backfillAssetExifWidthHeight(Drift db) async {
|
||||
try {
|
||||
await db.customStatement('''
|
||||
UPDATE remote_exif_entity AS remote_exif
|
||||
SET width = asset.width,
|
||||
height = asset.height
|
||||
FROM remote_asset_entity AS asset
|
||||
WHERE remote_exif.asset_id = asset.id;
|
||||
''');
|
||||
|
||||
dPrint(() => "[MIGRATION] Successfully backfilled asset exif width and height");
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while backfilling asset exif width and height: $error");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
|
||||
try {
|
||||
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
@@ -13,7 +14,7 @@ class MapSettingsListTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile.adaptive(
|
||||
activeThumbColor: context.primaryColor,
|
||||
title: Text(title, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5)),
|
||||
title: Text(title, style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
|
||||
value: selected,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
class MapTimeDropDown extends StatelessWidget {
|
||||
final int relativeTime;
|
||||
@@ -13,47 +11,41 @@ class MapTimeDropDown extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 28.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"date_range".t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text("date_range".tr(), style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (_, constraints) => DropdownMenu(
|
||||
width: constraints.maxWidth * 0.9,
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
initialSelection: relativeTime,
|
||||
onSelected: (value) => onTimeChange(value!),
|
||||
dropdownMenuEntries: [
|
||||
DropdownMenuEntry(value: 0, label: "all".tr()),
|
||||
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".tr()),
|
||||
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
|
||||
DropdownMenuEntry(value: 30, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"})),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_year".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_years".tr(namedArgs: {'years': "3"}),
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: DropdownMenu(
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
initialSelection: relativeTime,
|
||||
onSelected: (value) => onTimeChange(value!),
|
||||
dropdownMenuEntries: [
|
||||
DropdownMenuEntry(value: 0, label: "all".t(context: context)),
|
||||
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".t(context: context)),
|
||||
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
|
||||
DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"}),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_year".t(context: context),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_years".t(args: {'years': "3"}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@@ -18,9 +18,9 @@ class MapThemePicker extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"map_settings_theme_settings".t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
"map_settings_theme_settings",
|
||||
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart';
|
||||
|
||||
/// The interface in which controllers will be implemented.
|
||||
///
|
||||
@@ -63,9 +62,6 @@ abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
|
||||
/// The scale factor to transform the child (image or a customChild).
|
||||
late double? scale;
|
||||
|
||||
double? get initialScale;
|
||||
ScaleBoundaries? scaleBoundaries;
|
||||
|
||||
/// Nevermind this method :D, look away
|
||||
void setScaleInvisibly(double? scale);
|
||||
|
||||
@@ -145,9 +141,6 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
|
||||
|
||||
late StreamController<PhotoViewControllerValue> _outputCtrl;
|
||||
|
||||
@override
|
||||
ScaleBoundaries? scaleBoundaries;
|
||||
|
||||
late void Function(Offset)? _animatePosition;
|
||||
late void Function(double)? _animateScale;
|
||||
late void Function(double)? _animateRotation;
|
||||
@@ -318,7 +311,4 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
|
||||
}
|
||||
_valueNotifier.value = newValue;
|
||||
}
|
||||
|
||||
@override
|
||||
double? get initialScale => scaleBoundaries?.initialScale ?? initial.scale;
|
||||
}
|
||||
|
||||
@@ -108,17 +108,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||
}
|
||||
}
|
||||
|
||||
// Should be called only when _imageSize is not null
|
||||
ScaleBoundaries get scaleBoundaries {
|
||||
return ScaleBoundaries(
|
||||
widget.minScale ?? 0.0,
|
||||
widget.maxScale ?? double.infinity,
|
||||
widget.initialScale ?? PhotoViewComputedScale.contained,
|
||||
widget.outerSize,
|
||||
_imageSize!,
|
||||
);
|
||||
}
|
||||
|
||||
// retrieve image from the provider
|
||||
void _resolveImage() {
|
||||
final ImageStream newStream = widget.imageProvider.resolve(const ImageConfiguration());
|
||||
@@ -144,7 +133,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||
_lastStack = null;
|
||||
|
||||
_didLoadSynchronously = synchronousCall;
|
||||
widget.controller.scaleBoundaries = scaleBoundaries;
|
||||
}
|
||||
|
||||
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
|
||||
@@ -216,6 +204,14 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||
);
|
||||
}
|
||||
|
||||
final scaleBoundaries = ScaleBoundaries(
|
||||
widget.minScale ?? 0.0,
|
||||
widget.maxScale ?? double.infinity,
|
||||
widget.initialScale ?? PhotoViewComputedScale.contained,
|
||||
widget.outerSize,
|
||||
_imageSize!,
|
||||
);
|
||||
|
||||
return PhotoViewCore(
|
||||
imageProvider: widget.imageProvider,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
|
||||
@@ -55,7 +55,6 @@ class ExploreGrid extends StatelessWidget {
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.other,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
|
||||
class StarRatingPicker extends HookWidget {
|
||||
const StarRatingPicker({super.key, required this.onSelect, this.filter});
|
||||
final Function(SearchRatingFilter) onSelect;
|
||||
final SearchRatingFilter? filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedRating = useState(filter);
|
||||
|
||||
return RadioGroup(
|
||||
groupValue: selectedRating.value?.rating,
|
||||
onChanged: (int? newValue) {
|
||||
if (newValue == null) return;
|
||||
final newFilter = SearchRatingFilter(rating: newValue);
|
||||
selectedRating.value = newFilter;
|
||||
onSelect(newFilter);
|
||||
},
|
||||
child: Column(
|
||||
children: List.generate(
|
||||
6,
|
||||
(index) => RadioListTile<int>(
|
||||
key: Key("star_$index"),
|
||||
title: Text('rating_count'.t(args: {'count': (index)})),
|
||||
value: index,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
4
mobile/openapi/lib/api/assets_api.dart
generated
4
mobile/openapi/lib/api/assets_api.dart
generated
@@ -1691,7 +1691,7 @@ class AssetsApi {
|
||||
|
||||
/// View asset thumbnail
|
||||
///
|
||||
/// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.
|
||||
/// Retrieve the thumbnail image for the specified asset.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -1747,7 +1747,7 @@ class AssetsApi {
|
||||
|
||||
/// View asset thumbnail
|
||||
///
|
||||
/// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.
|
||||
/// Retrieve the thumbnail image for the specified asset.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
3
mobile/openapi/lib/model/asset_media_size.dart
generated
3
mobile/openapi/lib/model/asset_media_size.dart
generated
@@ -23,14 +23,12 @@ class AssetMediaSize {
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const original = AssetMediaSize._(r'original');
|
||||
static const fullsize = AssetMediaSize._(r'fullsize');
|
||||
static const preview = AssetMediaSize._(r'preview');
|
||||
static const thumbnail = AssetMediaSize._(r'thumbnail');
|
||||
|
||||
/// List of all possible values in this [enum][AssetMediaSize].
|
||||
static const values = <AssetMediaSize>[
|
||||
original,
|
||||
fullsize,
|
||||
preview,
|
||||
thumbnail,
|
||||
@@ -72,7 +70,6 @@ class AssetMediaSizeTypeTransformer {
|
||||
AssetMediaSize? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'original': return AssetMediaSize.original;
|
||||
case r'fullsize': return AssetMediaSize.fullsize;
|
||||
case r'preview': return AssetMediaSize.preview;
|
||||
case r'thumbnail': return AssetMediaSize.thumbnail;
|
||||
|
||||
3
mobile/openapi/lib/model/permission.dart
generated
3
mobile/openapi/lib/model/permission.dart
generated
@@ -72,7 +72,6 @@ class Permission {
|
||||
static const facePeriodRead = Permission._(r'face.read');
|
||||
static const facePeriodUpdate = Permission._(r'face.update');
|
||||
static const facePeriodDelete = Permission._(r'face.delete');
|
||||
static const folderPeriodRead = Permission._(r'folder.read');
|
||||
static const jobPeriodCreate = Permission._(r'job.create');
|
||||
static const jobPeriodRead = Permission._(r'job.read');
|
||||
static const libraryPeriodCreate = Permission._(r'library.create');
|
||||
@@ -231,7 +230,6 @@ class Permission {
|
||||
facePeriodRead,
|
||||
facePeriodUpdate,
|
||||
facePeriodDelete,
|
||||
folderPeriodRead,
|
||||
jobPeriodCreate,
|
||||
jobPeriodRead,
|
||||
libraryPeriodCreate,
|
||||
@@ -425,7 +423,6 @@ class PermissionTypeTransformer {
|
||||
case r'face.read': return Permission.facePeriodRead;
|
||||
case r'face.update': return Permission.facePeriodUpdate;
|
||||
case r'face.delete': return Permission.facePeriodDelete;
|
||||
case r'folder.read': return Permission.folderPeriodRead;
|
||||
case r'job.create': return Permission.jobPeriodCreate;
|
||||
case r'job.read': return Permission.jobPeriodRead;
|
||||
case r'library.create': return Permission.libraryPeriodCreate;
|
||||
|
||||
@@ -15,7 +15,6 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
SystemConfigGeneratedFullsizeImageDto({
|
||||
required this.enabled,
|
||||
required this.format,
|
||||
this.progressive = false,
|
||||
required this.quality,
|
||||
});
|
||||
|
||||
@@ -23,8 +22,6 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
|
||||
ImageFormat format;
|
||||
|
||||
bool progressive;
|
||||
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 100
|
||||
int quality;
|
||||
@@ -33,7 +30,6 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedFullsizeImageDto &&
|
||||
other.enabled == enabled &&
|
||||
other.format == format &&
|
||||
other.progressive == progressive &&
|
||||
other.quality == quality;
|
||||
|
||||
@override
|
||||
@@ -41,17 +37,15 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(format.hashCode) +
|
||||
(progressive.hashCode) +
|
||||
(quality.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, progressive=$progressive, quality=$quality]';
|
||||
String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, quality=$quality]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'format'] = this.format;
|
||||
json[r'progressive'] = this.progressive;
|
||||
json[r'quality'] = this.quality;
|
||||
return json;
|
||||
}
|
||||
@@ -67,7 +61,6 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
return SystemConfigGeneratedFullsizeImageDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
format: ImageFormat.fromJson(json[r'format'])!,
|
||||
progressive: mapValueOfType<bool>(json, r'progressive') ?? false,
|
||||
quality: mapValueOfType<int>(json, r'quality')!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,15 +14,12 @@ class SystemConfigGeneratedImageDto {
|
||||
/// Returns a new [SystemConfigGeneratedImageDto] instance.
|
||||
SystemConfigGeneratedImageDto({
|
||||
required this.format,
|
||||
this.progressive = false,
|
||||
required this.quality,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
ImageFormat format;
|
||||
|
||||
bool progressive;
|
||||
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 100
|
||||
int quality;
|
||||
@@ -33,7 +30,6 @@ class SystemConfigGeneratedImageDto {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto &&
|
||||
other.format == format &&
|
||||
other.progressive == progressive &&
|
||||
other.quality == quality &&
|
||||
other.size == size;
|
||||
|
||||
@@ -41,17 +37,15 @@ class SystemConfigGeneratedImageDto {
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(format.hashCode) +
|
||||
(progressive.hashCode) +
|
||||
(quality.hashCode) +
|
||||
(size.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigGeneratedImageDto[format=$format, progressive=$progressive, quality=$quality, size=$size]';
|
||||
String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'format'] = this.format;
|
||||
json[r'progressive'] = this.progressive;
|
||||
json[r'quality'] = this.quality;
|
||||
json[r'size'] = this.size;
|
||||
return json;
|
||||
@@ -67,7 +61,6 @@ class SystemConfigGeneratedImageDto {
|
||||
|
||||
return SystemConfigGeneratedImageDto(
|
||||
format: ImageFormat.fromJson(json[r'format'])!,
|
||||
progressive: mapValueOfType<bool>(json, r'progressive') ?? false,
|
||||
quality: mapValueOfType<int>(json, r'quality')!,
|
||||
size: mapValueOfType<int>(json, r'size')!,
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ function dart {
|
||||
}
|
||||
|
||||
function typescript {
|
||||
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
|
||||
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
|
||||
pnpm --filter @immich/sdk install --frozen-lockfile
|
||||
pnpm --filter @immich/sdk build
|
||||
}
|
||||
|
||||
@@ -3173,7 +3173,6 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "asset.upload",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -3226,7 +3225,6 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "job.create",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -4279,7 +4277,7 @@
|
||||
},
|
||||
"/assets/{id}/thumbnail": {
|
||||
"get": {
|
||||
"description": "Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.",
|
||||
"description": "Retrieve the thumbnail image for the specified asset.",
|
||||
"operationId": "viewAsset",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -14620,7 +14618,6 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "folder.read",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -14673,7 +14670,6 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "folder.read",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -16305,7 +16301,6 @@
|
||||
},
|
||||
"AssetMediaSize": {
|
||||
"enum": [
|
||||
"original",
|
||||
"fullsize",
|
||||
"preview",
|
||||
"thumbnail"
|
||||
@@ -18963,7 +18958,6 @@
|
||||
"face.read",
|
||||
"face.update",
|
||||
"face.delete",
|
||||
"folder.read",
|
||||
"job.create",
|
||||
"job.read",
|
||||
"library.create",
|
||||
@@ -22624,10 +22618,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"progressive": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"quality": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
@@ -22650,10 +22640,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"progressive": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"quality": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
|
||||
@@ -1538,12 +1538,10 @@ export type SystemConfigFFmpegDto = {
|
||||
export type SystemConfigGeneratedFullsizeImageDto = {
|
||||
enabled: boolean;
|
||||
format: ImageFormat;
|
||||
progressive?: boolean;
|
||||
quality: number;
|
||||
};
|
||||
export type SystemConfigGeneratedImageDto = {
|
||||
format: ImageFormat;
|
||||
progressive?: boolean;
|
||||
quality: number;
|
||||
size: number;
|
||||
};
|
||||
@@ -1877,210 +1875,6 @@ export type WorkflowUpdateDto = {
|
||||
name?: string;
|
||||
triggerType?: PluginTriggerType;
|
||||
};
|
||||
export type SyncAckV1 = {};
|
||||
export type SyncAlbumDeleteV1 = {
|
||||
albumId: string;
|
||||
};
|
||||
export type SyncAlbumToAssetDeleteV1 = {
|
||||
albumId: string;
|
||||
assetId: string;
|
||||
};
|
||||
export type SyncAlbumToAssetV1 = {
|
||||
albumId: string;
|
||||
assetId: string;
|
||||
};
|
||||
export type SyncAlbumUserDeleteV1 = {
|
||||
albumId: string;
|
||||
userId: string;
|
||||
};
|
||||
export type SyncAlbumUserV1 = {
|
||||
albumId: string;
|
||||
role: AlbumUserRole;
|
||||
userId: string;
|
||||
};
|
||||
export type SyncAlbumV1 = {
|
||||
createdAt: string;
|
||||
description: string;
|
||||
id: string;
|
||||
isActivityEnabled: boolean;
|
||||
name: string;
|
||||
order: AssetOrder;
|
||||
ownerId: string;
|
||||
thumbnailAssetId: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SyncAssetDeleteV1 = {
|
||||
assetId: string;
|
||||
};
|
||||
export type SyncAssetExifV1 = {
|
||||
assetId: string;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
dateTimeOriginal: string | null;
|
||||
description: string | null;
|
||||
exifImageHeight: number | null;
|
||||
exifImageWidth: number | null;
|
||||
exposureTime: string | null;
|
||||
fNumber: number | null;
|
||||
fileSizeInByte: number | null;
|
||||
focalLength: number | null;
|
||||
fps: number | null;
|
||||
iso: number | null;
|
||||
latitude: number | null;
|
||||
lensModel: string | null;
|
||||
longitude: number | null;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
modifyDate: string | null;
|
||||
orientation: string | null;
|
||||
profileDescription: string | null;
|
||||
projectionType: string | null;
|
||||
rating: number | null;
|
||||
state: string | null;
|
||||
timeZone: string | null;
|
||||
};
|
||||
export type SyncAssetFaceDeleteV1 = {
|
||||
assetFaceId: string;
|
||||
};
|
||||
export type SyncAssetFaceV1 = {
|
||||
assetId: string;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxY2: number;
|
||||
id: string;
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
personId: string | null;
|
||||
sourceType: string;
|
||||
};
|
||||
export type SyncAssetMetadataDeleteV1 = {
|
||||
assetId: string;
|
||||
key: string;
|
||||
};
|
||||
export type SyncAssetMetadataV1 = {
|
||||
assetId: string;
|
||||
key: string;
|
||||
value: object;
|
||||
};
|
||||
export type SyncAssetV1 = {
|
||||
checksum: string;
|
||||
deletedAt: string | null;
|
||||
duration: string | null;
|
||||
fileCreatedAt: string | null;
|
||||
fileModifiedAt: string | null;
|
||||
height: number | null;
|
||||
id: string;
|
||||
isEdited: boolean;
|
||||
isFavorite: boolean;
|
||||
libraryId: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
localDateTime: string | null;
|
||||
originalFileName: string;
|
||||
ownerId: string;
|
||||
stackId: string | null;
|
||||
thumbhash: string | null;
|
||||
"type": AssetTypeEnum;
|
||||
visibility: AssetVisibility;
|
||||
width: number | null;
|
||||
};
|
||||
export type SyncAuthUserV1 = {
|
||||
avatarColor: (UserAvatarColor) | null;
|
||||
deletedAt: string | null;
|
||||
email: string;
|
||||
hasProfileImage: boolean;
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
name: string;
|
||||
oauthId: string;
|
||||
pinCode: string | null;
|
||||
profileChangedAt: string;
|
||||
quotaSizeInBytes: number | null;
|
||||
quotaUsageInBytes: number;
|
||||
storageLabel: string | null;
|
||||
};
|
||||
export type SyncCompleteV1 = {};
|
||||
export type SyncMemoryAssetDeleteV1 = {
|
||||
assetId: string;
|
||||
memoryId: string;
|
||||
};
|
||||
export type SyncMemoryAssetV1 = {
|
||||
assetId: string;
|
||||
memoryId: string;
|
||||
};
|
||||
export type SyncMemoryDeleteV1 = {
|
||||
memoryId: string;
|
||||
};
|
||||
export type SyncMemoryV1 = {
|
||||
createdAt: string;
|
||||
data: object;
|
||||
deletedAt: string | null;
|
||||
hideAt: string | null;
|
||||
id: string;
|
||||
isSaved: boolean;
|
||||
memoryAt: string;
|
||||
ownerId: string;
|
||||
seenAt: string | null;
|
||||
showAt: string | null;
|
||||
"type": MemoryType;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SyncPartnerDeleteV1 = {
|
||||
sharedById: string;
|
||||
sharedWithId: string;
|
||||
};
|
||||
export type SyncPartnerV1 = {
|
||||
inTimeline: boolean;
|
||||
sharedById: string;
|
||||
sharedWithId: string;
|
||||
};
|
||||
export type SyncPersonDeleteV1 = {
|
||||
personId: string;
|
||||
};
|
||||
export type SyncPersonV1 = {
|
||||
birthDate: string | null;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
faceAssetId: string | null;
|
||||
id: string;
|
||||
isFavorite: boolean;
|
||||
isHidden: boolean;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SyncResetV1 = {};
|
||||
export type SyncStackDeleteV1 = {
|
||||
stackId: string;
|
||||
};
|
||||
export type SyncStackV1 = {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
ownerId: string;
|
||||
primaryAssetId: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SyncUserDeleteV1 = {
|
||||
userId: string;
|
||||
};
|
||||
export type SyncUserMetadataDeleteV1 = {
|
||||
key: UserMetadataKey;
|
||||
userId: string;
|
||||
};
|
||||
export type SyncUserMetadataV1 = {
|
||||
key: UserMetadataKey;
|
||||
userId: string;
|
||||
value: object;
|
||||
};
|
||||
export type SyncUserV1 = {
|
||||
avatarColor: (UserAvatarColor) | null;
|
||||
deletedAt: string | null;
|
||||
email: string;
|
||||
hasProfileImage: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
profileChangedAt: string;
|
||||
};
|
||||
/**
|
||||
* List all activities
|
||||
*/
|
||||
@@ -5730,7 +5524,6 @@ export enum Permission {
|
||||
FaceRead = "face.read",
|
||||
FaceUpdate = "face.update",
|
||||
FaceDelete = "face.delete",
|
||||
FolderRead = "folder.read",
|
||||
JobCreate = "job.create",
|
||||
JobRead = "job.read",
|
||||
LibraryCreate = "library.create",
|
||||
@@ -5867,7 +5660,6 @@ export enum MirrorAxis {
|
||||
Vertical = "vertical"
|
||||
}
|
||||
export enum AssetMediaSize {
|
||||
Original = "original",
|
||||
Fullsize = "fullsize",
|
||||
Preview = "preview",
|
||||
Thumbnail = "thumbnail"
|
||||
@@ -6145,8 +5937,3 @@ export enum OAuthTokenEndpointAuthMethod {
|
||||
ClientSecretPost = "client_secret_post",
|
||||
ClientSecretBasic = "client_secret_basic"
|
||||
}
|
||||
export enum UserMetadataKey {
|
||||
Preferences = "preferences",
|
||||
License = "license",
|
||||
Onboarding = "onboarding"
|
||||
}
|
||||
|
||||
213
pnpm-lock.yaml
generated
213
pnpm-lock.yaml
generated
@@ -36,7 +36,7 @@ importers:
|
||||
version: 1.20.1
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.23
|
||||
version: 4.17.22
|
||||
micromatch:
|
||||
specifier: ^4.0.8
|
||||
version: 4.0.8
|
||||
@@ -489,7 +489,7 @@ importers:
|
||||
version: 3.0.0(kysely@0.28.2)(postgres@3.4.8)
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.23
|
||||
version: 4.17.21
|
||||
luxon:
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.2
|
||||
@@ -742,7 +742,7 @@ importers:
|
||||
version: link:../open-api/typescript-sdk
|
||||
'@immich/ui':
|
||||
specifier: ^0.59.0
|
||||
version: 0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
|
||||
version: 0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
|
||||
'@mapbox/mapbox-gl-rtl-text':
|
||||
specifier: 0.2.3
|
||||
version: 0.2.3(mapbox-gl@1.13.3)
|
||||
@@ -775,7 +775,7 @@ importers:
|
||||
version: 0.41.4
|
||||
'@zoom-image/svelte':
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.8(svelte@5.48.0)
|
||||
version: 0.3.8(svelte@5.46.4)
|
||||
dom-to-image:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
@@ -802,7 +802,7 @@ importers:
|
||||
version: 4.1.0
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.23
|
||||
version: 4.17.22
|
||||
luxon:
|
||||
specifier: ^3.4.4
|
||||
version: 3.7.2
|
||||
@@ -826,16 +826,16 @@ importers:
|
||||
version: 5.2.2
|
||||
svelte-i18n:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(svelte@5.48.0)
|
||||
version: 4.0.1(svelte@5.46.4)
|
||||
svelte-jsoneditor:
|
||||
specifier: ^3.10.0
|
||||
version: 3.11.0(svelte@5.48.0)
|
||||
version: 3.11.0(svelte@5.46.4)
|
||||
svelte-maplibre:
|
||||
specifier: ^1.2.5
|
||||
version: 1.2.5(svelte@5.48.0)
|
||||
version: 1.2.5(svelte@5.46.4)
|
||||
svelte-persisted-store:
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0(svelte@5.48.0)
|
||||
version: 0.12.0(svelte@5.46.4)
|
||||
tabbable:
|
||||
specifier: ^6.2.0
|
||||
version: 6.4.0
|
||||
@@ -860,16 +860,16 @@ importers:
|
||||
version: 3.1.2
|
||||
'@sveltejs/adapter-static':
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
|
||||
version: 3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
|
||||
'@sveltejs/enhanced-img':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.27.1
|
||||
version: 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: 6.2.4
|
||||
version: 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.7
|
||||
version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
@@ -878,7 +878,7 @@ importers:
|
||||
version: 6.9.1
|
||||
'@testing-library/svelte':
|
||||
specifier: ^5.2.8
|
||||
version: 5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 5.3.1(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.5.2
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
@@ -917,7 +917,7 @@ importers:
|
||||
version: 6.0.2(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-svelte:
|
||||
specifier: ^3.12.4
|
||||
version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.48.0)
|
||||
version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.4)
|
||||
eslint-plugin-unicorn:
|
||||
specifier: ^62.0.0
|
||||
version: 62.0.0(eslint@9.39.2(jiti@2.6.1))
|
||||
@@ -938,19 +938,19 @@ importers:
|
||||
version: 4.2.0(prettier@3.8.0)
|
||||
prettier-plugin-svelte:
|
||||
specifier: ^3.3.3
|
||||
version: 3.4.1(prettier@3.8.0)(svelte@5.48.0)
|
||||
version: 3.4.1(prettier@3.8.0)(svelte@5.46.4)
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.5(rollup@4.55.1)
|
||||
svelte:
|
||||
specifier: 5.48.0
|
||||
version: 5.48.0
|
||||
specifier: 5.46.4
|
||||
version: 5.46.4
|
||||
svelte-check:
|
||||
specifier: ^4.1.5
|
||||
version: 4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3)
|
||||
version: 4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3)
|
||||
svelte-eslint-parser:
|
||||
specifier: ^1.3.3
|
||||
version: 1.4.1(svelte@5.48.0)
|
||||
version: 1.4.1(svelte@5.46.4)
|
||||
tailwindcss:
|
||||
specifier: ^4.1.7
|
||||
version: 4.1.18
|
||||
@@ -8916,8 +8916,8 @@ packages:
|
||||
lodash-es@4.17.21:
|
||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||
|
||||
lodash-es@4.17.23:
|
||||
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
|
||||
lodash-es@4.17.22:
|
||||
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
|
||||
|
||||
lodash.camelcase@4.3.0:
|
||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||
@@ -8964,9 +8964,6 @@ packages:
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
log-symbols@4.1.0:
|
||||
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -11608,8 +11605,8 @@ packages:
|
||||
peerDependencies:
|
||||
svelte: ^5.30.2
|
||||
|
||||
svelte@5.48.0:
|
||||
resolution: {integrity: sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==}
|
||||
svelte@5.46.4:
|
||||
resolution: {integrity: sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
svg-parser@2.0.4:
|
||||
@@ -14545,7 +14542,7 @@ snapshots:
|
||||
html-tags: 3.3.1
|
||||
html-webpack-plugin: 5.6.5(webpack@5.104.1)
|
||||
leven: 3.1.0
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
open: 8.4.2
|
||||
p-map: 4.0.0
|
||||
prompts: 2.4.2
|
||||
@@ -14662,7 +14659,7 @@ snapshots:
|
||||
cheerio: 1.0.0-rc.12
|
||||
feed: 4.2.2
|
||||
fs-extra: 11.3.2
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
schema-dts: 1.1.5
|
||||
@@ -14704,7 +14701,7 @@ snapshots:
|
||||
combine-promises: 1.2.0
|
||||
fs-extra: 11.3.2
|
||||
js-yaml: 4.1.1
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
schema-dts: 1.1.5
|
||||
@@ -15017,7 +15014,7 @@ snapshots:
|
||||
'@mdx-js/react': 3.1.1(@types/react@19.2.8)(react@18.3.1)
|
||||
clsx: 2.1.1
|
||||
infima: 0.2.0-alpha.45
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
nprogress: 0.2.0
|
||||
postcss: 8.5.6
|
||||
prism-react-renderer: 2.4.1(react@18.3.1)
|
||||
@@ -15115,7 +15112,7 @@ snapshots:
|
||||
clsx: 2.1.1
|
||||
eta: 2.2.0
|
||||
fs-extra: 11.3.2
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
tslib: 2.8.1
|
||||
@@ -15190,7 +15187,7 @@ snapshots:
|
||||
fs-extra: 11.3.2
|
||||
joi: 17.13.3
|
||||
js-yaml: 4.1.1
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- '@swc/core'
|
||||
@@ -15215,7 +15212,7 @@ snapshots:
|
||||
gray-matter: 4.0.3
|
||||
jiti: 1.21.7
|
||||
js-yaml: 4.1.1
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
micromatch: 4.0.8
|
||||
p-queue: 6.6.2
|
||||
prompts: 2.4.2
|
||||
@@ -15600,7 +15597,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
|
||||
'@grpc/grpc-js@1.14.3':
|
||||
dependencies:
|
||||
@@ -15744,19 +15741,19 @@ snapshots:
|
||||
|
||||
'@immich/justified-layout-wasm@0.4.3': {}
|
||||
|
||||
'@immich/svelte-markdown-preprocess@0.1.0(svelte@5.48.0)':
|
||||
'@immich/svelte-markdown-preprocess@0.1.0(svelte@5.46.4)':
|
||||
dependencies:
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
|
||||
'@immich/ui@0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)':
|
||||
'@immich/ui@0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)':
|
||||
dependencies:
|
||||
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.48.0)
|
||||
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.4)
|
||||
'@internationalized/date': 3.10.0
|
||||
'@mdi/js': 7.4.47
|
||||
bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
|
||||
bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
|
||||
luxon: 3.7.2
|
||||
simple-icons: 16.4.0
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
svelte-highlight: 7.9.0
|
||||
tailwind-merge: 3.4.0
|
||||
tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18)
|
||||
@@ -17483,17 +17480,17 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
|
||||
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
|
||||
dependencies:
|
||||
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
|
||||
'@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
magic-string: 0.30.21
|
||||
sharp: 0.34.5
|
||||
svelte: 5.48.0
|
||||
svelte-parse-markup: 0.1.5(svelte@5.48.0)
|
||||
svelte: 5.46.4
|
||||
svelte-parse-markup: 0.1.5(svelte@5.46.4)
|
||||
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-imagetools: 9.0.2(rollup@4.55.1)
|
||||
zimmerframe: 1.1.4
|
||||
@@ -17501,11 +17498,11 @@ snapshots:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
'@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0)
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@types/cookie': 0.6.0
|
||||
acorn: 8.15.0
|
||||
cookie: 0.6.0
|
||||
@@ -17517,28 +17514,28 @@ snapshots:
|
||||
sade: 1.8.1
|
||||
set-cookie-parser: 2.7.2
|
||||
sirv: 3.0.2
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
typescript: 5.9.3
|
||||
|
||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
debug: 4.4.3
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
deepmerge: 4.3.1
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
transitivePeerDependencies:
|
||||
@@ -17786,15 +17783,15 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
redent: 3.0.0
|
||||
|
||||
'@testing-library/svelte-core@1.0.0(svelte@5.48.0)':
|
||||
'@testing-library/svelte-core@1.0.0(svelte@5.46.4)':
|
||||
dependencies:
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
|
||||
'@testing-library/svelte@5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@testing-library/svelte@5.3.1(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
'@testing-library/svelte-core': 1.0.0(svelte@5.48.0)
|
||||
svelte: 5.48.0
|
||||
'@testing-library/svelte-core': 1.0.0(svelte@5.46.4)
|
||||
svelte: 5.46.4
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
@@ -18676,10 +18673,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@namnode/store': 0.1.0
|
||||
|
||||
'@zoom-image/svelte@0.3.8(svelte@5.48.0)':
|
||||
'@zoom-image/svelte@0.3.8(svelte@5.46.4)':
|
||||
dependencies:
|
||||
'@zoom-image/core': 0.41.4
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
|
||||
abab@2.0.6:
|
||||
optional: true
|
||||
@@ -18845,7 +18842,7 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
is-stream: 2.0.1
|
||||
lazystream: 1.0.1
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
@@ -19040,15 +19037,15 @@ snapshots:
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
|
||||
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.3
|
||||
'@floating-ui/dom': 1.7.4
|
||||
'@internationalized/date': 3.10.0
|
||||
esm-env: 1.2.2
|
||||
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
|
||||
svelte: 5.48.0
|
||||
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
|
||||
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
|
||||
svelte: 5.46.4
|
||||
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
|
||||
tabbable: 6.4.0
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
@@ -19335,7 +19332,7 @@ snapshots:
|
||||
chevrotain-allstar@0.3.1(chevrotain@11.0.3):
|
||||
dependencies:
|
||||
chevrotain: 11.0.3
|
||||
lodash-es: 4.17.23
|
||||
lodash-es: 4.17.22
|
||||
|
||||
chevrotain@11.0.3:
|
||||
dependencies:
|
||||
@@ -20030,7 +20027,7 @@ snapshots:
|
||||
dagre-d3-es@7.0.13:
|
||||
dependencies:
|
||||
d3: 7.9.0
|
||||
lodash-es: 4.17.23
|
||||
lodash-es: 4.17.22
|
||||
|
||||
data-urls@3.0.2:
|
||||
dependencies:
|
||||
@@ -20578,7 +20575,7 @@ snapshots:
|
||||
'@types/eslint': 9.6.1
|
||||
eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1))
|
||||
|
||||
eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.48.0):
|
||||
eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.4):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -20590,9 +20587,9 @@ snapshots:
|
||||
postcss-load-config: 3.1.4(postcss@8.5.6)
|
||||
postcss-safe-parser: 7.0.1(postcss@8.5.6)
|
||||
semver: 7.7.3
|
||||
svelte-eslint-parser: 1.4.1(svelte@5.48.0)
|
||||
svelte-eslint-parser: 1.4.1(svelte@5.46.4)
|
||||
optionalDependencies:
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
@@ -21578,7 +21575,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/html-minifier-terser': 6.1.0
|
||||
html-minifier-terser: 6.1.0
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
pretty-error: 4.0.0
|
||||
tapable: 2.3.0
|
||||
optionalDependencies:
|
||||
@@ -21772,7 +21769,7 @@ snapshots:
|
||||
cli-cursor: 3.1.0
|
||||
cli-width: 3.0.0
|
||||
figures: 3.2.0
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
mute-stream: 0.0.8
|
||||
ora: 5.4.1
|
||||
run-async: 2.4.1
|
||||
@@ -22379,7 +22376,7 @@ snapshots:
|
||||
|
||||
lodash-es@4.17.21: {}
|
||||
|
||||
lodash-es@4.17.23: {}
|
||||
lodash-es@4.17.22: {}
|
||||
|
||||
lodash.camelcase@4.3.0: {}
|
||||
|
||||
@@ -22411,8 +22408,6 @@ snapshots:
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
log-symbols@4.1.0:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
@@ -22815,7 +22810,7 @@ snapshots:
|
||||
dompurify: 3.3.1
|
||||
katex: 0.16.27
|
||||
khroma: 2.1.0
|
||||
lodash-es: 4.17.23
|
||||
lodash-es: 4.17.22
|
||||
marked: 16.4.2
|
||||
roughjs: 4.6.6
|
||||
stylis: 4.3.6
|
||||
@@ -23388,7 +23383,7 @@ snapshots:
|
||||
|
||||
node-emoji@1.11.0:
|
||||
dependencies:
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
|
||||
node-emoji@2.2.0:
|
||||
dependencies:
|
||||
@@ -24380,16 +24375,16 @@ snapshots:
|
||||
dependencies:
|
||||
prettier: 3.8.0
|
||||
|
||||
prettier-plugin-svelte@3.4.1(prettier@3.8.0)(svelte@5.48.0):
|
||||
prettier-plugin-svelte@3.4.1(prettier@3.8.0)(svelte@5.46.4):
|
||||
dependencies:
|
||||
prettier: 3.8.0
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
|
||||
prettier@3.8.0: {}
|
||||
|
||||
pretty-error@4.0.0:
|
||||
dependencies:
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
renderkid: 3.0.0
|
||||
|
||||
pretty-format@27.5.1:
|
||||
@@ -24862,7 +24857,7 @@ snapshots:
|
||||
css-select: 4.3.0
|
||||
dom-converter: 0.2.0
|
||||
htmlparser2: 6.1.0
|
||||
lodash: 4.17.23
|
||||
lodash: 4.17.21
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
repeat-string@1.6.1: {}
|
||||
@@ -25010,14 +25005,14 @@ snapshots:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
runed@0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
|
||||
runed@0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
esm-env: 1.2.2
|
||||
lz-string: 1.5.0
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
optionalDependencies:
|
||||
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
|
||||
rw@1.3.3: {}
|
||||
|
||||
@@ -25647,23 +25642,23 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
svelte-awesome@3.3.5(svelte@5.48.0):
|
||||
svelte-awesome@3.3.5(svelte@5.46.4):
|
||||
dependencies:
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
|
||||
svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3):
|
||||
svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
chokidar: 4.0.3
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picocolors: 1.1.1
|
||||
sade: 1.8.1
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- picomatch
|
||||
|
||||
svelte-eslint-parser@1.4.1(svelte@5.48.0):
|
||||
svelte-eslint-parser@1.4.1(svelte@5.46.4):
|
||||
dependencies:
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
@@ -25672,7 +25667,7 @@ snapshots:
|
||||
postcss-scss: 4.0.9(postcss@8.5.6)
|
||||
postcss-selector-parser: 7.1.1
|
||||
optionalDependencies:
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
|
||||
svelte-floating-ui@1.5.8:
|
||||
dependencies:
|
||||
@@ -25685,7 +25680,7 @@ snapshots:
|
||||
dependencies:
|
||||
highlight.js: 11.11.1
|
||||
|
||||
svelte-i18n@4.0.1(svelte@5.48.0):
|
||||
svelte-i18n@4.0.1(svelte@5.46.4):
|
||||
dependencies:
|
||||
cli-color: 2.0.4
|
||||
deepmerge: 4.3.1
|
||||
@@ -25693,10 +25688,10 @@ snapshots:
|
||||
estree-walker: 2.0.2
|
||||
intl-messageformat: 10.7.18
|
||||
sade: 1.8.1
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
tiny-glob: 0.2.9
|
||||
|
||||
svelte-jsoneditor@3.11.0(svelte@5.48.0):
|
||||
svelte-jsoneditor@3.11.0(svelte@5.46.4):
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/commands': 6.10.1
|
||||
@@ -25719,46 +25714,46 @@ snapshots:
|
||||
json-source-map: 0.6.1
|
||||
jsonpath-plus: 10.3.0
|
||||
jsonrepair: 3.13.1
|
||||
lodash-es: 4.17.23
|
||||
lodash-es: 4.17.22
|
||||
memoize-one: 6.0.0
|
||||
natural-compare-lite: 1.4.0
|
||||
sass: 1.97.1
|
||||
svelte: 5.48.0
|
||||
svelte-awesome: 3.3.5(svelte@5.48.0)
|
||||
svelte: 5.46.4
|
||||
svelte-awesome: 3.3.5(svelte@5.46.4)
|
||||
svelte-select: 5.8.3
|
||||
vanilla-picker: 2.12.3
|
||||
|
||||
svelte-maplibre@1.2.5(svelte@5.48.0):
|
||||
svelte-maplibre@1.2.5(svelte@5.46.4):
|
||||
dependencies:
|
||||
d3-geo: 3.1.1
|
||||
dequal: 2.0.3
|
||||
just-compare: 2.3.0
|
||||
maplibre-gl: 5.16.0
|
||||
pmtiles: 3.2.1
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
|
||||
svelte-parse-markup@0.1.5(svelte@5.48.0):
|
||||
svelte-parse-markup@0.1.5(svelte@5.46.4):
|
||||
dependencies:
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
|
||||
svelte-persisted-store@0.12.0(svelte@5.48.0):
|
||||
svelte-persisted-store@0.12.0(svelte@5.46.4):
|
||||
dependencies:
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
|
||||
svelte-select@5.8.3:
|
||||
dependencies:
|
||||
svelte-floating-ui: 1.5.8
|
||||
|
||||
svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
|
||||
svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
|
||||
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
|
||||
style-to-object: 1.0.14
|
||||
svelte: 5.48.0
|
||||
svelte: 5.46.4
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
svelte@5.48.0:
|
||||
svelte@5.46.4:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from 'src/enum';
|
||||
import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types';
|
||||
|
||||
export type SystemConfig = {
|
||||
export interface SystemConfig {
|
||||
backup: {
|
||||
database: {
|
||||
enabled: boolean;
|
||||
@@ -187,7 +187,7 @@ export type SystemConfig = {
|
||||
user: {
|
||||
deleteDelay: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type MachineLearningConfig = SystemConfig['machineLearning'];
|
||||
|
||||
@@ -319,13 +319,11 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
format: ImageFormat.Webp,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
preview: {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
colorspace: Colorspace.P3,
|
||||
extractEmbedded: false,
|
||||
@@ -333,7 +331,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
enabled: false,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
},
|
||||
newVersionCheck: {
|
||||
|
||||
@@ -147,8 +147,7 @@ export class AssetMediaController {
|
||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||
@Endpoint({
|
||||
summary: 'View asset thumbnail',
|
||||
description:
|
||||
'Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.',
|
||||
description: 'Retrieve the thumbnail image for the specified asset.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
async viewAsset(
|
||||
@@ -203,7 +202,7 @@ export class AssetMediaController {
|
||||
}
|
||||
|
||||
@Post('exist')
|
||||
@Authenticated({ permission: Permission.AssetUpload })
|
||||
@Authenticated()
|
||||
@Endpoint({
|
||||
summary: 'Check existing assets',
|
||||
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
|
||||
|
||||
@@ -66,7 +66,7 @@ export class AssetController {
|
||||
}
|
||||
|
||||
@Post('jobs')
|
||||
@Authenticated({ permission: Permission.JobCreate })
|
||||
@Authenticated()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Run an asset job',
|
||||
|
||||
@@ -70,33 +70,5 @@ describe(SystemConfigController.name, () => {
|
||||
expect(body).toEqual(errorDto.badRequest(['nightlyTasks.databaseCleanup must be a boolean value']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('image', () => {
|
||||
it('should accept config without optional progressive property', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
delete config.image.thumbnail.progressive;
|
||||
delete config.image.preview.progressive;
|
||||
delete config.image.fullsize.progressive;
|
||||
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should accept config with progressive set to true', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
config.image.thumbnail.progressive = true;
|
||||
config.image.preview.progressive = true;
|
||||
config.image.fullsize.progressive = true;
|
||||
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should reject invalid progressive value', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
(config.image.thumbnail.progressive as any) = 'invalid';
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['image.thumbnail.progressive must be a boolean value']));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { ApiTag } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ViewController {
|
||||
constructor(private service: ViewService) {}
|
||||
|
||||
@Get('folder/unique-paths')
|
||||
@Authenticated({ permission: Permission.FolderRead })
|
||||
@Authenticated()
|
||||
@Endpoint({
|
||||
summary: 'Retrieve unique paths',
|
||||
description: 'Retrieve a list of unique folder paths from asset original paths.',
|
||||
@@ -24,7 +24,7 @@ export class ViewController {
|
||||
}
|
||||
|
||||
@Get('folder')
|
||||
@Authenticated({ permission: Permission.FolderRead })
|
||||
@Authenticated()
|
||||
@Endpoint({
|
||||
summary: 'Retrieve assets by original path',
|
||||
description: 'Retrieve assets that are children of a specific folder.',
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AssetVisibility } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetMediaSize {
|
||||
Original = 'original',
|
||||
/**
|
||||
* An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF.
|
||||
* or otherwise the original image itself.
|
||||
|
||||
@@ -585,9 +585,6 @@ class SystemConfigGeneratedImageDto {
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
size!: number;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: false })
|
||||
progressive?: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigGeneratedFullsizeImageDto {
|
||||
@@ -603,9 +600,6 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
quality!: number;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: false })
|
||||
progressive?: boolean;
|
||||
}
|
||||
|
||||
export class SystemConfigImageDto {
|
||||
|
||||
@@ -146,8 +146,6 @@ export enum Permission {
|
||||
FaceUpdate = 'face.update',
|
||||
FaceDelete = 'face.delete',
|
||||
|
||||
FolderRead = 'folder.read',
|
||||
|
||||
JobCreate = 'job.create',
|
||||
JobRead = 'job.read',
|
||||
|
||||
|
||||
@@ -15,5 +15,3 @@ from
|
||||
"asset_edit"
|
||||
where
|
||||
"assetId" = $1
|
||||
order by
|
||||
"sequence" asc
|
||||
|
||||
@@ -585,40 +585,3 @@ where
|
||||
and "libraryId" = $2::uuid
|
||||
and "isExternal" = $3
|
||||
)
|
||||
|
||||
-- AssetRepository.getForOriginal
|
||||
select
|
||||
"originalFileName",
|
||||
"asset_file"."path" as "editedPath",
|
||||
"originalPath"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
|
||||
and "asset_file"."isEdited" = $1
|
||||
and "asset_file"."type" = $2
|
||||
where
|
||||
"asset"."id" = $3
|
||||
|
||||
-- AssetRepository.getForThumbnail
|
||||
select
|
||||
"asset"."originalPath",
|
||||
"asset"."originalFileName",
|
||||
"asset_file"."path" as "path"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
|
||||
and "asset_file"."type" = $1
|
||||
where
|
||||
"asset"."id" = $2
|
||||
order by
|
||||
"asset_file"."isEdited" desc
|
||||
|
||||
-- AssetRepository.getForVideo
|
||||
select
|
||||
"asset"."encodedVideoPath",
|
||||
"asset"."originalPath"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = $2
|
||||
|
||||
@@ -12,14 +12,14 @@ export class AssetEditRepository {
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID],
|
||||
})
|
||||
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
async replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
|
||||
|
||||
if (edits.length > 0) {
|
||||
return trx
|
||||
.insertInto('asset_edit')
|
||||
.values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit })))
|
||||
.values(edits.map((edit) => ({ assetId, ...edit })))
|
||||
.returning(['action', 'parameters'])
|
||||
.execute() as Promise<AssetEditActionItem[]>;
|
||||
}
|
||||
@@ -31,12 +31,11 @@ export class AssetEditRepository {
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID],
|
||||
})
|
||||
getAll(assetId: string): Promise<AssetEditActionItem[]> {
|
||||
async getAll(assetId: string): Promise<AssetEditActionItem[]> {
|
||||
return this.db
|
||||
.selectFrom('asset_edit')
|
||||
.select(['action', 'parameters'])
|
||||
.where('assetId', '=', assetId)
|
||||
.orderBy('sequence', 'asc')
|
||||
.execute() as Promise<AssetEditActionItem[]>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,47 +1009,4 @@ export class AssetRepository {
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, true] })
|
||||
async getForOriginal(id: string, isEdited: boolean) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select('originalFileName')
|
||||
.where('asset.id', '=', id)
|
||||
.$if(isEdited, (qb) =>
|
||||
qb
|
||||
.leftJoin('asset_file', (join) =>
|
||||
join
|
||||
.onRef('asset.id', '=', 'asset_file.assetId')
|
||||
.on('asset_file.isEdited', '=', true)
|
||||
.on('asset_file.type', '=', AssetFileType.FullSize),
|
||||
)
|
||||
.select('asset_file.path as editedPath'),
|
||||
)
|
||||
.select('originalPath')
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Preview, true] })
|
||||
async getForThumbnail(id: string, type: AssetFileType, isEdited: boolean) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', id)
|
||||
.leftJoin('asset_file', (join) =>
|
||||
join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', type),
|
||||
)
|
||||
.select(['asset.originalPath', 'asset.originalFileName', 'asset_file.path as path'])
|
||||
.orderBy('asset_file.isEdited', isEdited ? 'desc' : 'asc')
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForVideo(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.encodedVideoPath', 'asset.originalPath'])
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,6 @@ export class MediaRepository {
|
||||
quality: options.quality,
|
||||
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
||||
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
|
||||
progressive: options.progressive,
|
||||
});
|
||||
|
||||
await decoded.toFile(output);
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`DELETE FROM "asset_edit";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_edit" ADD "sequence" integer NOT NULL;`.execute(db);
|
||||
await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_assetId_sequence_uq" UNIQUE ("assetId", "sequence");`.execute(
|
||||
db,
|
||||
);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_edit" DROP CONSTRAINT "asset_edit_assetId_sequence_uq";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_edit" DROP COLUMN "sequence";`.execute(db);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Unique,
|
||||
} from 'src/sql-tools';
|
||||
|
||||
@Table('asset_edit')
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
referencingOldTableAs: 'deleted_edit',
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
@Unique({ columns: ['assetId', 'sequence'] })
|
||||
export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
@@ -33,7 +31,4 @@ export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
parameters!: AssetEditActionParameter[T];
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
sequence!: number;
|
||||
}
|
||||
|
||||
@@ -500,9 +500,17 @@ describe(AssetMediaService.name, () => {
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not found', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true, edits: true });
|
||||
});
|
||||
|
||||
it('should download a file', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getForOriginal.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -528,10 +536,7 @@ describe(AssetMediaService.name, () => {
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({
|
||||
...editedAsset,
|
||||
editedPath: '/uploads/user-id/fullsize/edited.jpg',
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue(editedAsset);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -557,10 +562,7 @@ describe(AssetMediaService.name, () => {
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({
|
||||
...editedAsset,
|
||||
editedPath: '/uploads/user-id/fullsize/edited.jpg',
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue(editedAsset);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -586,7 +588,7 @@ describe(AssetMediaService.name, () => {
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getForOriginal.mockResolvedValue(editedAsset);
|
||||
mocks.asset.getById.mockResolvedValue(editedAsset);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: false })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -597,6 +599,29 @@ describe(AssetMediaService.name, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should download original file when no edits exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/original/path.jpg',
|
||||
fileName: 'asset-id.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a not found when edits exist but no edited file available', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.withCropEdit);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewThumbnail', () => {
|
||||
@@ -608,9 +633,54 @@ describe(AssetMediaService.name, () => {
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw an error if the requested thumbnail file does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [] });
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw an error if the requested preview file does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
files: [
|
||||
{
|
||||
id: '42',
|
||||
path: '/path/to/preview',
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should fall back to preview if the requested thumbnail file does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/path/to/preview.jpg' });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
files: [
|
||||
{
|
||||
id: '42',
|
||||
path: '/path/to/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
@@ -626,7 +696,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should get preview file', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/uploads/user-id/thumbs/path.jpg' });
|
||||
mocks.asset.getById.mockResolvedValue({ ...assetStub.image });
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
).resolves.toEqual(
|
||||
@@ -641,7 +711,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should get thumbnail file', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/uploads/user-id/webp/path.ext' });
|
||||
mocks.asset.getById.mockResolvedValue({ ...assetStub.image });
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
@@ -652,65 +722,9 @@ describe(AssetMediaService.name, () => {
|
||||
fileName: 'asset-id_thumbnail.ext',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
});
|
||||
|
||||
it('should get original thumbnail by default', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/original-thumbnail.jpg',
|
||||
});
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/original-thumbnail.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
});
|
||||
|
||||
it('should get edited thumbnail when edited=true', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
|
||||
});
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: true }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
|
||||
});
|
||||
|
||||
it('should get original thumbnail when edited=false', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/original-thumbnail.jpg',
|
||||
});
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: false }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/original-thumbnail.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
});
|
||||
// TODO: Edited asset tests
|
||||
});
|
||||
|
||||
describe('playbackVideo', () => {
|
||||
@@ -722,15 +736,22 @@ describe(AssetMediaService.name, () => {
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
});
|
||||
|
||||
it('should throw an error if the video asset could not be found', async () => {
|
||||
it('should throw an error if the asset does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not a video', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should return the encoded video path if available', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id]));
|
||||
mocks.asset.getForVideo.mockResolvedValue(assetStub.hasEncodedVideo);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.hasEncodedVideo);
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -743,7 +764,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should fall back to the original path', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id]));
|
||||
mocks.asset.getForVideo.mockResolvedValue(assetStub.video);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.video);
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
|
||||
@@ -25,6 +25,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
CacheControl,
|
||||
JobName,
|
||||
@@ -35,7 +36,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { UploadFile, UploadRequest } from 'src/types';
|
||||
import { requireUploadAccess } from 'src/utils/access';
|
||||
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { asUploadRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -196,17 +197,27 @@ export class AssetMediaService extends BaseService {
|
||||
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
|
||||
|
||||
const { originalPath, originalFileName, editedPath } = await this.assetRepository.getForOriginal(
|
||||
id,
|
||||
dto.edited ?? false,
|
||||
);
|
||||
const asset = await this.findOrFail(id);
|
||||
|
||||
const path = editedPath ?? originalPath!;
|
||||
if (asset.edits!.length > 0 && (dto.edited ?? false)) {
|
||||
const { editedFullsizeFile } = getAssetFiles(asset.files ?? []);
|
||||
|
||||
if (!editedFullsizeFile) {
|
||||
throw new NotFoundException('Edited asset media not found');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: editedFullsizeFile.path,
|
||||
fileName: getFileNameWithoutExtension(asset.originalFileName) + getFilenameExtension(editedFullsizeFile.path),
|
||||
contentType: mimeTypes.lookup(editedFullsizeFile.path),
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
});
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path,
|
||||
fileName: getFileNameWithoutExtension(originalFileName) + getFilenameExtension(path),
|
||||
contentType: mimeTypes.lookup(path),
|
||||
path: asset.originalPath,
|
||||
fileName: asset.originalFileName,
|
||||
contentType: mimeTypes.lookup(asset.originalPath),
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
});
|
||||
}
|
||||
@@ -218,38 +229,45 @@ export class AssetMediaService extends BaseService {
|
||||
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||
|
||||
if (dto.size === AssetMediaSize.Original) {
|
||||
throw new BadRequestException('May not request original file');
|
||||
const asset = await this.findOrFail(id);
|
||||
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
|
||||
|
||||
const files = getAssetFiles(asset.files ?? []);
|
||||
|
||||
const requestingEdited = (dto.edited ?? false) && asset.edits!.length > 0;
|
||||
const { fullsizeFile, previewFile, thumbnailFile } = {
|
||||
fullsizeFile: requestingEdited ? files.editedFullsizeFile : files.fullsizeFile,
|
||||
previewFile: requestingEdited ? files.editedPreviewFile : files.previewFile,
|
||||
thumbnailFile: requestingEdited ? files.editedThumbnailFile : files.thumbnailFile,
|
||||
};
|
||||
|
||||
let filepath = previewFile?.path;
|
||||
if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) {
|
||||
filepath = thumbnailFile.path;
|
||||
} else if (size === AssetMediaSize.FULLSIZE) {
|
||||
if (mimeTypes.isWebSupportedImage(asset.originalPath) && !dto.edited) {
|
||||
// use original file for web supported images
|
||||
return { targetSize: 'original' };
|
||||
}
|
||||
if (!fullsizeFile) {
|
||||
// downgrade to preview if fullsize is not available.
|
||||
// e.g. disabled or not yet (re)generated
|
||||
return { targetSize: AssetMediaSize.PREVIEW };
|
||||
}
|
||||
filepath = fullsizeFile.path;
|
||||
}
|
||||
|
||||
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
|
||||
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
|
||||
id,
|
||||
size,
|
||||
dto.edited ?? false,
|
||||
);
|
||||
|
||||
if (size === AssetFileType.FullSize && mimeTypes.isWebSupportedImage(originalPath) && !dto.edited) {
|
||||
// use original file for web supported images
|
||||
return { targetSize: 'original' };
|
||||
}
|
||||
|
||||
if (dto.size === AssetMediaSize.FULLSIZE && !path) {
|
||||
// downgrade to preview if fullsize is not available.
|
||||
// e.g. disabled or not yet (re)generated
|
||||
return { targetSize: AssetMediaSize.PREVIEW };
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
if (!filepath) {
|
||||
throw new NotFoundException('Asset media not found');
|
||||
}
|
||||
|
||||
const fileName = `${getFileNameWithoutExtension(originalFileName)}_${size}${getFilenameExtension(path)}`;
|
||||
let fileName = getFileNameWithoutExtension(asset.originalFileName);
|
||||
fileName += `_${size}`;
|
||||
fileName += getFilenameExtension(filepath);
|
||||
|
||||
return new ImmichFileResponse({
|
||||
fileName,
|
||||
path,
|
||||
contentType: mimeTypes.lookup(path),
|
||||
path: filepath,
|
||||
contentType: mimeTypes.lookup(filepath),
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
});
|
||||
}
|
||||
@@ -257,10 +275,10 @@ export class AssetMediaService extends BaseService {
|
||||
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||
|
||||
const asset = await this.assetRepository.getForVideo(id);
|
||||
const asset = await this.findOrFail(id);
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found or asset is not a video');
|
||||
if (asset.type !== AssetType.Video) {
|
||||
throw new BadRequestException('Asset is not a video');
|
||||
}
|
||||
|
||||
const filepath = asset.encodedVideoPath || asset.originalPath;
|
||||
@@ -469,4 +487,13 @@ export class AssetMediaService extends BaseService {
|
||||
throw new BadRequestException('Quota has been exceeded!');
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const asset = await this.assetRepository.getById(id, { files: true, edits: true });
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +352,6 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -366,7 +365,6 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Webp,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -577,7 +575,6 @@ describe(MediaService.name, () => {
|
||||
format,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -591,7 +588,6 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Webp,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -626,7 +622,6 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -640,7 +635,6 @@ describe(MediaService.name, () => {
|
||||
format,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -649,58 +643,6 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate progressive JPEG for preview when enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { progressive: true }, thumbnail: { progressive: false } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
format: ImageFormat.Jpeg,
|
||||
progressive: true,
|
||||
}),
|
||||
expect.stringContaining('preview.jpeg'),
|
||||
);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
format: ImageFormat.Webp,
|
||||
progressive: false,
|
||||
}),
|
||||
expect.stringContaining('thumbnail.webp'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate progressive JPEG for thumbnail when enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
format: ImageFormat.Jpeg,
|
||||
progressive: false,
|
||||
}),
|
||||
expect.stringContaining('preview.jpeg'),
|
||||
);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
format: ImageFormat.Jpeg,
|
||||
progressive: true,
|
||||
}),
|
||||
expect.stringContaining('thumbnail.jpeg'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete previous thumbnail if different path', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
@@ -834,7 +776,6 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -866,7 +807,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Webp,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -880,7 +820,6 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -910,7 +849,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -923,7 +861,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
size: 1440,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
@@ -955,7 +892,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -1012,7 +948,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.Srgb,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -1052,7 +987,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Webp,
|
||||
quality: 90,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
@@ -1060,27 +994,6 @@ describe(MediaService.name, () => {
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate progressive JPEG for fullsize when enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { fullsize: { enabled: true, format: ImageFormat.Jpeg, progressive: true } },
|
||||
});
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
format: ImageFormat.Jpeg,
|
||||
progressive: true,
|
||||
}),
|
||||
expect.stringContaining('fullsize.jpeg'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAssetEditThumbnailGeneration', () => {
|
||||
@@ -1285,7 +1198,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1330,7 +1242,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1373,7 +1284,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1416,7 +1326,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1459,7 +1368,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1502,7 +1410,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1550,7 +1457,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
|
||||
@@ -351,7 +351,6 @@ export class MediaService extends BaseService {
|
||||
const fullsizeOptions = {
|
||||
format: image.fullsize.format,
|
||||
quality: image.fullsize.quality,
|
||||
progressive: image.fullsize.progressive,
|
||||
...thumbnailOptions,
|
||||
};
|
||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
||||
@@ -435,7 +434,6 @@ export class MediaService extends BaseService {
|
||||
format: ImageFormat.Jpeg,
|
||||
raw: info,
|
||||
quality: image.thumbnail.quality,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
size: FACE_THUMBNAIL_SIZE,
|
||||
edits: [
|
||||
|
||||
@@ -387,7 +387,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ TagsList: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -398,7 +398,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
|
||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -419,7 +419,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a string', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ Keywords: 'Parent' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -430,7 +430,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ Keywords: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -441,10 +441,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
|
||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -456,7 +453,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchal tags from Keywords', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
|
||||
mockReadTags({ Keywords: 'Parent/Child' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -476,10 +473,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore Keywords when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'Child'] }),
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Child'] } } as any);
|
||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -499,10 +493,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'TagA'] }),
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'TagA'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -524,10 +515,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -539,7 +527,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Mom|Dad'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
|
||||
@@ -554,10 +542,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }),
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Parent2/Child2'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
|
||||
@@ -167,15 +167,13 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
size: 250,
|
||||
format: ImageFormat.Webp,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
preview: {
|
||||
size: 1440,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
fullsize: { enabled: false, format: ImageFormat.Jpeg, quality: 80, progressive: false },
|
||||
fullsize: { enabled: false, format: ImageFormat.Jpeg, quality: 80 },
|
||||
colorspace: Colorspace.P3,
|
||||
extractEmbedded: false,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import { JobStatus } from 'src/enum';
|
||||
import { TagService } from 'src/services/tag.service';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(TagService.name, () => {
|
||||
@@ -192,10 +191,7 @@ describe(TagService.name, () => {
|
||||
it('should upsert records', async () => {
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
tags: [factory.tag({ value: 'tag-1' }), factory.tag({ value: 'tag-2' })],
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] } as any);
|
||||
mocks.tag.upsertAssetIds.mockResolvedValue([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||
@@ -246,10 +242,7 @@ describe(TagService.name, () => {
|
||||
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
||||
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.tag.addAssetIds.mockResolvedValue();
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
tags: [factory.tag({ value: 'tag-1' })],
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }] } as any);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -23,48 +23,41 @@ import {
|
||||
VideoCodec,
|
||||
} from 'src/enum';
|
||||
|
||||
export type DeepPartial<T> =
|
||||
T extends Record<string, unknown>
|
||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: T extends Array<infer R>
|
||||
? DeepPartial<R>[]
|
||||
: T;
|
||||
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
|
||||
|
||||
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
|
||||
|
||||
export type FullsizeImageOptions = {
|
||||
export interface FullsizeImageOptions {
|
||||
format: ImageFormat;
|
||||
quality: number;
|
||||
enabled: boolean;
|
||||
progressive?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ImageOptions = {
|
||||
export interface ImageOptions {
|
||||
format: ImageFormat;
|
||||
quality: number;
|
||||
size: number;
|
||||
progressive?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type RawImageInfo = {
|
||||
export interface RawImageInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
channels: 1 | 2 | 3 | 4;
|
||||
};
|
||||
}
|
||||
|
||||
type DecodeImageOptions = {
|
||||
interface DecodeImageOptions {
|
||||
colorspace: string;
|
||||
processInvalidImages: boolean;
|
||||
raw?: RawImageInfo;
|
||||
edits?: AssetEditActionItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DecodeToBufferOptions extends DecodeImageOptions {
|
||||
size?: number;
|
||||
orientation?: ExifOrientation;
|
||||
}
|
||||
|
||||
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality' | 'progressive'> & DecodeToBufferOptions;
|
||||
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality'> & DecodeToBufferOptions;
|
||||
|
||||
export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo };
|
||||
|
||||
@@ -511,7 +504,7 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
|
||||
[SystemMetadataKey.MemoriesState]: MemoriesState;
|
||||
}
|
||||
|
||||
export type UserPreferences = {
|
||||
export interface UserPreferences {
|
||||
albums: {
|
||||
defaultAssetOrder: AssetOrder;
|
||||
};
|
||||
@@ -554,7 +547,7 @@ export type UserPreferences = {
|
||||
cast: {
|
||||
gCastEnabled: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
|
||||
key: T;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
@@ -12,7 +10,6 @@ import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { mediumFactory, newMediumService } from 'test/medium.factory';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
@@ -100,162 +97,4 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewThumbnail', () => {
|
||||
it('should return original thumbnail by default when both exist', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
// Create both original and edited thumbnails
|
||||
await ctx.newAssetFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/original/preview.jpg',
|
||||
isEdited: false,
|
||||
});
|
||||
await ctx.newAssetFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/edited/preview.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const result = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW });
|
||||
|
||||
expect(result).toBeInstanceOf(ImmichFileResponse);
|
||||
expect((result as ImmichFileResponse).path).toBe('/original/preview.jpg');
|
||||
});
|
||||
|
||||
it('should return edited thumbnail when edited=true', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
// Create both original and edited thumbnails
|
||||
await ctx.newAssetFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/original/preview.jpg',
|
||||
isEdited: false,
|
||||
});
|
||||
await ctx.newAssetFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/edited/preview.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const result = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW, edited: true });
|
||||
|
||||
expect(result).toBeInstanceOf(ImmichFileResponse);
|
||||
expect((result as ImmichFileResponse).path).toBe('/edited/preview.jpg');
|
||||
});
|
||||
|
||||
it('should return original thumbnail when edited=false', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
// Create both original and edited thumbnails
|
||||
await ctx.newAssetFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/original/preview.jpg',
|
||||
isEdited: false,
|
||||
});
|
||||
await ctx.newAssetFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/edited/preview.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const result = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW, edited: false });
|
||||
|
||||
expect(result).toBeInstanceOf(ImmichFileResponse);
|
||||
expect((result as ImmichFileResponse).path).toBe('/original/preview.jpg');
|
||||
});
|
||||
|
||||
it('should return original thumbnail when only original exists and edited=false', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
// Create only original thumbnail
|
||||
await ctx.newAssetFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/original/preview.jpg',
|
||||
isEdited: false,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const result = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW, edited: false });
|
||||
|
||||
expect(result).toBeInstanceOf(ImmichFileResponse);
|
||||
expect((result as ImmichFileResponse).path).toBe('/original/preview.jpg');
|
||||
});
|
||||
|
||||
it('should return original thumbnail when only original exists and edited=true', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
// Create only original thumbnail
|
||||
await ctx.newAssetFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/original/preview.jpg',
|
||||
isEdited: false,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const result = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW, edited: true });
|
||||
|
||||
expect(result).toBeInstanceOf(ImmichFileResponse);
|
||||
expect((result as ImmichFileResponse).path).toBe('/original/preview.jpg');
|
||||
});
|
||||
|
||||
it('should work with thumbnail size', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
// Create both original and edited thumbnails
|
||||
await ctx.newAssetFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/original/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
});
|
||||
await ctx.newAssetFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/edited/thumbnail.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
// Test default (should get original)
|
||||
const resultDefault = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.THUMBNAIL });
|
||||
expect(resultDefault).toBeInstanceOf(ImmichFileResponse);
|
||||
expect((resultDefault as ImmichFileResponse).path).toBe('/original/thumbnail.jpg');
|
||||
|
||||
// Test edited=true (should get edited)
|
||||
const resultEdited = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.THUMBNAIL, edited: true });
|
||||
expect(resultEdited).toBeInstanceOf(ImmichFileResponse);
|
||||
expect((resultEdited as ImmichFileResponse).path).toBe('/edited/thumbnail.jpg');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,8 +50,5 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
upsertBulkMetadata: vitest.fn(),
|
||||
deleteMetadataByKey: vitest.fn(),
|
||||
deleteBulkMetadata: vitest.fn(),
|
||||
getForOriginal: vitest.fn(),
|
||||
getForThumbnail: vitest.fn(),
|
||||
getForVideo: vitest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Activity,
|
||||
ApiKey,
|
||||
AssetFace,
|
||||
AssetFile,
|
||||
AuthApiKey,
|
||||
AuthSharedLink,
|
||||
@@ -10,16 +9,12 @@ import {
|
||||
Library,
|
||||
Memory,
|
||||
Partner,
|
||||
Person,
|
||||
Session,
|
||||
Stack,
|
||||
Tag,
|
||||
User,
|
||||
UserAdmin,
|
||||
} from 'src/database';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto';
|
||||
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
@@ -28,11 +23,10 @@ import {
|
||||
AssetVisibility,
|
||||
MemoryType,
|
||||
Permission,
|
||||
SourceType,
|
||||
UserMetadataKey,
|
||||
UserStatus,
|
||||
} from 'src/enum';
|
||||
import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types';
|
||||
import { OnThisDayData, UserMetadataItem } from 'src/types';
|
||||
import { v4, v7 } from 'uuid';
|
||||
|
||||
export const newUuid = () => v4();
|
||||
@@ -166,18 +160,11 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
|
||||
...dto,
|
||||
});
|
||||
|
||||
const stackFactory = ({ owner, assets, ...stack }: DeepPartial<Stack> = {}): Stack => {
|
||||
const ownerId = newUuid();
|
||||
|
||||
return {
|
||||
id: newUuid(),
|
||||
primaryAssetId: assets?.[0].id ?? newUuid(),
|
||||
ownerId,
|
||||
owner: userFactory(owner ?? { id: ownerId }),
|
||||
assets: assets?.map((asset) => assetFactory(asset)) ?? [],
|
||||
...stack,
|
||||
};
|
||||
};
|
||||
const stackFactory = () => ({
|
||||
id: newUuid(),
|
||||
ownerId: newUuid(),
|
||||
primaryAssetId: newUuid(),
|
||||
});
|
||||
|
||||
const userFactory = (user: Partial<User> = {}) => ({
|
||||
id: newUuid(),
|
||||
@@ -236,43 +223,39 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
const assetFactory = (
|
||||
asset: Omit<DeepPartial<MapAsset>, 'exifInfo' | 'owner' | 'stack' | 'tags' | 'faces' | 'files' | 'edits'> = {},
|
||||
) => {
|
||||
return {
|
||||
id: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
deletedAt: null,
|
||||
updateId: newUuidV7(),
|
||||
status: AssetStatus.Active,
|
||||
checksum: newSha1(),
|
||||
deviceAssetId: '',
|
||||
deviceId: '',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
encodedVideoPath: null,
|
||||
fileCreatedAt: newDate(),
|
||||
fileModifiedAt: newDate(),
|
||||
isExternal: false,
|
||||
isFavorite: false,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: newDate(),
|
||||
originalFileName: 'IMG_123.jpg',
|
||||
originalPath: `/data/12/34/IMG_123.jpg`,
|
||||
ownerId: newUuid(),
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetType.Image,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
isEdited: false,
|
||||
...asset,
|
||||
};
|
||||
};
|
||||
const assetFactory = (asset: Partial<MapAsset> = {}) => ({
|
||||
id: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
deletedAt: null,
|
||||
updateId: newUuidV7(),
|
||||
status: AssetStatus.Active,
|
||||
checksum: newSha1(),
|
||||
deviceAssetId: '',
|
||||
deviceId: '',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
encodedVideoPath: null,
|
||||
fileCreatedAt: newDate(),
|
||||
fileModifiedAt: newDate(),
|
||||
isExternal: false,
|
||||
isFavorite: false,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: newDate(),
|
||||
originalFileName: 'IMG_123.jpg',
|
||||
originalPath: `/data/12/34/IMG_123.jpg`,
|
||||
ownerId: newUuid(),
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetType.Image,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
isEdited: false,
|
||||
...asset,
|
||||
});
|
||||
|
||||
const activityFactory = (activity: Partial<Activity> = {}) => {
|
||||
const userId = activity.userId || newUuid();
|
||||
@@ -408,102 +391,6 @@ const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
|
||||
...file,
|
||||
});
|
||||
|
||||
const exifFactory = (exif: Partial<Exif> = {}) => ({
|
||||
assetId: newUuid(),
|
||||
autoStackId: null,
|
||||
bitsPerSample: null,
|
||||
city: 'Austin',
|
||||
colorspace: null,
|
||||
country: 'United States of America',
|
||||
dateTimeOriginal: newDate(),
|
||||
description: '',
|
||||
exifImageHeight: 420,
|
||||
exifImageWidth: 42,
|
||||
exposureTime: null,
|
||||
fileSizeInByte: 69,
|
||||
fNumber: 1.7,
|
||||
focalLength: 4.38,
|
||||
fps: null,
|
||||
iso: 947,
|
||||
latitude: 30.267_334_570_570_195,
|
||||
longitude: -97.789_833_534_282_07,
|
||||
lensModel: null,
|
||||
livePhotoCID: null,
|
||||
make: 'Google',
|
||||
model: 'Pixel 7',
|
||||
modifyDate: newDate(),
|
||||
orientation: '1',
|
||||
profileDescription: null,
|
||||
projectionType: null,
|
||||
rating: 4,
|
||||
state: 'Texas',
|
||||
tags: ['parent/child'],
|
||||
timeZone: 'UTC-6',
|
||||
...exif,
|
||||
});
|
||||
|
||||
const tagFactory = (tag: Partial<Tag>): Tag => ({
|
||||
id: newUuid(),
|
||||
color: null,
|
||||
createdAt: newDate(),
|
||||
parentId: null,
|
||||
updatedAt: newDate(),
|
||||
value: `tag-${newUuid()}`,
|
||||
...tag,
|
||||
});
|
||||
|
||||
const faceFactory = ({ person, ...face }: DeepPartial<AssetFace> = {}): AssetFace => ({
|
||||
assetId: newUuid(),
|
||||
boundingBoxX1: 1,
|
||||
boundingBoxX2: 2,
|
||||
boundingBoxY1: 1,
|
||||
boundingBoxY2: 2,
|
||||
deletedAt: null,
|
||||
id: newUuid(),
|
||||
imageHeight: 420,
|
||||
imageWidth: 42,
|
||||
isVisible: true,
|
||||
personId: null,
|
||||
sourceType: SourceType.MachineLearning,
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
person: person === null ? null : personFactory(person),
|
||||
...face,
|
||||
});
|
||||
|
||||
const assetEditFactory = (edit?: Partial<AssetEditActionItem>): AssetEditActionItem => {
|
||||
switch (edit?.action) {
|
||||
case AssetEditAction.Crop: {
|
||||
return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit };
|
||||
}
|
||||
case AssetEditAction.Mirror: {
|
||||
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit };
|
||||
}
|
||||
case AssetEditAction.Rotate: {
|
||||
return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit };
|
||||
}
|
||||
default: {
|
||||
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const personFactory = (person?: Partial<Person>): Person => ({
|
||||
birthDate: newDate(),
|
||||
color: null,
|
||||
createdAt: newDate(),
|
||||
faceAssetId: null,
|
||||
id: newUuid(),
|
||||
isFavorite: false,
|
||||
isHidden: false,
|
||||
name: 'person',
|
||||
ownerId: newUuid(),
|
||||
thumbnailPath: '/path/to/person/thumbnail.jpg',
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...person,
|
||||
});
|
||||
|
||||
export const factory = {
|
||||
activity: activityFactory,
|
||||
apiKey: apiKeyFactory,
|
||||
@@ -525,11 +412,6 @@ export const factory = {
|
||||
jobAssets: {
|
||||
sidecarWrite: assetSidecarWriteFactory,
|
||||
},
|
||||
exif: exifFactory,
|
||||
face: faceFactory,
|
||||
person: personFactory,
|
||||
assetEdit: assetEditFactory,
|
||||
tag: tagFactory,
|
||||
uuid: newUuid,
|
||||
date: newDate,
|
||||
responses: {
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"svelte": "5.48.0",
|
||||
"svelte": "5.46.4",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
|
||||
329
web/src/app.css
329
web/src/app.css
@@ -74,20 +74,6 @@
|
||||
--immich-dark-bg: 10 10 10;
|
||||
--immich-dark-fg: 229 231 235;
|
||||
--immich-dark-gray: 33 33 33;
|
||||
|
||||
/* transitions */
|
||||
--immich-split-viewer-nav: enabled;
|
||||
|
||||
/* view transition variables */
|
||||
--vt-duration-default: 250ms;
|
||||
--vt-duration-hero: 350ms;
|
||||
--vt-duration-slideshow: 1s;
|
||||
--vt-viewer-slide-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
--vt-viewer-slide-distance: 15%;
|
||||
--vt-viewer-opacity-start: 0.1;
|
||||
--vt-viewer-opacity-mid: 0.4;
|
||||
--vt-viewer-blur-max: 4px;
|
||||
--vt-viewer-blur-mid: 2px;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
@@ -185,318 +171,3 @@
|
||||
@apply bg-subtle rounded-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
::view-transition {
|
||||
background: var(--color-black);
|
||||
animation-duration: var(--vt-duration-default);
|
||||
}
|
||||
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
mix-blend-mode: normal;
|
||||
animation-duration: inherit;
|
||||
}
|
||||
|
||||
::view-transition-old(*) {
|
||||
animation-name: fadeOut;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
::view-transition-new(*) {
|
||||
animation-name: fadeIn;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
html:active-view-transition-type(slideshow) {
|
||||
&::view-transition-old(root) {
|
||||
animation: var(--vt-duration-slideshow) 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(root) {
|
||||
animation: var(--vt-duration-slideshow) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
html:active-view-transition-type(viewer-nav) {
|
||||
&::view-transition-old(root) {
|
||||
animation: var(--vt-duration-hero) 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(root) {
|
||||
animation: var(--vt-duration-hero) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
::view-transition-old(info) {
|
||||
animation: var(--vt-duration-default) 0s flyOutRight forwards;
|
||||
}
|
||||
::view-transition-new(info) {
|
||||
animation: var(--vt-duration-default) 0s flyInRight forwards;
|
||||
}
|
||||
|
||||
::view-transition-group(detail-panel) {
|
||||
z-index: 1;
|
||||
}
|
||||
::view-transition-old(detail-panel),
|
||||
::view-transition-new(detail-panel) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-group(letterbox-left),
|
||||
::view-transition-group(letterbox-right),
|
||||
::view-transition-group(letterbox-top),
|
||||
::view-transition-group(letterbox-bottom) {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
::view-transition-old(letterbox-left),
|
||||
::view-transition-old(letterbox-right),
|
||||
::view-transition-old(letterbox-top),
|
||||
::view-transition-old(letterbox-bottom) {
|
||||
background-color: var(--color-black);
|
||||
}
|
||||
|
||||
::view-transition-new(letterbox-left),
|
||||
::view-transition-new(letterbox-right) {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
::view-transition-new(letterbox-left),
|
||||
::view-transition-new(letterbox-right),
|
||||
::view-transition-new(letterbox-top),
|
||||
::view-transition-new(letterbox-bottom) {
|
||||
background-color: var(--color-black);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
::view-transition-group(exclude-leftbutton),
|
||||
::view-transition-group(exclude-rightbutton),
|
||||
::view-transition-group(exclude) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
::view-transition-old(exclude-leftbutton),
|
||||
::view-transition-old(exclude-rightbutton),
|
||||
::view-transition-old(exclude) {
|
||||
visibility: hidden;
|
||||
}
|
||||
::view-transition-new(exclude-leftbutton),
|
||||
::view-transition-new(exclude-rightbutton),
|
||||
::view-transition-new(exclude) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
::view-transition-old(hero) {
|
||||
animation: var(--vt-duration-hero) fadeOut forwards;
|
||||
align-content: center;
|
||||
}
|
||||
::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-hero) fadeIn forwards;
|
||||
align-content: center;
|
||||
}
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old) {
|
||||
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutLeft forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new) {
|
||||
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInRight forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-old(previous) {
|
||||
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutRight forwards;
|
||||
}
|
||||
::view-transition-old(previous-old) {
|
||||
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutRight forwards;
|
||||
overflow: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
::view-transition-new(previous) {
|
||||
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInLeft forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(previous-new) {
|
||||
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInLeft forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes flyInLeft {
|
||||
from {
|
||||
/* object-position: -25dvw; */
|
||||
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
50% {
|
||||
opacity: var(--vt-viewer-opacity-mid);
|
||||
filter: blur(var(--vt-viewer-blur-mid));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyOutLeft {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
50% {
|
||||
opacity: var(--vt-viewer-opacity-mid);
|
||||
filter: blur(var(--vt-viewer-blur-mid));
|
||||
}
|
||||
to {
|
||||
/* object-position: -25dvw; */
|
||||
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyInRight {
|
||||
from {
|
||||
/* object-position: 25dvw; */
|
||||
transform: translateX(var(--vt-viewer-slide-distance));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
50% {
|
||||
opacity: var(--vt-viewer-opacity-mid);
|
||||
filter: blur(var(--vt-viewer-blur-mid));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fly out to right */
|
||||
@keyframes flyOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
50% {
|
||||
opacity: var(--vt-viewer-opacity-mid);
|
||||
filter: blur(var(--vt-viewer-blur-mid));
|
||||
}
|
||||
to {
|
||||
/* object-position: 50dvw 0px; */
|
||||
transform: translateX(var(--vt-viewer-slide-distance));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion: when system preference is set */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(hero) {
|
||||
animation-name: none;
|
||||
}
|
||||
|
||||
::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
::view-transition-new(hero) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(viewer) {
|
||||
&::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(timeline) {
|
||||
&::view-transition-old(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-group(letterbox-left),
|
||||
::view-transition-group(letterbox-right),
|
||||
::view-transition-group(letterbox-top),
|
||||
::view-transition-group(letterbox-bottom) {
|
||||
animation-name: none;
|
||||
}
|
||||
|
||||
::view-transition-old(letterbox-left),
|
||||
::view-transition-old(letterbox-right),
|
||||
::view-transition-old(letterbox-top),
|
||||
::view-transition-old(letterbox-bottom) {
|
||||
animation: var(--vt-duration-default) fadeOut forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(letterbox-left),
|
||||
::view-transition-new(letterbox-right),
|
||||
::view-transition-new(letterbox-top),
|
||||
::view-transition-new(letterbox-bottom) {
|
||||
animation: var(--vt-duration-default) fadeIn forwards;
|
||||
}
|
||||
|
||||
::view-transition-group(previous),
|
||||
::view-transition-group(previous-old),
|
||||
::view-transition-group(next),
|
||||
::view-transition-group(next-old) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old),
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old) {
|
||||
animation: var(--vt-duration-default) fadeOut forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-new(previous),
|
||||
::view-transition-new(previous-new),
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new) {
|
||||
animation: var(--vt-duration-default) fadeIn forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
/**
|
||||
* Converts a ClassValue to a string suitable for className assignment.
|
||||
* Handles strings, arrays, and objects similar to how clsx works.
|
||||
*/
|
||||
function classValueToString(value: ClassValue | undefined): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((v) => classValueToString(v))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
// Object/dictionary case
|
||||
return Object.entries(value)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export interface ImageLoaderProperties {
|
||||
imgClass?: ClassValue;
|
||||
alt?: string;
|
||||
draggable?: boolean;
|
||||
role?: string;
|
||||
style?: string;
|
||||
title?: string | null;
|
||||
loading?: 'lazy' | 'eager';
|
||||
dataAttributes?: Record<string, string>;
|
||||
}
|
||||
export interface ImageSourceProperty {
|
||||
src: string | undefined;
|
||||
}
|
||||
export interface ImageLoaderCallbacks {
|
||||
onStart?: () => void;
|
||||
onLoad?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
onElementCreated?: (element: HTMLImageElement) => void;
|
||||
}
|
||||
|
||||
const updateImageAttributes = (img: HTMLImageElement, params: ImageLoaderProperties) => {
|
||||
if (params.alt !== undefined) {
|
||||
img.alt = params.alt;
|
||||
}
|
||||
if (params.draggable !== undefined) {
|
||||
img.draggable = params.draggable;
|
||||
}
|
||||
if (params.imgClass) {
|
||||
img.className = classValueToString(params.imgClass);
|
||||
}
|
||||
if (params.role) {
|
||||
img.role = params.role;
|
||||
}
|
||||
if (params.style !== undefined) {
|
||||
img.setAttribute('style', params.style);
|
||||
}
|
||||
if (params.title !== undefined && params.title !== null) {
|
||||
img.title = params.title;
|
||||
}
|
||||
if (params.loading !== undefined) {
|
||||
img.loading = params.loading;
|
||||
}
|
||||
if (params.dataAttributes) {
|
||||
for (const [key, value] of Object.entries(params.dataAttributes)) {
|
||||
img.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const destroyImageElement = (
|
||||
imgElement: HTMLImageElement,
|
||||
currentSrc: string | undefined,
|
||||
handleLoad: () => void,
|
||||
handleError: () => void,
|
||||
) => {
|
||||
imgElement.removeEventListener('load', handleLoad);
|
||||
imgElement.removeEventListener('error', handleError);
|
||||
cancelImageUrl(currentSrc);
|
||||
imgElement.remove();
|
||||
};
|
||||
|
||||
const createImageElement = (
|
||||
src: string | undefined,
|
||||
properties: ImageLoaderProperties,
|
||||
onLoad: () => void,
|
||||
onError: () => void,
|
||||
onStart?: () => void,
|
||||
onElementCreated?: (imgElement: HTMLImageElement) => void,
|
||||
) => {
|
||||
if (!src) {
|
||||
return undefined;
|
||||
}
|
||||
const img = document.createElement('img');
|
||||
updateImageAttributes(img, properties);
|
||||
|
||||
img.addEventListener('load', onLoad);
|
||||
img.addEventListener('error', onError);
|
||||
|
||||
onStart?.();
|
||||
|
||||
if (src) {
|
||||
img.src = src;
|
||||
onElementCreated?.(img);
|
||||
}
|
||||
|
||||
return img;
|
||||
};
|
||||
|
||||
export function loadImage(
|
||||
src: string,
|
||||
properties: ImageLoaderProperties,
|
||||
onLoad: () => void,
|
||||
onError: () => void,
|
||||
onStart?: () => void,
|
||||
) {
|
||||
let destroyed = false;
|
||||
const wrapper = (fn: (() => void) | undefined) => () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
fn?.();
|
||||
};
|
||||
const wrappedOnLoad = wrapper(onLoad);
|
||||
const wrappedOnError = wrapper(onError);
|
||||
const wrappedOnStart = wrapper(onStart);
|
||||
const img = createImageElement(src, properties, wrappedOnLoad, wrappedOnError, wrappedOnStart);
|
||||
if (!img) {
|
||||
return () => void 0;
|
||||
}
|
||||
return () => {
|
||||
destroyed = true;
|
||||
destroyImageElement(img, src, wrappedOnLoad, wrappedOnError);
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadImageFunction = typeof loadImage;
|
||||
|
||||
/**
|
||||
* 1. Creates and appends an <img> element to the parent
|
||||
* 2. Coordinates with service worker before src triggers fetch
|
||||
* 3. Adds load/error listeners
|
||||
* 4. Cancels SW request when element is removed from DOM
|
||||
*/
|
||||
export function imageLoader(
|
||||
node: HTMLElement,
|
||||
params: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks,
|
||||
) {
|
||||
let currentSrc = params.src;
|
||||
let currentCallbacks = params;
|
||||
let imgElement: HTMLImageElement | undefined = undefined;
|
||||
|
||||
const handleLoad = () => {
|
||||
currentCallbacks.onLoad?.();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
currentCallbacks.onError?.(new Error(`Failed to load image: ${currentSrc}`));
|
||||
};
|
||||
|
||||
const handleElementCreated = (img: HTMLImageElement) => {
|
||||
if (img) {
|
||||
node.append(img);
|
||||
currentCallbacks.onElementCreated?.(img);
|
||||
}
|
||||
};
|
||||
|
||||
const createImage = () => {
|
||||
imgElement = createImageElement(currentSrc, params, handleLoad, handleError, params.onStart, handleElementCreated);
|
||||
};
|
||||
createImage();
|
||||
|
||||
return {
|
||||
update(newParams: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks) {
|
||||
// If src changed, recreate the image element
|
||||
if (newParams.src !== currentSrc) {
|
||||
if (imgElement) {
|
||||
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
|
||||
}
|
||||
currentSrc = newParams.src;
|
||||
currentCallbacks = newParams;
|
||||
|
||||
createImage();
|
||||
return;
|
||||
}
|
||||
|
||||
currentCallbacks = newParams;
|
||||
|
||||
if (!imgElement) {
|
||||
return;
|
||||
}
|
||||
updateImageAttributes(imgElement, newParams);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (imgElement) {
|
||||
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
|
||||
const state = get(photoZoomState);
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
initialState: state,
|
||||
});
|
||||
|
||||
const unsubscribes = [
|
||||
assetViewerManager.on('ZoomChange', (state) => zoomInstance.setState(state)),
|
||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||
photoZoomState.subscribe((state) => zoomInstance.setState(state)),
|
||||
zoomInstance.subscribe(({ state }) => {
|
||||
photoZoomState.set(state);
|
||||
}),
|
||||
];
|
||||
|
||||
const stopIfDisabled = (event: Event) => {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { assetViewerManager, type Events } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import type { EventCallback } from '$lib/utils/base-event-manager.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
[K in keyof Events as `on${K}`]?: EventCallback<Events, K>;
|
||||
};
|
||||
|
||||
const props: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribes: Array<() => void> = [];
|
||||
|
||||
for (const name of Object.keys(props)) {
|
||||
const event = name.slice(2) as keyof Events;
|
||||
const listener = props[name as keyof Props] as EventCallback<Events, typeof event> | undefined;
|
||||
if (!listener) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unsubscribes.push(assetViewerManager.on(event, listener));
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -2,15 +2,14 @@
|
||||
import { eventManager, type Events } from '$lib/managers/event-manager.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
[K in keyof Events as `on${K}`]?: (...args: Events[K]) => void;
|
||||
};
|
||||
type Props = Partial<{
|
||||
[K in keyof Events as `on${K}`]: (...args: Events[K]) => void;
|
||||
}>;
|
||||
|
||||
const props: Props = $props();
|
||||
const unsubscribes: Array<() => void> = [];
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribes: Array<() => void> = [];
|
||||
|
||||
for (const name of Object.keys(props)) {
|
||||
const event = name.slice(2) as keyof Events;
|
||||
const listener = props[name as keyof Props];
|
||||
@@ -21,7 +20,8 @@
|
||||
|
||||
const args = [event, listener as (...args: Events[typeof event]) => void] as const;
|
||||
|
||||
unsubscribes.push(eventManager.on(...args));
|
||||
eventManager.on(...args);
|
||||
unsubscribes.push(() => eventManager.off(...args));
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -37,11 +37,6 @@
|
||||
name="format"
|
||||
isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format}
|
||||
{disabled}
|
||||
onSelect={(value) => {
|
||||
if (value === ImageFormat.Webp) {
|
||||
configToEdit.image.thumbnail.progressive = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
@@ -69,15 +64,6 @@
|
||||
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_progressive')}
|
||||
subtitle={$t('admin.image_progressive_description')}
|
||||
checked={configToEdit.image.thumbnail.progressive}
|
||||
onToggle={(isChecked) => (configToEdit.image.thumbnail.progressive = isChecked)}
|
||||
isEdited={configToEdit.image.thumbnail.progressive !== config.image.thumbnail.progressive}
|
||||
disabled={disabled || configToEdit.image.thumbnail.format === ImageFormat.Webp}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
@@ -96,11 +82,6 @@
|
||||
name="format"
|
||||
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
|
||||
{disabled}
|
||||
onSelect={(value) => {
|
||||
if (value === ImageFormat.Webp) {
|
||||
configToEdit.image.preview.progressive = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
@@ -127,15 +108,6 @@
|
||||
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_progressive')}
|
||||
subtitle={$t('admin.image_progressive_description')}
|
||||
checked={configToEdit.image.preview.progressive}
|
||||
onToggle={(isChecked) => (configToEdit.image.preview.progressive = isChecked)}
|
||||
isEdited={configToEdit.image.preview.progressive !== config.image.preview.progressive}
|
||||
disabled={disabled || configToEdit.image.preview.format === ImageFormat.Webp}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
@@ -165,11 +137,6 @@
|
||||
name="format"
|
||||
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
|
||||
disabled={disabled || !configToEdit.image.fullsize.enabled}
|
||||
onSelect={(value) => {
|
||||
if (value === ImageFormat.Webp) {
|
||||
configToEdit.image.fullsize.progressive = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
@@ -180,17 +147,6 @@
|
||||
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
|
||||
disabled={disabled || !configToEdit.image.fullsize.enabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_progressive')}
|
||||
subtitle={$t('admin.image_progressive_description')}
|
||||
checked={configToEdit.image.fullsize.progressive}
|
||||
onToggle={(isChecked) => (configToEdit.image.fullsize.progressive = isChecked)}
|
||||
isEdited={configToEdit.image.fullsize.progressive !== config.image.fullsize.progressive}
|
||||
disabled={disabled ||
|
||||
!configToEdit.image.fullsize.enabled ||
|
||||
configToEdit.image.fullsize.format === ImageFormat.Webp}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<div class="mt-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { albumFactory } from '@test-data/factories/album-factory';
|
||||
import { render } from '@testing-library/svelte';
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock('$lib/utils');
|
||||
|
||||
describe('AlbumCover component', () => {
|
||||
it('renders an image when the album has a thumbnail', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf');
|
||||
vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf');
|
||||
const component = render(AlbumCover, {
|
||||
album: albumFactory.build({
|
||||
albumName: 'someName',
|
||||
@@ -21,7 +21,7 @@ describe('AlbumCover component', () => {
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
|
||||
expect(img.getAttribute('src')).toBe('/asdf');
|
||||
expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: '123' });
|
||||
expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' });
|
||||
});
|
||||
|
||||
it('renders an image when the album has no thumbnail', () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
|
||||
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { type AlbumResponseDto } from '@immich/sdk';
|
||||
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
|
||||
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
let alt = $derived(album.albumName || $t('unnamed_album'));
|
||||
let thumbnailUrl = $derived(
|
||||
album.albumThumbnailAssetId ? getAssetMediaUrl({ id: album.albumThumbnailAssetId }) : null,
|
||||
album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null,
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
@@ -114,7 +114,7 @@
|
||||
<ControlAppBar showBackButton={false}>
|
||||
{#snippet leading()}
|
||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||
<Logo variant={mobileDevice.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ type ActionMap = {
|
||||
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
|
||||
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
|
||||
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | undefined; asset: AssetResponseDto };
|
||||
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
|
||||
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiTune } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
let { onAction }: Props = $props();
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
icon={mdiTune}
|
||||
aria-label={$t('editor')}
|
||||
onclick={() => onAction()}
|
||||
/>
|
||||
@@ -7,7 +7,7 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetType } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||
@@ -142,7 +142,7 @@
|
||||
<a class="aspect-square w-19 h-19" href={Route.viewAlbumAsset({ albumId, assetId: reaction.assetId })}>
|
||||
<img
|
||||
class="rounded-lg w-19 h-19 object-cover"
|
||||
src={getAssetMediaUrl({ id: reaction.assetId })}
|
||||
src={getAssetThumbnailUrl(reaction.assetId)}
|
||||
alt="Profile picture of {reaction.user.name}, who commented on this asset"
|
||||
/>
|
||||
</a>
|
||||
@@ -195,7 +195,7 @@
|
||||
>
|
||||
<img
|
||||
class="rounded-lg w-19 h-19 object-cover"
|
||||
src={getAssetMediaUrl({ id: reaction.assetId })}
|
||||
src={getAssetThumbnailUrl(reaction.assetId)}
|
||||
alt="Profile picture of {reaction.user.name}, who liked this asset"
|
||||
/>
|
||||
</a>
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { imageLoader } from '$lib/actions/image-loader.svelte';
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
|
||||
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { getDimensions } from '$lib/utils/asset-utils';
|
||||
import { scaleToFit } from '$lib/utils/layout-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy, untrack, type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
zoomDisabled?: boolean;
|
||||
imageClass?: string;
|
||||
container: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
slideshowState: SlideshowState;
|
||||
slideshowLook: SlideshowLook;
|
||||
transitionName?: string | null | undefined;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
imgElement?: HTMLImageElement;
|
||||
overlays?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
imgElement = $bindable<HTMLImageElement | undefined>(),
|
||||
asset,
|
||||
sharedLink,
|
||||
zoomDisabled = false,
|
||||
imageClass = '',
|
||||
container,
|
||||
slideshowState,
|
||||
slideshowLook,
|
||||
transitionName,
|
||||
onImageReady,
|
||||
onError,
|
||||
overlays,
|
||||
}: Props = $props();
|
||||
|
||||
let previousLoader = $state<AdaptiveImageLoader>();
|
||||
let previousAssetId: string | undefined;
|
||||
let previousSharedLinkId: string | undefined;
|
||||
|
||||
const resetZoomState = () => {
|
||||
assetViewerManager.zoomState = {
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const zoomTransform = $derived.by(() => {
|
||||
const { currentZoom, currentPositionX, currentPositionY, currentRotation } = assetViewerManager.zoomState;
|
||||
return `translate3d(${currentPositionX}px, ${currentPositionY}px, 0) scale(${currentZoom}) rotate(${currentRotation}deg)`;
|
||||
});
|
||||
|
||||
const adaptiveImageLoader = $derived.by(() => {
|
||||
if (previousAssetId === asset.id && previousSharedLinkId === sharedLink?.id) {
|
||||
return previousLoader!;
|
||||
}
|
||||
|
||||
return untrack(() => {
|
||||
previousAssetId = asset.id;
|
||||
previousSharedLinkId = sharedLink?.id;
|
||||
|
||||
previousLoader?.destroy();
|
||||
resetZoomState();
|
||||
const loader = new AdaptiveImageLoader(asset, sharedLink, {
|
||||
currentZoomFn: () => assetViewerManager.zoom,
|
||||
onImageReady,
|
||||
onError,
|
||||
});
|
||||
previousLoader = loader;
|
||||
return loader;
|
||||
});
|
||||
});
|
||||
onDestroy(() => adaptiveImageLoader.destroy());
|
||||
|
||||
const imageDimensions = $derived.by(() => {
|
||||
if ((asset.width ?? 0) > 0 && (asset.height ?? 0) > 0) {
|
||||
return { width: asset.width!, height: asset.height! };
|
||||
}
|
||||
|
||||
if (asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageWidth) {
|
||||
return getDimensions(asset.exifInfo) as { width: number; height: number };
|
||||
}
|
||||
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const scaledDimensions = $derived(scaleToFit(imageDimensions, container));
|
||||
|
||||
const renderDimensions = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
left: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
};
|
||||
});
|
||||
|
||||
const blurredSlideshow = $derived(
|
||||
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||
);
|
||||
|
||||
const loadState = $derived(adaptiveImageLoader.adaptiveLoaderState);
|
||||
const imageAltText = $derived(loadState.previewUrl ? $getAltText(toTimelineAsset(asset)) : '');
|
||||
const thumbnailUrl = $derived(loadState.thumbnailUrl);
|
||||
const previewUrl = $derived(loadState.previewUrl);
|
||||
const originalUrl = $derived(loadState.originalUrl);
|
||||
const showSpinner = $derived(!asset.thumbhash && loadState.quality === 'basic');
|
||||
const showBrokenAsset = $derived(loadState.hasError);
|
||||
|
||||
// Effect: Upgrade to original when user zooms in
|
||||
$effect(() => {
|
||||
if (assetViewerManager.zoom > 1 && loadState.quality === 'preview') {
|
||||
void adaptiveImageLoader.triggerOriginal();
|
||||
}
|
||||
});
|
||||
let thumbnailElement = $state<HTMLImageElement>();
|
||||
let previewElement = $state<HTMLImageElement>();
|
||||
let originalElement = $state<HTMLImageElement>();
|
||||
|
||||
// Effect: Synchronize highest quality element as main imgElement
|
||||
$effect(() => {
|
||||
imgElement = originalElement ?? previewElement ?? thumbnailElement;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full">
|
||||
<!-- Blurred slideshow background (full viewport) -->
|
||||
{#if blurredSlideshow}
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash! }} class="-z-1 absolute top-0 left-0 start-0 h-dvh w-dvw"
|
||||
></canvas>
|
||||
{/if}
|
||||
|
||||
<!-- Letterbox regions (empty space around image) -->
|
||||
<Letterboxes
|
||||
{transitionName}
|
||||
{slideshowState}
|
||||
{slideshowLook}
|
||||
hasThumbhash={!!asset.thumbhash}
|
||||
{scaledDimensions}
|
||||
{container}
|
||||
/>
|
||||
|
||||
<!-- Main image box with transition -->
|
||||
<div
|
||||
style:view-transition-name={transitionName}
|
||||
data-transition-name={transitionName}
|
||||
class="absolute"
|
||||
style:left={renderDimensions.left}
|
||||
style:top={renderDimensions.top}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
>
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<div style:transform-origin="0px 0px" style:transform={zoomTransform} class="h-full w-full absolute">
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
|
||||
</div>
|
||||
{:else if showSpinner}
|
||||
<div id="spinner" class="absolute flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="absolute top-0"
|
||||
style:transform-origin="0px 0px"
|
||||
style:transform={zoomTransform}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
use:imageLoader={{
|
||||
src: thumbnailUrl,
|
||||
onStart: () => adaptiveImageLoader.onThumbnailStart(),
|
||||
onLoad: () => adaptiveImageLoader.onThumbnailLoad(),
|
||||
onError: () => adaptiveImageLoader.onThumbnailError(),
|
||||
onElementCreated: (element) => (thumbnailElement = element),
|
||||
imgClass: ['absolute h-full', 'w-full'],
|
||||
alt: '',
|
||||
role: 'presentation',
|
||||
dataAttributes: {
|
||||
'data-testid': 'thumbnail',
|
||||
},
|
||||
}}
|
||||
></div>
|
||||
|
||||
{#if showBrokenAsset}
|
||||
<div class="h-full w-full absolute" style:transform-origin="0px 0px" style:transform={zoomTransform}>
|
||||
<BrokenAsset class="text-xl h-full w-full" />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="absolute top-0"
|
||||
style:transform-origin="0px 0px"
|
||||
style:transform={zoomTransform}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
use:imageLoader={{
|
||||
src: previewUrl,
|
||||
onStart: () => adaptiveImageLoader.onPreviewStart(),
|
||||
onLoad: () => adaptiveImageLoader.onPreviewLoad(),
|
||||
onError: () => adaptiveImageLoader.onPreviewError(),
|
||||
onElementCreated: (element) => (previewElement = element),
|
||||
imgClass: ['h-full', 'w-full', imageClass],
|
||||
alt: imageAltText,
|
||||
draggable: false,
|
||||
dataAttributes: {
|
||||
'data-testid': 'preview',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{@render overlays?.()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute top-0"
|
||||
style:transform-origin="0px 0px"
|
||||
style:transform={zoomTransform}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
use:imageLoader={{
|
||||
src: originalUrl,
|
||||
onStart: () => adaptiveImageLoader.onOriginalStart(),
|
||||
onLoad: () => adaptiveImageLoader.onOriginalLoad(),
|
||||
onError: () => adaptiveImageLoader.onOriginalError(),
|
||||
onElementCreated: (element) => (originalElement = element),
|
||||
imgClass: ['h-full', 'w-full', imageClass],
|
||||
alt: imageAltText,
|
||||
draggable: false,
|
||||
dataAttributes: {
|
||||
'data-testid': 'original',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{@render overlays?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Use placeholder empty image to zoomImage so it can monitor mouse-wheel events and update zoom state -->
|
||||
<div
|
||||
class="absolute top-0"
|
||||
use:zoomImageAction={{ disabled: zoomDisabled }}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
>
|
||||
<img alt="" class="absolute h-full w-full hidden" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user