mirror of
https://github.com/immich-app/immich.git
synced 2025-11-02 02:57:49 +09:00
feat(web): use timeline in geolocation manager (#21492)
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docker / pre-job (push) Has been cancelled
Docker / Re-Tag ML () (push) Has been cancelled
Docker / Re-Tag ML (-armnn) (push) Has been cancelled
Docker / Re-Tag ML (-cuda) (push) Has been cancelled
Docker / Re-Tag ML (-openvino) (push) Has been cancelled
Docker / Re-Tag ML (-rknn) (push) Has been cancelled
Docker / Re-Tag ML (-rocm) (push) Has been cancelled
Docker / Re-Tag Server () (push) Has been cancelled
Docker / Build and Push ML (armnn, linux/arm64, -armnn) (push) Has been cancelled
Docker / Build and Push ML (cpu, ) (push) Has been cancelled
Docker / Build and Push ML (cuda, linux/amd64, -cuda) (push) Has been cancelled
Docker / Build and Push ML (openvino, linux/amd64, -openvino) (push) Has been cancelled
Docker / Build and Push ML (rknn, linux/arm64, -rknn) (push) Has been cancelled
Docker / Build and Push ML (rocm, linux/amd64, {"linux/amd64": "mich"}, -rocm) (push) Has been cancelled
Docker / Build and Push Server (push) Has been cancelled
Docker / Docker Build & Push Server Success (push) Has been cancelled
Docker / Docker Build & Push ML Success (push) Has been cancelled
Docs build / pre-job (push) Has been cancelled
Docs build / Docs Build (push) Has been cancelled
Static Code Analysis / pre-job (push) Has been cancelled
Static Code Analysis / Run Dart Code Analysis (push) Has been cancelled
Static Code Analysis / zizmor (push) Has been cancelled
Test / pre-job (push) Has been cancelled
Test / Test & Lint Server (push) Has been cancelled
Test / Unit Test CLI (push) Has been cancelled
Test / Unit Test CLI (Windows) (push) Has been cancelled
Test / Lint Web (push) Has been cancelled
Test / Test Web (push) Has been cancelled
Test / Test i18n (push) Has been cancelled
Test / End-to-End Lint (push) Has been cancelled
Test / Medium Tests (Server) (push) Has been cancelled
Test / End-to-End Tests (Server & CLI) (ubuntu-24.04-arm) (push) Has been cancelled
Test / End-to-End Tests (Server & CLI) (ubuntu-latest) (push) Has been cancelled
Test / End-to-End Tests (Web) (ubuntu-24.04-arm) (push) Has been cancelled
Test / End-to-End Tests (Web) (ubuntu-latest) (push) Has been cancelled
Test / End-to-End Tests Success (push) Has been cancelled
Test / Unit Test Mobile (push) Has been cancelled
Test / Unit Test ML (push) Has been cancelled
Test / .github Files Formatting (push) Has been cancelled
Test / ShellCheck (push) Has been cancelled
Test / OpenAPI Clients (push) Has been cancelled
Test / SQL Schema Checks (push) Has been cancelled
CLI Build / CLI Publish (push) Has been cancelled
CLI Build / Docker (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docker / pre-job (push) Has been cancelled
Docker / Re-Tag ML () (push) Has been cancelled
Docker / Re-Tag ML (-armnn) (push) Has been cancelled
Docker / Re-Tag ML (-cuda) (push) Has been cancelled
Docker / Re-Tag ML (-openvino) (push) Has been cancelled
Docker / Re-Tag ML (-rknn) (push) Has been cancelled
Docker / Re-Tag ML (-rocm) (push) Has been cancelled
Docker / Re-Tag Server () (push) Has been cancelled
Docker / Build and Push ML (armnn, linux/arm64, -armnn) (push) Has been cancelled
Docker / Build and Push ML (cpu, ) (push) Has been cancelled
Docker / Build and Push ML (cuda, linux/amd64, -cuda) (push) Has been cancelled
Docker / Build and Push ML (openvino, linux/amd64, -openvino) (push) Has been cancelled
Docker / Build and Push ML (rknn, linux/arm64, -rknn) (push) Has been cancelled
Docker / Build and Push ML (rocm, linux/amd64, {"linux/amd64": "mich"}, -rocm) (push) Has been cancelled
Docker / Build and Push Server (push) Has been cancelled
Docker / Docker Build & Push Server Success (push) Has been cancelled
Docker / Docker Build & Push ML Success (push) Has been cancelled
Docs build / pre-job (push) Has been cancelled
Docs build / Docs Build (push) Has been cancelled
Static Code Analysis / pre-job (push) Has been cancelled
Static Code Analysis / Run Dart Code Analysis (push) Has been cancelled
Static Code Analysis / zizmor (push) Has been cancelled
Test / pre-job (push) Has been cancelled
Test / Test & Lint Server (push) Has been cancelled
Test / Unit Test CLI (push) Has been cancelled
Test / Unit Test CLI (Windows) (push) Has been cancelled
Test / Lint Web (push) Has been cancelled
Test / Test Web (push) Has been cancelled
Test / Test i18n (push) Has been cancelled
Test / End-to-End Lint (push) Has been cancelled
Test / Medium Tests (Server) (push) Has been cancelled
Test / End-to-End Tests (Server & CLI) (ubuntu-24.04-arm) (push) Has been cancelled
Test / End-to-End Tests (Server & CLI) (ubuntu-latest) (push) Has been cancelled
Test / End-to-End Tests (Web) (ubuntu-24.04-arm) (push) Has been cancelled
Test / End-to-End Tests (Web) (ubuntu-latest) (push) Has been cancelled
Test / End-to-End Tests Success (push) Has been cancelled
Test / Unit Test Mobile (push) Has been cancelled
Test / Unit Test ML (push) Has been cancelled
Test / .github Files Formatting (push) Has been cancelled
Test / ShellCheck (push) Has been cancelled
Test / OpenAPI Clients (push) Has been cancelled
Test / SQL Schema Checks (push) Has been cancelled
CLI Build / CLI Publish (push) Has been cancelled
CLI Build / Docker (push) Has been cancelled
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,3 +25,5 @@ mobile/ios/fastlane/report.xml
|
||||
|
||||
vite.config.js.timestamp-*
|
||||
.pnpm-store
|
||||
.devcontainer/library
|
||||
.devcontainer/.env*
|
||||
|
||||
@@ -1076,10 +1076,7 @@
|
||||
"gcast_enabled": "Google Cast",
|
||||
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
|
||||
"general": "General",
|
||||
"geolocation_instruction_all_have_location": "All assets for this date already have location data. Try showing all assets or select a different date",
|
||||
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
|
||||
"geolocation_instruction_no_date": "Select a date to manage location data for photos and videos from that day",
|
||||
"geolocation_instruction_no_photos": "No photos or videos found for this date. Select a different date to show them",
|
||||
"get_help": "Get Help",
|
||||
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
|
||||
"getting_started": "Getting Started",
|
||||
@@ -1850,10 +1847,8 @@
|
||||
"shift_to_permanent_delete": "press ⇧ to permanently delete asset",
|
||||
"show_album_options": "Show album options",
|
||||
"show_albums": "Show albums",
|
||||
"show_all_assets": "Show all assets",
|
||||
"show_all_people": "Show all people",
|
||||
"show_and_hide_people": "Show & hide people",
|
||||
"show_assets_without_location": "Show assets without location",
|
||||
"show_file_location": "Show file location",
|
||||
"show_gallery": "Show gallery",
|
||||
"show_hidden_people": "Show hidden people",
|
||||
@@ -2038,7 +2033,6 @@
|
||||
"use_biometric": "Use biometric",
|
||||
"use_current_connection": "use current connection",
|
||||
"use_custom_date_range": "Use custom date range instead",
|
||||
"use_this_location": "Click to use location",
|
||||
"user": "User",
|
||||
"user_has_been_deleted": "This user has been deleted.",
|
||||
"user_id": "User ID",
|
||||
|
||||
30
mobile/openapi/lib/api/timeline_api.dart
generated
30
mobile/openapi/lib/api/timeline_api.dart
generated
@@ -53,12 +53,15 @@ class TimelineApi {
|
||||
/// * [AssetVisibility] visibility:
|
||||
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
|
||||
///
|
||||
/// * [bool] withCoordinates:
|
||||
/// Include location data in the response
|
||||
///
|
||||
/// * [bool] withPartners:
|
||||
/// Include assets shared by partners
|
||||
///
|
||||
/// * [bool] withStacked:
|
||||
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/timeline/bucket';
|
||||
|
||||
@@ -100,6 +103,9 @@ class TimelineApi {
|
||||
if (visibility != null) {
|
||||
queryParams.addAll(_queryParams('', 'visibility', visibility));
|
||||
}
|
||||
if (withCoordinates != null) {
|
||||
queryParams.addAll(_queryParams('', 'withCoordinates', withCoordinates));
|
||||
}
|
||||
if (withPartners != null) {
|
||||
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
|
||||
}
|
||||
@@ -156,13 +162,16 @@ class TimelineApi {
|
||||
/// * [AssetVisibility] visibility:
|
||||
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
|
||||
///
|
||||
/// * [bool] withCoordinates:
|
||||
/// Include location data in the response
|
||||
///
|
||||
/// * [bool] withPartners:
|
||||
/// Include assets shared by partners
|
||||
///
|
||||
/// * [bool] withStacked:
|
||||
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
|
||||
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
|
||||
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -210,12 +219,15 @@ class TimelineApi {
|
||||
/// * [AssetVisibility] visibility:
|
||||
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
|
||||
///
|
||||
/// * [bool] withCoordinates:
|
||||
/// Include location data in the response
|
||||
///
|
||||
/// * [bool] withPartners:
|
||||
/// Include assets shared by partners
|
||||
///
|
||||
/// * [bool] withStacked:
|
||||
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/timeline/buckets';
|
||||
|
||||
@@ -256,6 +268,9 @@ class TimelineApi {
|
||||
if (visibility != null) {
|
||||
queryParams.addAll(_queryParams('', 'visibility', visibility));
|
||||
}
|
||||
if (withCoordinates != null) {
|
||||
queryParams.addAll(_queryParams('', 'withCoordinates', withCoordinates));
|
||||
}
|
||||
if (withPartners != null) {
|
||||
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
|
||||
}
|
||||
@@ -309,13 +324,16 @@ class TimelineApi {
|
||||
/// * [AssetVisibility] visibility:
|
||||
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
|
||||
///
|
||||
/// * [bool] withCoordinates:
|
||||
/// Include location data in the response
|
||||
///
|
||||
/// * [bool] withPartners:
|
||||
/// Include assets shared by partners
|
||||
///
|
||||
/// * [bool] withStacked:
|
||||
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
|
||||
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
|
||||
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ class TimeBucketAssetResponseDto {
|
||||
this.isFavorite = const [],
|
||||
this.isImage = const [],
|
||||
this.isTrashed = const [],
|
||||
this.latitude = const [],
|
||||
this.livePhotoVideoId = const [],
|
||||
this.localOffsetHours = const [],
|
||||
this.longitude = const [],
|
||||
this.ownerId = const [],
|
||||
this.projectionType = const [],
|
||||
this.ratio = const [],
|
||||
@@ -55,12 +57,18 @@ class TimeBucketAssetResponseDto {
|
||||
/// Array indicating whether each asset is in the trash
|
||||
List<bool> isTrashed;
|
||||
|
||||
/// Array of latitude coordinates extracted from EXIF GPS data
|
||||
List<num?> latitude;
|
||||
|
||||
/// Array of live photo video asset IDs (null for non-live photos)
|
||||
List<String?> livePhotoVideoId;
|
||||
|
||||
/// Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.
|
||||
List<num> localOffsetHours;
|
||||
|
||||
/// Array of longitude coordinates extracted from EXIF GPS data
|
||||
List<num?> longitude;
|
||||
|
||||
/// Array of owner IDs for each asset
|
||||
List<String> ownerId;
|
||||
|
||||
@@ -89,8 +97,10 @@ class TimeBucketAssetResponseDto {
|
||||
_deepEquality.equals(other.isFavorite, isFavorite) &&
|
||||
_deepEquality.equals(other.isImage, isImage) &&
|
||||
_deepEquality.equals(other.isTrashed, isTrashed) &&
|
||||
_deepEquality.equals(other.latitude, latitude) &&
|
||||
_deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) &&
|
||||
_deepEquality.equals(other.localOffsetHours, localOffsetHours) &&
|
||||
_deepEquality.equals(other.longitude, longitude) &&
|
||||
_deepEquality.equals(other.ownerId, ownerId) &&
|
||||
_deepEquality.equals(other.projectionType, projectionType) &&
|
||||
_deepEquality.equals(other.ratio, ratio) &&
|
||||
@@ -109,8 +119,10 @@ class TimeBucketAssetResponseDto {
|
||||
(isFavorite.hashCode) +
|
||||
(isImage.hashCode) +
|
||||
(isTrashed.hashCode) +
|
||||
(latitude.hashCode) +
|
||||
(livePhotoVideoId.hashCode) +
|
||||
(localOffsetHours.hashCode) +
|
||||
(longitude.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(projectionType.hashCode) +
|
||||
(ratio.hashCode) +
|
||||
@@ -119,7 +131,7 @@ class TimeBucketAssetResponseDto {
|
||||
(visibility.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, fileCreatedAt=$fileCreatedAt, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, livePhotoVideoId=$livePhotoVideoId, localOffsetHours=$localOffsetHours, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]';
|
||||
String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, fileCreatedAt=$fileCreatedAt, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, localOffsetHours=$localOffsetHours, longitude=$longitude, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -131,8 +143,10 @@ class TimeBucketAssetResponseDto {
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
json[r'isImage'] = this.isImage;
|
||||
json[r'isTrashed'] = this.isTrashed;
|
||||
json[r'latitude'] = this.latitude;
|
||||
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
|
||||
json[r'localOffsetHours'] = this.localOffsetHours;
|
||||
json[r'longitude'] = this.longitude;
|
||||
json[r'ownerId'] = this.ownerId;
|
||||
json[r'projectionType'] = this.projectionType;
|
||||
json[r'ratio'] = this.ratio;
|
||||
@@ -175,12 +189,18 @@ class TimeBucketAssetResponseDto {
|
||||
isTrashed: json[r'isTrashed'] is Iterable
|
||||
? (json[r'isTrashed'] as Iterable).cast<bool>().toList(growable: false)
|
||||
: const [],
|
||||
latitude: json[r'latitude'] is Iterable
|
||||
? (json[r'latitude'] as Iterable).cast<num>().toList(growable: false)
|
||||
: const [],
|
||||
livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable
|
||||
? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
localOffsetHours: json[r'localOffsetHours'] is Iterable
|
||||
? (json[r'localOffsetHours'] as Iterable).cast<num>().toList(growable: false)
|
||||
: const [],
|
||||
longitude: json[r'longitude'] is Iterable
|
||||
? (json[r'longitude'] as Iterable).cast<num>().toList(growable: false)
|
||||
: const [],
|
||||
ownerId: json[r'ownerId'] is Iterable
|
||||
? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
|
||||
@@ -8895,6 +8895,15 @@
|
||||
"$ref": "#/components/schemas/AssetVisibility"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "withCoordinates",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Include location data in the response",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "withPartners",
|
||||
"required": false,
|
||||
@@ -9040,6 +9049,15 @@
|
||||
"$ref": "#/components/schemas/AssetVisibility"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "withCoordinates",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Include location data in the response",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "withPartners",
|
||||
"required": false,
|
||||
@@ -17066,6 +17084,14 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"latitude": {
|
||||
"description": "Array of latitude coordinates extracted from EXIF GPS data",
|
||||
"items": {
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"livePhotoVideoId": {
|
||||
"description": "Array of live photo video asset IDs (null for non-live photos)",
|
||||
"items": {
|
||||
@@ -17081,6 +17107,14 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"longitude": {
|
||||
"description": "Array of longitude coordinates extracted from EXIF GPS data",
|
||||
"items": {
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"ownerId": {
|
||||
"description": "Array of owner IDs for each asset",
|
||||
"items": {
|
||||
|
||||
@@ -1561,10 +1561,14 @@ export type TimeBucketAssetResponseDto = {
|
||||
isImage: boolean[];
|
||||
/** Array indicating whether each asset is in the trash */
|
||||
isTrashed: boolean[];
|
||||
/** Array of latitude coordinates extracted from EXIF GPS data */
|
||||
latitude?: (number | null)[];
|
||||
/** Array of live photo video asset IDs (null for non-live photos) */
|
||||
livePhotoVideoId: (string | null)[];
|
||||
/** Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective. */
|
||||
localOffsetHours: number[];
|
||||
/** Array of longitude coordinates extracted from EXIF GPS data */
|
||||
longitude?: (number | null)[];
|
||||
/** Array of owner IDs for each asset */
|
||||
ownerId: string[];
|
||||
/** Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL") */
|
||||
@@ -4293,7 +4297,7 @@ export function tagAssets({ id, bulkIdsDto }: {
|
||||
/**
|
||||
* This endpoint requires the `asset.read` permission.
|
||||
*/
|
||||
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withPartners, withStacked }: {
|
||||
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
|
||||
albumId?: string;
|
||||
isFavorite?: boolean;
|
||||
isTrashed?: boolean;
|
||||
@@ -4305,6 +4309,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
|
||||
timeBucket: string;
|
||||
userId?: string;
|
||||
visibility?: AssetVisibility;
|
||||
withCoordinates?: boolean;
|
||||
withPartners?: boolean;
|
||||
withStacked?: boolean;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
@@ -4323,6 +4328,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
|
||||
timeBucket,
|
||||
userId,
|
||||
visibility,
|
||||
withCoordinates,
|
||||
withPartners,
|
||||
withStacked
|
||||
}))}`, {
|
||||
@@ -4332,7 +4338,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
|
||||
/**
|
||||
* This endpoint requires the `asset.read` permission.
|
||||
*/
|
||||
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withPartners, withStacked }: {
|
||||
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
|
||||
albumId?: string;
|
||||
isFavorite?: boolean;
|
||||
isTrashed?: boolean;
|
||||
@@ -4343,6 +4349,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
|
||||
tagId?: string;
|
||||
userId?: string;
|
||||
visibility?: AssetVisibility;
|
||||
withCoordinates?: boolean;
|
||||
withPartners?: boolean;
|
||||
withStacked?: boolean;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
@@ -4360,6 +4367,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
|
||||
tagId,
|
||||
userId,
|
||||
visibility,
|
||||
withCoordinates,
|
||||
withPartners,
|
||||
withStacked
|
||||
}))}`, {
|
||||
|
||||
@@ -53,6 +53,12 @@ export class TimeBucketDto {
|
||||
description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
|
||||
})
|
||||
visibility?: AssetVisibility;
|
||||
|
||||
@ValidateBoolean({
|
||||
optional: true,
|
||||
description: 'Include location data in the response',
|
||||
})
|
||||
withCoordinates?: boolean;
|
||||
}
|
||||
|
||||
export class TimeBucketAssetDto extends TimeBucketDto {
|
||||
@@ -185,6 +191,22 @@ export class TimeBucketAssetResponseDto {
|
||||
description: 'Array of country names extracted from EXIF GPS data',
|
||||
})
|
||||
country!: (string | null)[];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
required: false,
|
||||
items: { type: 'number', nullable: true },
|
||||
description: 'Array of latitude coordinates extracted from EXIF GPS data',
|
||||
})
|
||||
latitude!: number[];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
required: false,
|
||||
items: { type: 'number', nullable: true },
|
||||
description: 'Array of longitude coordinates extracted from EXIF GPS data',
|
||||
})
|
||||
longitude!: number[];
|
||||
}
|
||||
|
||||
export class TimeBucketsResponseDto {
|
||||
|
||||
@@ -60,6 +60,7 @@ interface AssetBuilderOptions {
|
||||
status?: AssetStatus;
|
||||
assetType?: AssetType;
|
||||
visibility?: AssetVisibility;
|
||||
withCoordinates?: boolean;
|
||||
}
|
||||
|
||||
export interface TimeBucketOptions extends AssetBuilderOptions {
|
||||
@@ -628,6 +629,7 @@ export class AssetRepository {
|
||||
)
|
||||
.as('ratio'),
|
||||
])
|
||||
.$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude']))
|
||||
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||
.$if(options.visibility == undefined, withDefaultVisibility)
|
||||
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
|
||||
@@ -701,6 +703,12 @@ export class AssetRepository {
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'),
|
||||
])
|
||||
.$if(!!options.withCoordinates, (qb) =>
|
||||
qb.select((eb) => [
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['latitude']), sql.lit('{}')).as('latitude'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['longitude']), sql.lit('{}')).as('longitude'),
|
||||
]),
|
||||
)
|
||||
.$if(!!options.withStacked, (qb) =>
|
||||
qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
@@ -12,10 +13,10 @@
|
||||
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
@@ -27,11 +28,23 @@
|
||||
monthGroup: MonthGroup;
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
customLayout?: Snippet<[TimelineAsset]>;
|
||||
|
||||
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
||||
onSelectAssets: (asset: TimelineAsset) => void;
|
||||
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
|
||||
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||
onThumbnailClick?: (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -42,10 +55,12 @@
|
||||
monthGroup = $bindable(),
|
||||
assetInteraction,
|
||||
timelineManager,
|
||||
customLayout,
|
||||
onSelect,
|
||||
onSelectAssets,
|
||||
onSelectAssetCandidates,
|
||||
onScrollCompensation,
|
||||
onThumbnailClick,
|
||||
}: Props = $props();
|
||||
|
||||
let isMouseOverGroup = $state(false);
|
||||
@@ -55,7 +70,7 @@
|
||||
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||
);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
const onClick = (
|
||||
const _onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
@@ -202,7 +217,13 @@
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
}}
|
||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
||||
@@ -212,6 +233,9 @@
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
{#if customLayout}
|
||||
{@render customLayout(asset)}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- {/if} -->
|
||||
{/each}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
@@ -65,6 +66,18 @@
|
||||
onEscape?: () => void;
|
||||
children?: Snippet;
|
||||
empty?: Snippet;
|
||||
customLayout?: Snippet<[TimelineAsset]>;
|
||||
onThumbnailClick?: (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -84,6 +97,8 @@
|
||||
onEscape = () => {},
|
||||
children,
|
||||
empty,
|
||||
customLayout,
|
||||
onThumbnailClick,
|
||||
}: Props = $props();
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore;
|
||||
@@ -940,6 +955,8 @@
|
||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||
onSelectAssets={handleSelectAssets}
|
||||
onScrollCompensation={handleScrollCompensation}
|
||||
{customLayout}
|
||||
{onThumbnailClick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onDateChange: (year?: number, month?: number, day?: number) => Promise<void>;
|
||||
onClearFilters?: () => void;
|
||||
defaultDate?: string;
|
||||
}
|
||||
|
||||
let { onDateChange, onClearFilters, defaultDate }: Props = $props();
|
||||
|
||||
let selectedYear = $state<number | undefined>(undefined);
|
||||
let selectedMonth = $state<number | undefined>(undefined);
|
||||
let selectedDay = $state<number | undefined>(undefined);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from({ length: 30 }, (_, i) => currentYear - i);
|
||||
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: i + 1,
|
||||
label: new Date(2000, i).toLocaleString('default', { month: 'long' }),
|
||||
}));
|
||||
|
||||
const dayOptions = $derived.by(() => {
|
||||
if (!selectedYear || !selectedMonth) {
|
||||
return [];
|
||||
}
|
||||
const daysInMonth = new Date(selectedYear, selectedMonth, 0).getDate();
|
||||
return Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
});
|
||||
|
||||
if (defaultDate) {
|
||||
const [year, month, day] = defaultDate.split('-');
|
||||
selectedYear = Number.parseInt(year);
|
||||
selectedMonth = Number.parseInt(month);
|
||||
selectedDay = Number.parseInt(day);
|
||||
}
|
||||
|
||||
const filterAssetsByDate = async () => {
|
||||
await onDateChange(selectedYear, selectedMonth, selectedDay);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
selectedYear = undefined;
|
||||
selectedMonth = undefined;
|
||||
selectedDay = undefined;
|
||||
if (onClearFilters) {
|
||||
onClearFilters();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mt-2 mb-2 p-2 rounded-lg">
|
||||
<div class="flex flex-wrap gap-4 items-end w-136">
|
||||
<div class="flex-1 min-w-20">
|
||||
<label for="year-select" class="immich-form-label">
|
||||
{$t('year')}
|
||||
</label>
|
||||
<select
|
||||
id="year-select"
|
||||
bind:value={selectedYear}
|
||||
onchange={filterAssetsByDate}
|
||||
class="text-sm w-full mt-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value={undefined}>{$t('year')}</option>
|
||||
{#each yearOptions as year (year)}
|
||||
<option value={year}>{year}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-2 min-w-24">
|
||||
<label for="month-select" class="immich-form-label">
|
||||
{$t('month')}
|
||||
</label>
|
||||
<select
|
||||
id="month-select"
|
||||
bind:value={selectedMonth}
|
||||
onchange={filterAssetsByDate}
|
||||
disabled={!selectedYear}
|
||||
class="text-sm w-full mt-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:bg-gray-400"
|
||||
>
|
||||
<option value={undefined}>{$t('month')}</option>
|
||||
{#each monthOptions as month (month.value)}
|
||||
<option value={month.value}>{month.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-16">
|
||||
<label for="day-select" class="immich-form-label">
|
||||
{$t('day')}
|
||||
</label>
|
||||
<select
|
||||
id="day-select"
|
||||
bind:value={selectedDay}
|
||||
onchange={filterAssetsByDate}
|
||||
disabled={!selectedYear || !selectedMonth}
|
||||
class="text-sm w-full mt-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:bg-gray-400"
|
||||
>
|
||||
<option value={undefined}>{$t('day')}</option>
|
||||
{#each dayOptions as day (day)}
|
||||
<option value={day}>{day}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<Button size="small" color="secondary" variant="ghost" onclick={clearFilters}>{$t('reset')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,104 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
assetInteraction: AssetInteraction;
|
||||
onSelectAsset: (asset: AssetResponseDto) => void;
|
||||
onMouseEvent: (asset: AssetResponseDto) => void;
|
||||
onLocation: (location: { latitude: number; longitude: number }) => void;
|
||||
}
|
||||
|
||||
let { asset, assetInteraction, onSelectAsset, onMouseEvent, onLocation }: Props = $props();
|
||||
|
||||
let assetData = $derived(
|
||||
JSON.stringify(
|
||||
{
|
||||
originalFileName: asset.originalFileName,
|
||||
localDateTime: asset.localDateTime,
|
||||
make: asset.exifInfo?.make,
|
||||
model: asset.exifInfo?.model,
|
||||
gps: {
|
||||
latitude: asset.exifInfo?.latitude,
|
||||
longitude: asset.exifInfo?.longitude,
|
||||
},
|
||||
location: asset.exifInfo?.city ? `${asset.exifInfo?.country} - ${asset.exifInfo?.city}` : undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
let boxWidth = $state(300);
|
||||
let timelineAsset = $derived(toTimelineAsset(asset));
|
||||
const hasGps = $derived(!!asset.exifInfo?.latitude && !!asset.exifInfo?.longitude);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-full aspect-square rounded-xl border-3 transition-colors font-semibold text-xs dark:bg-black bg-gray-200 border-gray-200 dark:border-gray-800"
|
||||
bind:clientWidth={boxWidth}
|
||||
title={assetData}
|
||||
>
|
||||
<div class="relative w-full h-full overflow-hidden rounded-lg">
|
||||
<Thumbnail
|
||||
asset={timelineAsset}
|
||||
onClick={() => {
|
||||
if (asset.exifInfo?.latitude && asset.exifInfo?.longitude) {
|
||||
onLocation({ latitude: asset.exifInfo?.latitude, longitude: asset.exifInfo?.longitude });
|
||||
} else {
|
||||
onSelectAsset(asset);
|
||||
}
|
||||
}}
|
||||
onSelect={() => onSelectAsset(asset)}
|
||||
onMouseEvent={() => onMouseEvent(asset)}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
thumbnailSize={boxWidth}
|
||||
readonly={hasGps}
|
||||
/>
|
||||
|
||||
{#if hasGps}
|
||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
|
||||
{$t('gps')}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-danger text-light">
|
||||
{$t('gps_missing')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-center mt-4 px-4 text-sm font-semibold truncate" title={asset.originalFileName}>
|
||||
<a href={`${AppRoute.PHOTOS}/${asset.id}`} target="_blank" rel="noopener noreferrer">
|
||||
{asset.originalFileName}
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center my-3">
|
||||
<p class="px-4 text-xs font-normal truncate text-dark/75">
|
||||
{new Date(asset.localDateTime).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p class="px-4 text-xs font-normal truncate text-dark/75">
|
||||
{new Date(asset.localDateTime).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})}
|
||||
</p>
|
||||
{#if hasGps}
|
||||
<p class="text-primary mt-2 text-xs font-normal px-4 text-center truncate">
|
||||
{asset.exifInfo?.country}
|
||||
</p>
|
||||
<p class="text-primary text-xs font-normal px-4 text-center truncate">
|
||||
{asset.exifInfo?.city}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,6 +187,11 @@ export class MonthGroup {
|
||||
thumbhash: bucketAssets.thumbhash[i],
|
||||
people: null, // People are not included in the bucket assets
|
||||
};
|
||||
|
||||
if (bucketAssets.latitude?.[i] && bucketAssets.longitude?.[i]) {
|
||||
timelineAsset.latitude = bucketAssets.latitude?.[i];
|
||||
timelineAsset.longitude = bucketAssets.longitude?.[i];
|
||||
}
|
||||
this.addTimelineAsset(timelineAsset, addContext);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ export type TimelineAsset = {
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
people: string[] | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
};
|
||||
|
||||
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { buildDateRangeFromYearMonthAndDay, getAlbumDateRange, timeToSeconds } from './date-time';
|
||||
import { getAlbumDateRange, timeToSeconds } from './date-time';
|
||||
|
||||
describe('converting time to seconds', () => {
|
||||
it('parses hh:mm:ss correctly', () => {
|
||||
@@ -75,24 +75,3 @@ describe('getAlbumDate', () => {
|
||||
expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDateRangeFromYearMonthAndDay', () => {
|
||||
it('should build correct date range for a specific day', () => {
|
||||
const result = buildDateRangeFromYearMonthAndDay(2023, 1, 8);
|
||||
|
||||
expect(result.from).toContain('2023-01-08T00:00:00');
|
||||
expect(result.to).toContain('2023-01-09T00:00:00');
|
||||
});
|
||||
|
||||
it('should build correct date range for a month', () => {
|
||||
const result = buildDateRangeFromYearMonthAndDay(2023, 2);
|
||||
expect(result.from).toContain('2023-02-01T00:00:00');
|
||||
expect(result.to).toContain('2023-03-01T00:00:00');
|
||||
});
|
||||
|
||||
it('should build correct date range for a year', () => {
|
||||
const result = buildDateRangeFromYearMonthAndDay(2023);
|
||||
expect(result.from).toContain('2023-01-01T00:00:00');
|
||||
expect(result.to).toContain('2024-01-01T00:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,33 +85,3 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string
|
||||
*/
|
||||
export const asLocalTimeISO = (date: DateTime<true>) =>
|
||||
(date.setZone('utc', { keepLocalTime: true }) as DateTime<true>).toISO();
|
||||
|
||||
/**
|
||||
* Creates a date range for filtering assets based on year, month, and day parameters
|
||||
*/
|
||||
export const buildDateRangeFromYearMonthAndDay = (year: number, month?: number, day?: number) => {
|
||||
const baseDate = DateTime.fromObject({
|
||||
year,
|
||||
month: month || 1,
|
||||
day: day || 1,
|
||||
});
|
||||
|
||||
let from: DateTime;
|
||||
let to: DateTime;
|
||||
|
||||
if (day) {
|
||||
from = baseDate.startOf('day');
|
||||
to = baseDate.plus({ days: 1 }).startOf('day');
|
||||
} else if (month) {
|
||||
from = baseDate.startOf('month');
|
||||
to = baseDate.plus({ months: 1 }).startOf('month');
|
||||
} else {
|
||||
from = baseDate.startOf('year');
|
||||
to = baseDate.plus({ years: 1 }).startOf('year');
|
||||
}
|
||||
|
||||
return {
|
||||
from: from.toISO() || undefined,
|
||||
to: to.toISO() || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -190,6 +190,8 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
city: city || null,
|
||||
country: country || null,
|
||||
people,
|
||||
latitude: assetResponse.exifInfo?.latitude || null,
|
||||
longitude: assetResponse.exifInfo?.longitude || null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
<script lang="ts">
|
||||
import emptyUrl from '$lib/assets/empty-5.svg';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
|
||||
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
|
||||
import DatePicker from '$lib/components/shared-components/date-picker.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import Geolocation from '$lib/components/utilities-page/geolocation/geolocation.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { buildDateRangeFromYearMonthAndDay } from '$lib/utils/date-time';
|
||||
import { setQueryValue } from '$lib/utils/navigation';
|
||||
import { buildDateString } from '$lib/utils/string-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { searchAssets, updateAssets, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetVisibility, getAssetInfo, updateAssets } from '@immich/sdk';
|
||||
import { Button, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiMapMarkerMultipleOutline,
|
||||
mdiMapMarkerOff,
|
||||
mdiPencilOutline,
|
||||
mdiSelectAll,
|
||||
mdiSelectRemove,
|
||||
} from '@mdi/js';
|
||||
import { mdiMapMarkerMultipleOutline, mdiPencilOutline, mdiSelectRemove } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
@@ -29,89 +24,19 @@
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
let partialDate = $state<string | null>(data.partialDate);
|
||||
|
||||
let isLoading = $state(false);
|
||||
let assets = $state<AssetResponseDto[]>([]);
|
||||
let shiftKeyIsDown = $state(false);
|
||||
let assetInteraction = new AssetInteraction();
|
||||
let location = $state<{ latitude: number; longitude: number }>({ latitude: 0, longitude: 0 });
|
||||
let assetsToDisplay = $state(500);
|
||||
let takenRange = $state<{ takenAfter?: string; takenBefore?: string } | null>(null);
|
||||
let locationUpdated = $state(false);
|
||||
let showOnlyAssetsWithoutLocation = $state(false);
|
||||
|
||||
// Filtered assets based on location filter
|
||||
let filteredAssets = $derived(
|
||||
showOnlyAssetsWithoutLocation
|
||||
? assets.filter((asset) => !asset.exifInfo?.latitude || !asset.exifInfo?.longitude)
|
||||
: assets,
|
||||
);
|
||||
|
||||
void init();
|
||||
|
||||
async function init() {
|
||||
if (partialDate) {
|
||||
const [year, month, day] = partialDate.split('-');
|
||||
const { from: takenAfter, to: takenBefore } = buildDateRangeFromYearMonthAndDay(
|
||||
Number.parseInt(year),
|
||||
Number.parseInt(month),
|
||||
Number.parseInt(day),
|
||||
);
|
||||
takenRange = { takenAfter, takenBefore };
|
||||
const dateString = buildDateString(Number.parseInt(year), Number.parseInt(month), Number.parseInt(day));
|
||||
await setQueryValue('date', dateString);
|
||||
await loadAssets();
|
||||
}
|
||||
}
|
||||
|
||||
const loadAssets = async () => {
|
||||
if (takenRange) {
|
||||
isLoading = true;
|
||||
|
||||
const searchResult = await searchAssets({
|
||||
metadataSearchDto: {
|
||||
withExif: true,
|
||||
takenAfter: takenRange.takenAfter,
|
||||
takenBefore: takenRange.takenBefore,
|
||||
size: assetsToDisplay,
|
||||
},
|
||||
});
|
||||
|
||||
assets = searchResult.assets.items;
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = async (selectedYear?: number, selectedMonth?: number, selectedDay?: number) => {
|
||||
partialDate = selectedYear ? buildDateString(selectedYear, selectedMonth, selectedDay) : null;
|
||||
if (!selectedYear) {
|
||||
assets = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { from: takenAfter, to: takenBefore } = buildDateRangeFromYearMonthAndDay(
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
selectedDay,
|
||||
);
|
||||
const dateString = buildDateString(selectedYear, selectedMonth, selectedDay);
|
||||
takenRange = { takenAfter, takenBefore };
|
||||
await setQueryValue('date', dateString);
|
||||
await loadAssets();
|
||||
} catch (error) {
|
||||
console.error('Failed to filter assets by date:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearFilters = async () => {
|
||||
assets = [];
|
||||
assetInteraction.clearMultiselect();
|
||||
await setQueryValue('date', '');
|
||||
};
|
||||
|
||||
const toggleLocationFilter = () => {
|
||||
showOnlyAssetsWithoutLocation = !showOnlyAssetsWithoutLocation;
|
||||
};
|
||||
const timelineManager = new TimelineManager();
|
||||
void timelineManager.updateOptions({
|
||||
visibility: AssetVisibility.Timeline,
|
||||
withStacked: true,
|
||||
withPartners: true,
|
||||
withCoordinates: true,
|
||||
});
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const confirmed = await modalManager.show(GeolocationUpdateConfirmModal, {
|
||||
@@ -131,61 +56,21 @@
|
||||
},
|
||||
});
|
||||
|
||||
void loadAssets();
|
||||
const updatedAssets = await Promise.all(
|
||||
assetInteraction.selectedAssets.map(async (asset) => {
|
||||
const updatedAsset = await getAssetInfo({ ...authManager.params, id: asset.id });
|
||||
return toTimelineAsset(updatedAsset);
|
||||
}),
|
||||
);
|
||||
|
||||
timelineManager.updateAssets(updatedAssets);
|
||||
|
||||
handleDeselectAll();
|
||||
};
|
||||
|
||||
// Assets selection handlers
|
||||
// TODO: might be refactored to use the same logic as in asset-grid.svelte and gallery-viewer.svelte
|
||||
const handleSelectAssets = (asset: AssetResponseDto) => {
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
const deselect = assetInteraction.hasSelectedAsset(asset.id);
|
||||
|
||||
if (deselect) {
|
||||
for (const candidate of assetInteraction.assetSelectionCandidates) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
|
||||
}
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
} else {
|
||||
for (const candidate of assetInteraction.assetSelectionCandidates) {
|
||||
assetInteraction.selectAsset(candidate);
|
||||
}
|
||||
assetInteraction.selectAsset(timelineAsset);
|
||||
}
|
||||
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
assetInteraction.setAssetSelectionStart(deselect ? null : timelineAsset);
|
||||
};
|
||||
|
||||
const selectAssetCandidates = (endAsset: AssetResponseDto) => {
|
||||
if (!shiftKeyIsDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startAsset = assetInteraction.assetSelectionStart;
|
||||
if (!startAsset) {
|
||||
return;
|
||||
}
|
||||
|
||||
let start = assets.findIndex((a) => a.id === startAsset.id);
|
||||
let end = assets.findIndex((a) => a.id === endAsset.id);
|
||||
|
||||
if (start > end) {
|
||||
[start, end] = [end, start];
|
||||
}
|
||||
|
||||
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
|
||||
};
|
||||
const assetMouseEventHandler = (asset: AssetResponseDto) => {
|
||||
if (assetInteraction.selectionActive) {
|
||||
selectAssetCandidates(asset);
|
||||
}
|
||||
};
|
||||
// Keyboard handlers
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = true;
|
||||
}
|
||||
if (event.key === 'Escape' && assetInteraction.selectionActive) {
|
||||
cancelMultiselect(assetInteraction);
|
||||
@@ -194,12 +79,9 @@
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = false;
|
||||
}
|
||||
};
|
||||
const handleSelectAll = () => {
|
||||
assetInteraction.selectAssets(filteredAssets.map((a) => toTimelineAsset(a)));
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
cancelMultiselect(assetInteraction);
|
||||
};
|
||||
@@ -217,6 +99,39 @@
|
||||
|
||||
location = { latitude: point.lat, longitude: point.lng };
|
||||
};
|
||||
const handleEscape = () => {
|
||||
if (assetInteraction.selectionActive) {
|
||||
assetInteraction.clearMultiselect();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const hasGps = (asset: TimelineAsset) => {
|
||||
return !!asset.latitude && !!asset.longitude;
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => {
|
||||
if (hasGps(asset)) {
|
||||
locationUpdated = true;
|
||||
setTimeout(() => {
|
||||
locationUpdated = false;
|
||||
}, 1500);
|
||||
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
||||
void setQueryValue('at', asset.id);
|
||||
} else {
|
||||
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||
@@ -224,9 +139,7 @@
|
||||
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
||||
{#snippet buttons()}
|
||||
<div class="flex gap-2 justify-end place-items-center">
|
||||
{#if filteredAssets.length > 0}
|
||||
<Text class="hidden md:block text-xs mr-4 text-dark/50">{$t('geolocation_instruction_location')}</Text>
|
||||
{/if}
|
||||
<Text class="hidden md:block text-xs mr-4 text-dark/50">{$t('geolocation_instruction_location')}</Text>
|
||||
<div class="border flex place-items-center place-content-center px-2 py-1 bg-primary/10 rounded-2xl">
|
||||
<Text class="hidden md:inline-block text-xs text-gray-500 font-mono mr-5 ml-2 uppercase">
|
||||
{$t('selected_gps_coordinates')}
|
||||
@@ -242,6 +155,16 @@
|
||||
<Button size="small" color="secondary" variant="ghost" leadingIcon={mdiPencilOutline} onclick={handlePickOnMap}>
|
||||
<Text class="hidden sm:inline-block">{$t('location_picker_choose_on_map')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
leadingIcon={mdiSelectRemove}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
disabled={!assetInteraction.selectionActive}
|
||||
onclick={handleDeselectAll}
|
||||
>
|
||||
{$t('unselect_all')}
|
||||
</Button>
|
||||
<Button
|
||||
leadingIcon={mdiMapMarkerMultipleOutline}
|
||||
size="small"
|
||||
@@ -256,70 +179,35 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="bg-light flex items-center justify-between flex-wrap border-b">
|
||||
<div class="flex gap-2 items-center">
|
||||
<DatePicker
|
||||
onDateChange={handleDateChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
defaultDate={partialDate || undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
leadingIcon={showOnlyAssetsWithoutLocation ? mdiMapMarkerMultipleOutline : mdiMapMarkerOff}
|
||||
color={showOnlyAssetsWithoutLocation ? 'primary' : 'secondary'}
|
||||
variant="ghost"
|
||||
onclick={toggleLocationFilter}
|
||||
>
|
||||
{showOnlyAssetsWithoutLocation ? $t('show_all_assets') : $t('show_assets_without_location')}
|
||||
</Button>
|
||||
<Button
|
||||
leadingIcon={assetInteraction.selectionActive ? mdiSelectRemove : mdiSelectAll}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={assetInteraction.selectionActive ? handleDeselectAll : handleSelectAll}
|
||||
>
|
||||
{assetInteraction.selectionActive ? $t('unselect_all') : $t('select_all')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="h-full w-full flex items-center justify-center">
|
||||
<LoadingSpinner size="giant" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filteredAssets && filteredAssets.length > 0}
|
||||
<div class="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 mt-4">
|
||||
{#each filteredAssets as asset (asset.id)}
|
||||
<Geolocation
|
||||
{asset}
|
||||
{assetInteraction}
|
||||
onSelectAsset={(asset) => handleSelectAssets(asset)}
|
||||
onMouseEvent={(asset) => assetMouseEventHandler(asset)}
|
||||
onLocation={(selected) => {
|
||||
location = selected;
|
||||
locationUpdated = true;
|
||||
setTimeout(() => {
|
||||
locationUpdated = false;
|
||||
}, 1000);
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full">
|
||||
{#if partialDate == null}
|
||||
<EmptyPlaceholder text={$t('geolocation_instruction_no_date')} src={emptyUrl} />
|
||||
{:else if showOnlyAssetsWithoutLocation && filteredAssets.length === 0 && assets.length > 0}
|
||||
<EmptyPlaceholder text={$t('geolocation_instruction_all_have_location')} src={emptyUrl} />
|
||||
<AssetGrid
|
||||
isSelectionMode={true}
|
||||
enableRouting={true}
|
||||
{timelineManager}
|
||||
{assetInteraction}
|
||||
removeAction={AssetAction.ARCHIVE}
|
||||
onEscape={handleEscape}
|
||||
withStacked
|
||||
onThumbnailClick={handleThumbnailClick}
|
||||
>
|
||||
{#snippet customLayout(asset: TimelineAsset)}
|
||||
{#if hasGps(asset)}
|
||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
|
||||
{asset.city || $t('gps')}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyPlaceholder text={$t('geolocation_instruction_no_photos')} src={emptyUrl} />
|
||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-danger text-light">
|
||||
{$t('gps_missing')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet empty()}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => {}} />
|
||||
{/snippet}
|
||||
</AssetGrid>
|
||||
</UserPageLayout>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getQueryValue } from '$lib/utils/navigation';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
const partialDate = getQueryValue('date');
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
partialDate,
|
||||
meta: {
|
||||
title: $t('manage_geolocation'),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (({ params }) => {
|
||||
const photoId = params.photoId;
|
||||
return redirect(302, `${AppRoute.PHOTOS}/${photoId}`);
|
||||
}) satisfies PageLoad;
|
||||
Reference in New Issue
Block a user