mirror of
https://github.com/immich-app/immich.git
synced 2026-04-24 18:19:51 -04:00
feat(server,web): favorite albums per user
This commit is contained in:
parent
f0835d06f8
commit
166f36e5bf
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' });
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -24,6 +24,7 @@ export class AlbumUserFactory {
|
||||
albumId: newUuid(),
|
||||
userId: newUuid(),
|
||||
role: AlbumUserRole.Editor,
|
||||
isFavorite: false,
|
||||
createId: newUuidV7(),
|
||||
createdAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
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 } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@ -60,11 +60,14 @@
|
||||
|
||||
<div class="mt-4">
|
||||
<p
|
||||
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-primary"
|
||||
class="flex items-center gap-1 w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-primary"
|
||||
data-testid="album-name"
|
||||
title={album.albumName}
|
||||
>
|
||||
{album.albumName}
|
||||
{#if album.isFavorite}
|
||||
<Icon icon={mdiHeart} size="1em" aria-label={$t('favorite')} class="text-primary shrink-0" />
|
||||
{/if}
|
||||
<span class="line-clamp-2">{album.albumName}</span>
|
||||
</p>
|
||||
|
||||
{#if showDateRange && album.startDate && album.endDate}
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -94,6 +94,7 @@ export enum AlbumFilter {
|
||||
All = 'All',
|
||||
Owned = 'Owned',
|
||||
Shared = 'Shared',
|
||||
Favorites = 'Favorites',
|
||||
}
|
||||
|
||||
export enum AlbumGroupBy {
|
||||
|
||||
@ -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)]);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -14,5 +14,6 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
|
||||
albumUsers: [],
|
||||
hasSharedLink: false,
|
||||
isActivityEnabled: true,
|
||||
isFavorite: false,
|
||||
order: AssetOrder.Desc,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user