Compare commits

...

9 Commits

Author SHA1 Message Date
Alex Tran
ad996d1b88 wip: fav button 2026-04-26 22:05:56 -05:00
Alex Tran
08a73c0978 fix: tests 2026-04-26 13:06:17 -05:00
Alex
976ce39fe2 Merge branch 'main' into feat/favorite-albums 2026-04-26 09:18:12 -05:00
Savely Krasovsky
9263e2f2e1 feat(ml): update Intel graphics compiler and compute runtime (#28076)
feat(ml): update Intel graphics compiler and compute runtime to latest versions
2026-04-25 08:49:57 -04:00
Aaron Liu
a3ee615c5b chore(ml): update huggingfacehub and pillow (#27552) 2026-04-24 19:44:01 -04:00
Yaros
39cfad7136 feat(mobile): action bottom sheet on map timeline (#27515) 2026-04-24 09:30:10 -05:00
renovate[bot]
350056dd1a fix(deps): update dependency uuid to v14 [security] (#28046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 11:24:33 +02:00
Alex
a162230a75 chore: regenerate mobile openapi sdk 2026-04-23 04:15:14 +00:00
Alex
166f36e5bf feat(server,web): favorite albums per user 2026-04-23 03:33:32 +00:00
31 changed files with 394 additions and 69 deletions

View File

@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \

View File

@@ -9,12 +9,12 @@ dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"huggingface-hub>=1.0,<2.0",
"insightface>=0.7.3,<1.0",
"numpy<2.4.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=12.2,<12.3",
"pillow>=12.2,<13",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",

View File

@@ -2,17 +2,21 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.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/user.provider.dart';
class MapBottomSheet extends StatelessWidget {
const MapBottomSheet({super.key});
final Key? sheetKey;
const MapBottomSheet({super.key, this.sheetKey});
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
key: sheetKey,
initialChildSize: 0.25,
maxChildSize: 0.75,
shouldCloseOnMinExtent: false,
@@ -49,7 +53,7 @@ class _ScopedMapTimeline extends StatelessWidget {
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: null, withScrubber: false),
child: const Timeline(appBar: null, bottomSheet: GeneralBottomSheet(minChildSize: 0.23), withScrubber: false),
);
}
}

View File

@@ -11,6 +11,7 @@ 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';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
@@ -53,6 +54,7 @@ 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);
final GlobalKey _bottomSheetKey = GlobalKey();
StreamSubscription? _eventSubscription;
@override
@@ -184,7 +186,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return Stack(
children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset, sheetKey: _bottomSheetKey),
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
],
);
@@ -224,8 +226,9 @@ class _Map extends StatelessWidget {
class _DynamicBottomSheet extends StatefulWidget {
final ValueNotifier<double> bottomSheetOffset;
final GlobalKey sheetKey;
const _DynamicBottomSheet({required this.bottomSheetOffset});
const _DynamicBottomSheet({required this.bottomSheetOffset, required this.sheetKey});
@override
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
@@ -236,10 +239,13 @@ class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
Widget build(BuildContext context) {
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
widget.bottomSheetOffset.value = notification.extent;
return true;
final sheet = notification.context.findAncestorWidgetOfExactType<BaseBottomSheet>();
if (sheet?.key == widget.sheetKey) {
widget.bottomSheetOffset.value = notification.extent;
}
return false;
},
child: const MapBottomSheet(),
child: MapBottomSheet(sheetKey: widget.sheetKey),
);
}
}

View File

@@ -469,6 +469,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
clipBehavior: Clip.none,
children: [
timeline,
if (isBottomWidgetVisible)

View File

@@ -508,9 +508,12 @@ class AlbumsApi {
/// * [String] assetId:
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] favorite:
/// Filter to only albums favorited by the authenticated user
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async {
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? favorite, bool? shared, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums';
@@ -524,6 +527,9 @@ class AlbumsApi {
if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId));
}
if (favorite != null) {
queryParams.addAll(_queryParams('', 'favorite', favorite));
}
if (shared != null) {
queryParams.addAll(_queryParams('', 'shared', shared));
}
@@ -551,10 +557,13 @@ class AlbumsApi {
/// * [String] assetId:
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] favorite:
/// Filter to only albums favorited by the authenticated user
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? shared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, );
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? favorite, bool? shared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, favorite: favorite, shared: shared, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -24,6 +24,7 @@ class AlbumResponseDto {
required this.hasSharedLink,
required this.id,
required this.isActivityEnabled,
required this.isFavorite,
this.lastModifiedAssetTimestamp,
this.order,
required this.shared,
@@ -72,6 +73,9 @@ class AlbumResponseDto {
/// Activity feed enabled
bool isActivityEnabled;
/// Whether the authenticated user has favorited this album
bool isFavorite;
/// Last modified asset timestamp
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -117,6 +121,7 @@ class AlbumResponseDto {
other.hasSharedLink == hasSharedLink &&
other.id == id &&
other.isActivityEnabled == isActivityEnabled &&
other.isFavorite == isFavorite &&
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
other.order == order &&
other.shared == shared &&
@@ -137,6 +142,7 @@ class AlbumResponseDto {
(hasSharedLink.hashCode) +
(id.hashCode) +
(isActivityEnabled.hashCode) +
(isFavorite.hashCode) +
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
(order == null ? 0 : order!.hashCode) +
(shared.hashCode) +
@@ -144,7 +150,7 @@ class AlbumResponseDto {
(updatedAt.hashCode);
@override
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]';
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, isFavorite=$isFavorite, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -167,6 +173,7 @@ class AlbumResponseDto {
json[r'hasSharedLink'] = this.hasSharedLink;
json[r'id'] = this.id;
json[r'isActivityEnabled'] = this.isActivityEnabled;
json[r'isFavorite'] = this.isFavorite;
if (this.lastModifiedAssetTimestamp != null) {
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
} else {
@@ -207,6 +214,7 @@ class AlbumResponseDto {
hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!,
id: mapValueOfType<String>(json, r'id')!,
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
order: AssetOrder.fromJson(json[r'order']),
shared: mapValueOfType<bool>(json, r'shared')!,
@@ -268,6 +276,7 @@ class AlbumResponseDto {
'hasSharedLink',
'id',
'isActivityEnabled',
'isFavorite',
'shared',
'updatedAt',
};

View File

@@ -14,6 +14,7 @@ class SyncAlbumUserV1 {
/// Returns a new [SyncAlbumUserV1] instance.
SyncAlbumUserV1({
required this.albumId,
required this.isFavorite,
required this.role,
required this.userId,
});
@@ -21,6 +22,9 @@ class SyncAlbumUserV1 {
/// Album ID
String albumId;
/// Favorite flag
bool isFavorite;
AlbumUserRole role;
/// User ID
@@ -29,6 +33,7 @@ class SyncAlbumUserV1 {
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserV1 &&
other.albumId == albumId &&
other.isFavorite == isFavorite &&
other.role == role &&
other.userId == userId;
@@ -36,15 +41,17 @@ class SyncAlbumUserV1 {
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumId.hashCode) +
(isFavorite.hashCode) +
(role.hashCode) +
(userId.hashCode);
@override
String toString() => 'SyncAlbumUserV1[albumId=$albumId, role=$role, userId=$userId]';
String toString() => 'SyncAlbumUserV1[albumId=$albumId, isFavorite=$isFavorite, role=$role, userId=$userId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumId'] = this.albumId;
json[r'isFavorite'] = this.isFavorite;
json[r'role'] = this.role;
json[r'userId'] = this.userId;
return json;
@@ -60,6 +67,7 @@ class SyncAlbumUserV1 {
return SyncAlbumUserV1(
albumId: mapValueOfType<String>(json, r'albumId')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
role: AlbumUserRole.fromJson(json[r'role'])!,
userId: mapValueOfType<String>(json, r'userId')!,
);
@@ -110,6 +118,7 @@ class SyncAlbumUserV1 {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'albumId',
'isFavorite',
'role',
'userId',
};

View File

@@ -13,26 +13,53 @@ part of openapi.api;
class UpdateAlbumUserDto {
/// Returns a new [UpdateAlbumUserDto] instance.
UpdateAlbumUserDto({
required this.role,
this.isFavorite,
this.role,
});
AlbumUserRole role;
/// Mark album as favorite for the user (only the user themselves can update)
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AlbumUserRole? role;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumUserDto &&
other.isFavorite == isFavorite &&
other.role == role;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(role.hashCode);
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(role == null ? 0 : role!.hashCode);
@override
String toString() => 'UpdateAlbumUserDto[role=$role]';
String toString() => 'UpdateAlbumUserDto[isFavorite=$isFavorite, role=$role]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.role != null) {
json[r'role'] = this.role;
} else {
// json[r'role'] = null;
}
return json;
}
@@ -45,7 +72,8 @@ class UpdateAlbumUserDto {
final json = value.cast<String, dynamic>();
return UpdateAlbumUserDto(
role: AlbumUserRole.fromJson(json[r'role'])!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
role: AlbumUserRole.fromJson(json[r'role']),
);
}
return null;
@@ -93,7 +121,6 @@ class UpdateAlbumUserDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'role',
};
}

View File

@@ -1648,6 +1648,15 @@
"type": "string"
}
},
{
"name": "favorite",
"required": false,
"in": "query",
"description": "Filter to only albums favorited by the authenticated user",
"schema": {
"type": "boolean"
}
},
{
"name": "shared",
"required": false,
@@ -15324,6 +15333,10 @@
"description": "Activity feed enabled",
"type": "boolean"
},
"isFavorite": {
"description": "Whether the authenticated user has favorited this album",
"type": "boolean"
},
"lastModifiedAssetTimestamp": {
"description": "Last modified asset timestamp",
"format": "date-time",
@@ -15357,6 +15370,7 @@
"hasSharedLink",
"id",
"isActivityEnabled",
"isFavorite",
"shared",
"updatedAt"
],
@@ -22452,6 +22466,10 @@
"description": "Album ID",
"type": "string"
},
"isFavorite": {
"description": "Favorite flag",
"type": "boolean"
},
"role": {
"$ref": "#/components/schemas/AlbumUserRole"
},
@@ -22462,6 +22480,7 @@
},
"required": [
"albumId",
"isFavorite",
"role",
"userId"
],
@@ -25202,13 +25221,14 @@
},
"UpdateAlbumUserDto": {
"properties": {
"isFavorite": {
"description": "Mark album as favorite for the user (only the user themselves can update)",
"type": "boolean"
},
"role": {
"$ref": "#/components/schemas/AlbumUserRole"
}
},
"required": [
"role"
],
"type": "object"
},
"UpdateAssetDto": {

View File

@@ -474,6 +474,8 @@ export type AlbumResponseDto = {
id: string;
/** Activity feed enabled */
isActivityEnabled: boolean;
/** Whether the authenticated user has favorited this album */
isFavorite: boolean;
/** Last modified asset timestamp */
lastModifiedAssetTimestamp?: string;
order?: AssetOrder;
@@ -556,7 +558,9 @@ export type MapMarkerResponseDto = {
state: string | null;
};
export type UpdateAlbumUserDto = {
role: AlbumUserRole;
/** Mark album as favorite for the user (only the user themselves can update) */
isFavorite?: boolean;
role?: AlbumUserRole;
};
export type AlbumUserAddDto = {
/** Album user role */
@@ -2856,6 +2860,8 @@ export type SyncAlbumUserDeleteV1 = {
export type SyncAlbumUserV1 = {
/** Album ID */
albumId: string;
/** Favorite flag */
isFavorite: boolean;
role: AlbumUserRole;
/** User ID */
userId: string;
@@ -3619,8 +3625,9 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/**
* List all albums
*/
export function getAllAlbums({ assetId, shared }: {
export function getAllAlbums({ assetId, favorite, shared }: {
assetId?: string;
favorite?: boolean;
shared?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
@@ -3628,6 +3635,7 @@ export function getAllAlbums({ assetId, shared }: {
data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({
assetId,
favorite,
shared
}))}`, {
...opts

10
pnpm-lock.yaml generated
View File

@@ -570,8 +570,8 @@ importers:
specifier: ^2.0.0
version: 2.0.9
uuid:
specifier: ^11.1.0
version: 11.1.0
specifier: ^14.0.0
version: 14.0.0
validator:
specifier: ^13.12.0
version: 13.15.35
@@ -12110,6 +12110,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
@@ -25779,6 +25783,8 @@ snapshots:
uuid@11.1.0: {}
uuid@14.0.0: {}
uuid@8.3.2: {}
validator@13.15.35: {}

View File

@@ -114,7 +114,7 @@
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"uuid": "^14.0.0",
"validator": "^13.12.0",
"zod": "^4.3.6"
},

View File

@@ -35,6 +35,7 @@ export type AuthUser = {
export type AlbumUser = {
user: ShallowDehydrateObject<User>;
role: AlbumUserRole;
isFavorite: boolean;
};
export type AssetFile = {
@@ -395,7 +396,12 @@ export const columns = {
'asset.height',
'asset.isEdited',
],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncAlbumUser: [
'album_user.albumId as albumId',
'album_user.userId as userId',
'album_user.role',
'album_user.isFavorite',
],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],

View File

@@ -69,6 +69,7 @@ const GetAlbumsSchema = z
.optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'),
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'),
favorite: stringToBool.optional().describe('Filter to only albums favorited by the authenticated user'),
})
.meta({ id: 'GetAlbumsDto' });
@@ -82,7 +83,11 @@ const AlbumStatisticsResponseSchema = z
const UpdateAlbumUserSchema = z
.object({
role: AlbumUserRoleSchema,
role: AlbumUserRoleSchema.optional(),
isFavorite: z
.boolean()
.optional()
.describe('Mark album as favorite for the user (only the user themselves can update)'),
})
.meta({ id: 'UpdateAlbumUserDto' });
@@ -118,6 +123,7 @@ export const AlbumResponseSchema = z
'First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.',
),
hasSharedLink: z.boolean().describe('Has shared link'),
isFavorite: z.boolean().describe('Whether the authenticated user has favorited this album'),
assetCount: z.int().min(0).describe('Number of assets'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
lastModifiedAssetTimestamp: z
@@ -161,8 +167,9 @@ export type MapAlbumDto = {
order: AssetOrder;
};
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => {
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>, authUserId?: string): AlbumResponseDto => {
const albumUsers: AlbumUserResponseDto[] = [];
let isFavorite = false;
if (entity.albumUsers) {
for (const albumUser of entity.albumUsers) {
@@ -171,6 +178,9 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
user,
role: albumUser.role,
});
if (authUserId && user.id === authUserId) {
isFavorite = albumUser.isFavorite ?? false;
}
}
}
@@ -196,6 +206,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
albumUsers,
shared: hasSharedUser || hasSharedLink,
hasSharedLink,
isFavorite,
startDate: asDateString(startDate),
endDate: asDateString(endDate),
assetCount: entity.assets?.length || 0,

View File

@@ -195,6 +195,7 @@ const SyncAlbumUserV1Schema = z
albumId: z.string().describe('Album ID'),
userId: z.string().describe('User ID'),
role: AlbumUserRoleSchema,
isFavorite: z.boolean().describe('Favorite flag'),
})
.meta({ id: 'SyncAlbumUserV1' });

View File

@@ -19,6 +19,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
@@ -98,6 +99,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
@@ -195,6 +197,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
@@ -258,6 +261,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
@@ -334,6 +338,70 @@ where
order by
"album"."createdAt" desc
-- AlbumRepository.getFavorites
select
"album".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"shared_link".*
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
) as agg
) as "sharedLinks"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
and "album_user"."isFavorite" = true
where
"album"."deletedAt" is null
order by
"album"."createdAt" desc
-- AlbumRepository.getNotShared
select
"album".*,
@@ -357,6 +425,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
@@ -461,7 +530,8 @@ with
returning
"album_user"."albumId",
"album_user"."userId",
"album_user"."role"
"album_user"."role",
"album_user"."isFavorite"
),
"album_asset" as (
insert into
@@ -485,6 +555,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)

View File

@@ -331,6 +331,7 @@ select
"album_user"."albumId" as "albumId",
"album_user"."userId" as "userId",
"album_user"."role",
"album_user"."isFavorite",
"album_user"."updateId"
from
"album_user" as "album_user"
@@ -368,6 +369,7 @@ select
"album_user"."albumId" as "albumId",
"album_user"."userId" as "userId",
"album_user"."role",
"album_user"."isFavorite",
"album_user"."updateId"
from
"album_user" as "album_user"

View File

@@ -39,6 +39,7 @@ const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'albu
.innerJoin('user', 'user.id', 'album_user.userId')
.whereRef('album_user.albumId', '=', 'album.id')
.select('album_user.role')
.select('album_user.isFavorite')
.select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user'))
.orderBy('album_user.role')
.$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc'))
@@ -244,6 +245,27 @@ export class AlbumRepository {
.execute();
}
/**
* Get albums the user has favorited (owned or shared).
*/
@GenerateSql({ params: [DummyValue.UUID] })
getFavorites(userId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', userId)
.on('album_user.isFavorite', '=', sql.lit(true)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(userId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
/**
* Get albums of owner that are _not_ shared
*/
@@ -380,7 +402,7 @@ export class AlbumRepository {
sql`unnest(${roles}::album_user_role_enum[])`.as('role'),
]),
)
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role']),
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role', 'album_user.isFavorite']),
)
.with('album_asset', (db) =>
db

View File

@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" ADD "isFavorite" boolean NOT NULL DEFAULT false;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" DROP COLUMN "isFavorite";`.execute(db);
}

View File

@@ -58,6 +58,9 @@ export class AlbumUserTable {
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>;
@Column({ type: 'boolean', default: false })
isFavorite!: Generated<boolean>;
@CreateIdColumn({ index: true })
createId!: Generated<string>;

View File

@@ -38,12 +38,17 @@ export class AlbumService extends BaseService {
};
}
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, shared, favorite }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails();
let albums: MapAlbumDto[];
if (assetId) {
albums = await this.albumRepository.getByAssetId(ownerId, assetId);
} else if (favorite === true) {
albums = await this.albumRepository.getFavorites(ownerId);
} else if (shared === true) {
albums = await this.albumRepository.getShared(ownerId);
} else if (shared === false) {
@@ -61,7 +66,7 @@ export class AlbumService extends BaseService {
}
return albums.map((album) => ({
...mapAlbum(album),
...mapAlbum(album, ownerId),
sharedLinks: undefined,
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
@@ -82,7 +87,7 @@ export class AlbumService extends BaseService {
const isShared = hasSharedUsers || hasSharedLink;
return {
...mapAlbum(album),
...mapAlbum(album, auth.user.id),
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
assetCount: albumMetadataForIds?.assetCount ?? 0,
@@ -141,7 +146,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
}
return mapAlbum(album);
return mapAlbum(album, auth.user.id);
}
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
@@ -168,7 +173,7 @@ export class AlbumService extends BaseService {
auth.user.id,
);
return mapAlbum({ ...updatedAlbum, assets: album.assets });
return mapAlbum({ ...updatedAlbum, assets: album.assets }, auth.user.id);
}
async delete(auth: AuthDto, id: string): Promise<void> {
@@ -310,7 +315,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
return this.findOrFail(id, auth.user.id, { withAssets: true }).then(mapAlbum);
return this.findOrFail(id, auth.user.id, { withAssets: true }).then((album) => mapAlbum(album, auth.user.id));
}
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@@ -341,8 +346,22 @@ export class AlbumService extends BaseService {
}
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
if (dto.role === undefined && dto.isFavorite === undefined) {
throw new BadRequestException('No updates provided');
}
if (dto.role !== undefined) {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
}
if (dto.isFavorite !== undefined) {
if (userId !== auth.user.id) {
throw new BadRequestException('Cannot favorite an album on behalf of another user');
}
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
}
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role, isFavorite: dto.isFavorite });
}
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {

View File

@@ -24,6 +24,7 @@ export class AlbumUserFactory {
albumId: newUuid(),
userId: newUuid(),
role: AlbumUserRole.Editor,
isFavorite: false,
createId: newUuidV7(),
createdAt: newDate(),
updateId: newUuidV7(),

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { toggleAlbumFavorite } from '$lib/services/album.service';
import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
import { getShortDateRange } from '$lib/utils/date-time';
import { type AlbumResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { Icon, IconButton } from '@immich/ui';
import { mdiDotsVertical, mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -26,37 +27,70 @@
onShowContextMenu = undefined,
}: Props = $props();
let togglingFavorite = $state(false);
const showAlbumContextMenu = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onShowContextMenu?.(getContextMenuPositionFromEvent(e));
};
const onToggleFavorite = async (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (togglingFavorite) {
return;
}
togglingFavorite = true;
try {
await toggleAlbumFavorite(album);
} finally {
togglingFavorite = false;
}
};
</script>
<div
class="group relative rounded-2xl border border-transparent p-5 hover:bg-gray-100 hover:border-gray-200 dark:hover:border-gray-800 dark:hover:bg-gray-900"
data-testid="album-card"
>
{#if onShowContextMenu}
<div
id="icon-{album.id}"
class="absolute end-6 top-6 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
data-testid="context-button-parent"
<div class="relative">
<AlbumCover {album} {preload} class="transition-all duration-300 hover:shadow-lg" />
{#if onShowContextMenu}
<div
id="icon-{album.id}"
class="absolute inset-e-3 top-3 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
data-testid="context-button-parent"
>
<IconButton
color="secondary"
aria-label={$t('show_album_options')}
icon={mdiDotsVertical}
shape="round"
variant="filled"
size="medium"
class="icon-white-drop-shadow"
onclick={showAlbumContextMenu}
/>
</div>
{/if}
<button
type="button"
class="absolute inset-e-3 bottom-3 inline-flex items-center justify-center rounded-full p-2 bg-white/50 dark:bg-gray-900/90 backdrop-blur-md shadow-lg ring-1 ring-black/5 transition-all duration-150 hover:scale-110 active:scale-95 disabled:opacity-50 {album.isFavorite
? 'text-red-500 dark:text-red-400'
: 'text-gray-500 dark:text-gray-300 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-red-500 dark:hover:text-red-400'}"
aria-label={$t(album.isFavorite ? 'unfavorite' : 'favorite')}
aria-pressed={album.isFavorite}
disabled={togglingFavorite}
onclick={onToggleFavorite}
>
<IconButton
color="secondary"
aria-label={$t('show_album_options')}
icon={mdiDotsVertical}
shape="round"
variant="filled"
size="medium"
class="icon-white-drop-shadow"
onclick={showAlbumContextMenu}
<Icon
icon={album.isFavorite ? mdiHeart : mdiHeartOutline}
size="1.125em"
class={album.isFavorite ? 'drop-shadow-[0_1px_2px_rgba(239,68,68,0.5)]' : ''}
/>
</div>
{/if}
<AlbumCover {album} {preload} class="transition-all duration-300 hover:shadow-lg" />
</button>
</div>
<div class="mt-4">
<p

View File

@@ -131,6 +131,15 @@
case AlbumFilter.Shared: {
return sharedAlbums;
}
case AlbumFilter.Favorites: {
const nonOwnedFavorites = sharedAlbums.filter(
(album) =>
album.isFavorite &&
album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role !== AlbumUserRole.Owner,
);
const ownedFavorites = ownedAlbums.filter((album) => album.isFavorite);
return nonOwnedFavorites.length > 0 ? ownedFavorites.concat(nonOwnedFavorites) : ownedFavorites;
}
default: {
const nonOwnedAlbums = sharedAlbums.filter(
(album) =>

View File

@@ -1,5 +1,5 @@
import '@testing-library/jest-dom';
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
import { waitFor, type RenderResult } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { init, register, waitLocale } from 'svelte-i18n';
import { sdkMock } from '$lib/__mocks__/sdk.mock';
@@ -40,7 +40,7 @@ describe('AlbumCard component', () => {
shared: true,
},
])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => {
sut = render(AlbumCard, { album, showItemCount: true });
sut = renderWithTooltips(AlbumCard, { album, showItemCount: true });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
@@ -64,7 +64,7 @@ describe('AlbumCard component', () => {
shared: false,
albumName: 'some album name',
});
sut = render(AlbumCard, { album, showItemCount: true });
sut = renderWithTooltips(AlbumCard, { album, showItemCount: true });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
@@ -79,7 +79,7 @@ describe('AlbumCard component', () => {
it('hides context menu when "onShowContextMenu" is undefined', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
sut = render(AlbumCard, { album });
sut = renderWithTooltips(AlbumCard, { album });
const contextButtonParent = sut.queryByTestId('context-button-parent');
expect(contextButtonParent).not.toBeInTheDocument();

View File

@@ -173,6 +173,24 @@ export const handleUpdateUserAlbumRole = async ({
}
};
export const toggleAlbumFavorite = async (album: AlbumResponseDto): Promise<AlbumResponseDto | undefined> => {
const $t = await getFormatter();
const next = !album.isFavorite;
try {
await updateAlbumUser({
id: album.id,
userId: authManager.user.id,
updateAlbumUserDto: { isFavorite: next },
});
const updated = { ...album, isFavorite: next };
eventManager.emit('AlbumUpdate', updated);
return updated;
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_info'));
}
};
export const handleAddUsersToAlbum = async (album: AlbumResponseDto, users: UserResponseDto[]) => {
const $t = await getFormatter();

View File

@@ -94,6 +94,7 @@ export enum AlbumFilter {
All = 'All',
Owned = 'Owned',
Shared = 'Shared',
Favorites = 'Favorites',
}
export enum AlbumGroupBy {

View File

@@ -89,6 +89,7 @@
[AlbumFilter.All]: $t('all'),
[AlbumFilter.Owned]: $t('owned'),
[AlbumFilter.Shared]: $t('shared'),
[AlbumFilter.Favorites]: $t('favorites'),
});
let selectedFilterOption = $derived(albumFilterNames[findFilterOption($albumViewSettings.filter)]);

View File

@@ -44,6 +44,7 @@
getAlbumAssetsActions,
handleDeleteAlbum,
handleDownloadAlbum,
toggleAlbumFavorite,
} from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetBulkActions } from '$lib/services/asset.service';
@@ -68,6 +69,8 @@
mdiDeleteOutline,
mdiDotsVertical,
mdiDownload,
mdiHeart,
mdiHeartOutline,
mdiImageOutline,
mdiImagePlusOutline,
mdiLink,
@@ -500,6 +503,20 @@
{#snippet trailing()}
<ActionButton action={Cast} />
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={album.isFavorite ? $t('remove_from_favorites') : $t('to_favorite')}
icon={album.isFavorite ? mdiHeart : mdiHeartOutline}
onclick={async () => {
const updated = await toggleAlbumFavorite(album);
if (updated) {
album = updated;
}
}}
/>
{#if isEditor}
<IconButton
variant="ghost"

View File

@@ -14,5 +14,6 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
albumUsers: [],
hasSharedLink: false,
isActivityEnabled: true,
isFavorite: false,
order: AssetOrder.Desc,
});