Compare commits

..

8 Commits

Author SHA1 Message Date
CJPeckover
6e0005acfd - add toggle to show/hide asset owner names 2025-08-25 19:55:25 -04:00
CJPeckover
55a196bfa0 Merge branch 'immich-main' into feat/shared-album-owner-labels 2025-08-25 18:27:51 -04:00
CJPeckover
d0b49846dc format 2025-08-23 01:12:37 -04:00
CJPeckover
8896b2dbf5 fix lint 2025-08-23 01:11:10 -04:00
CJPeckover
251e644b2a - cleanup albumUsers creation
- use font-light for the user's name
2025-08-23 01:09:51 -04:00
CJPeckover
a02635f9a5 cleanup 2025-08-23 00:57:50 -04:00
CJPeckover
104f3dfcc3 - change owner to their name in white text instead of the avatar 2025-08-23 00:54:50 -04:00
CJPeckover
b7e3b48a44 - pass available album users along to the thumbnail through the asset-date-group
- show a small user-avatar in bottom right of thumbnail
2025-08-22 17:29:03 -04:00
25 changed files with 115 additions and 256 deletions

View File

@@ -1,31 +1,5 @@
# FAQ
## Commercial Guidelines
### Are you open to commercial partnerships and collaborations?
We are working to commercialize Immich and we'd love for you to help us by making Immich better. FUTO is dedicated to developing sustainable models for developing open source software for our customers. We want our customers to be delighted by the products our engineers deliver, and we want our engineers to be paid when they succeed.
If you wish to use Immich in a commercial product not owned by FUTO, we have the following requirements:
- Plugin Integrations: Integrations for other platforms are typically approved, provided proper notification is given.
- Reseller Partnerships: Must adhere to the guidelines outlined below regarding trademark usage, and proper representation.
- Strategic Collaborations: We welcome discussions about mutually beneficial partnerships that enhance the value proposition for both organizations.
### What are your guidelines for resellers and trademark usage?
For organizations seeking to resell Immich, we have established the following guidelines to protect our brand integrity and ensure proper representation.
- We request that resellers do not display our trademarks on their websites or marketing materials. If such usage is discovered, we will contact you to request removal.
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directy from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
## User
### How can I reset the admin password?

View File

@@ -38,7 +38,7 @@ services:
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:7a4469b9484e37bf2630a60bc2f02f086dae898143b599ecc1c93f619849ef6b
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:0e763a2383d56f90364fcd72767ac41400cd30d2627f407f7e7960c9f1923c21
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres

View File

@@ -1941,9 +1941,7 @@
"to_change_password": "Change password",
"to_favorite": "Favorite",
"to_login": "Login",
"to_multi_select": "to multi-select",
"to_parent": "Go to parent",
"to_select": "to select",
"to_trash": "Trash",
"toggle_settings": "Toggle settings",
"total": "Total",

View File

@@ -75,8 +75,6 @@ profileChangedAt: $profileChangedAt
bool? isPartnerSharedWith,
bool? hasProfileImage,
DateTime? profileChangedAt,
int? quotaSizeInBytes,
int? quotaUsageInBytes,
}) => UserDto(
id: id ?? this.id,
email: email ?? this.email,
@@ -90,8 +88,6 @@ profileChangedAt: $profileChangedAt
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
hasProfileImage: hasProfileImage ?? this.hasProfileImage,
profileChangedAt: profileChangedAt ?? this.profileChangedAt,
quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes,
quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes,
);
@override
@@ -109,9 +105,7 @@ profileChangedAt: $profileChangedAt
other.memoryEnabled == memoryEnabled &&
other.inTimeline == inTimeline &&
other.hasProfileImage == hasProfileImage &&
other.profileChangedAt.isAtSameMomentAs(profileChangedAt) &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes;
other.profileChangedAt.isAtSameMomentAs(profileChangedAt);
}
@override
@@ -127,9 +121,7 @@ profileChangedAt: $profileChangedAt
isPartnerSharedBy.hashCode ^
isPartnerSharedWith.hashCode ^
hasProfileImage.hashCode ^
profileChangedAt.hashCode ^
quotaSizeInBytes.hashCode ^
quotaUsageInBytes.hashCode;
profileChangedAt.hashCode;
}
class PartnerUserDto {

View File

@@ -54,8 +54,6 @@ class User {
avatarColor: dto.avatarColor,
memoryEnabled: dto.memoryEnabled,
inTimeline: dto.inTimeline,
quotaUsageInBytes: dto.quotaUsageInBytes,
quotaSizeInBytes: dto.quotaSizeInBytes,
);
UserDto toDto() => UserDto(

View File

@@ -65,53 +65,40 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final cacheManager = this.cacheManager;
final streamController = StreamController<List<int>>(sync: true);
final Stream<List<int>> stream;
cacheManager?.putStreamedFile(url, streamController.stream);
stream = response.map((chunk) {
if (_isCancelled) {
throw StateError('Cancelled request');
}
if (cacheManager != null) {
streamController.add(chunk);
}
return chunk;
});
try {
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
streamController.close();
return await ImmutableBuffer.fromUint8List(bytes);
} catch (e) {
streamController.addError(e);
streamController.close();
if (_isCancelled) {
return null;
}
rethrow;
}
}
Future<Uint8List> _downloadBytes(Stream<List<int>> stream, int length) async {
// Handle unknown content length from reverse proxy
final contentLength = response.contentLength;
final Uint8List bytes;
int offset = 0;
if (length > 0) {
if (contentLength >= 0) {
// Known content length - use pre-allocated buffer
bytes = Uint8List(length);
await stream.listen((chunk) {
bytes = Uint8List(contentLength);
final subscription = response.listen((List<int> chunk) {
// this is important to break the response stream if the request is cancelled
if (_isCancelled) {
throw StateError('Cancelled request');
}
bytes.setAll(offset, chunk);
offset += chunk.length;
}, cancelOnError: true).asFuture();
}, cancelOnError: true);
cacheManager?.putStreamedFile(url, response);
await subscription.asFuture();
} else {
// Unknown content length - collect chunks dynamically
final chunks = <List<int>>[];
int totalLength = 0;
await stream.listen((chunk) {
final subscription = response.listen((List<int> chunk) {
// this is important to break the response stream if the request is cancelled
if (_isCancelled) {
throw StateError('Cancelled request');
}
chunks.add(chunk);
totalLength += chunk.length;
}, cancelOnError: true).asFuture();
}, cancelOnError: true);
cacheManager?.putStreamedFile(url, response);
await subscription.asFuture();
// Combine all chunks into a single buffer
bytes = Uint8List(totalLength);
for (final chunk in chunks) {
bytes.setAll(offset, chunk);
@@ -119,7 +106,7 @@ class RemoteImageRequest extends ImageRequest {
}
}
return bytes;
return await ImmutableBuffer.fromUint8List(bytes);
}
Future<ImageInfo?> _loadCachedFile(

View File

@@ -29,8 +29,6 @@ abstract final class UserConverter {
isPartnerSharedWith: false,
profileChangedAt: adminDto.profileChangedAt,
hasProfileImage: adminDto.profileImagePath.isNotEmpty,
quotaSizeInBytes: adminDto.quotaSizeInBytes ?? 0,
quotaUsageInBytes: adminDto.quotaUsageInBytes ?? 0,
);
static UserDto fromPartnerDto(PartnerResponseDto dto) => UserDto(

View File

@@ -94,7 +94,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
imageInfo.dispose();
return;
}
_fadeController.value = 1.0;
setState(() {
_providerImage = imageInfo.image;
});
@@ -115,7 +115,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
final imageStreamListener = _imageStreamListener = ImageStreamListener(
(ImageInfo imageInfo, bool synchronousCall) {
_stopListeningToThumbhashStream();
_stopListeningToStream();
if (!mounted) {
imageInfo.dispose();
return;
@@ -125,7 +125,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
return;
}
if ((synchronousCall && _providerImage == null) || !_isVisible()) {
if (synchronousCall && _providerImage == null) {
_fadeController.value = 1.0;
} else if (_fadeController.isAnimating) {
_fadeController.forward();
@@ -201,15 +201,6 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
_loadFromThumbhashProvider();
}
bool _isVisible() {
final renderObject = context.findRenderObject() as RenderBox?;
if (renderObject == null || !renderObject.attached) return false;
final topLeft = renderObject.localToGlobal(Offset.zero);
final bottomRight = renderObject.localToGlobal(Offset(renderObject.size.width, renderObject.size.height));
return topLeft.dy < context.height && bottomRight.dy > 0;
}
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;

View File

@@ -57,10 +57,7 @@ class TimelineHeader extends StatelessWidget {
if (isMonthHeader)
Row(
children: [
Text(
toBeginningOfSentenceCase(_formatMonth(context, date)),
style: context.textTheme.labelLarge?.copyWith(fontSize: 24),
),
Text(_formatMonth(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 24)),
const Spacer(),
if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
],
@@ -68,10 +65,7 @@ class TimelineHeader extends StatelessWidget {
if (isDayHeader)
Row(
children: [
Text(
toBeginningOfSentenceCase(_formatDay(context, date)),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
Text(_formatDay(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 15)),
const Spacer(),
_BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
],

View File

@@ -38,21 +38,9 @@ abstract class RemoteCacheManager extends CacheManager {
final file = await store.fileSystem.createFile(path);
final sink = file.openWrite();
try {
await source.listen(sink.add, cancelOnError: true).asFuture();
await source.pipe(sink);
} catch (e) {
try {
await sink.close();
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
try {
await sink.flush();
await sink.close();
} catch (e) {
try {
await file.delete();
} catch (e) {

View File

@@ -31,7 +31,7 @@ class _MapThemeOverrideState extends ConsumerState<MapThemeOverride> with Widget
@override
void initState() {
super.initState();
_theme = widget.themeMode ?? ref.read(immichThemeModeProvider);
_theme = widget.themeMode ?? ref.read(mapStateNotifierProvider.select((v) => v.themeMode));
setState(() {
_isDarkTheme = checkDarkTheme();
});
@@ -65,7 +65,7 @@ class _MapThemeOverrideState extends ConsumerState<MapThemeOverride> with Widget
@override
Widget build(BuildContext context) {
_theme = widget.themeMode ?? ref.watch(immichThemeModeProvider);
_theme = widget.themeMode ?? ref.watch(mapStateNotifierProvider.select((v) => v.themeMode));
var appTheme = ref.watch(immichThemeProvider);
final locale = ref.watch(localeProvider);

View File

@@ -1,79 +0,0 @@
import { UserAdminController } from 'src/controllers/user-admin.controller';
import { UserAdminCreateDto } from 'src/dtos/user.dto';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { UserAdminService } from 'src/services/user-admin.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(UserAdminController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(UserAdminService);
beforeAll(async () => {
ctx = await controllerSetup(UserAdminController, [
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
{ provide: UserAdminService, useValue: service },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /admin/users', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/admin/users');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /admin/users', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/admin/users');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should not allow decimal quota`, async () => {
const dto: UserAdminCreateDto = {
email: 'user@immich.app',
password: 'test',
name: 'Test User',
quotaSizeInBytes: 1.2,
};
const { status, body } = await request(ctx.getHttpServer())
.post(`/admin/users`)
.set('Authorization', `Bearer token`)
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
});
});
describe('GET /admin/users/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PUT /admin/users/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should not allow decimal quota`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/admin/users/${factory.uuid()}`)
.set('Authorization', `Bearer token`)
.send({ quotaSizeInBytes: 1.2 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
});
});
});

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
import { IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types';
@@ -91,7 +91,7 @@ export class UserAdminCreateDto {
storageLabel?: string | null;
@Optional({ nullable: true })
@IsInt()
@IsNumber()
@Min(0)
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
@@ -137,7 +137,7 @@ export class UserAdminUpdateDto {
shouldChangePassword?: boolean;
@Optional({ nullable: true })
@IsInt()
@IsNumber()
@Min(0)
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely';
import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
@@ -68,6 +68,12 @@ const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
).as('person');
};
const withAsset = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as(
'asset',
);
};
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom(
eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_face.id'),
@@ -475,12 +481,7 @@ export class PersonRepository {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select((eb) =>
jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as(
'asset',
),
)
.$narrowType<{ asset: NotNull }>()
.select(withAsset)
.select(withPerson)
.where('asset_face.assetId', 'in', assetIds)
.where('asset_face.personId', 'in', personIds)

View File

@@ -42,6 +42,7 @@ describe(MediaService.name, () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image]));
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]);
await sut.handleQueueGenerateThumbnails({ force: true });

View File

@@ -197,10 +197,6 @@ export class PersonService extends BaseService {
throw new BadRequestException('Invalid assetId for feature face');
}
if (face.asset.isOffline) {
throw new BadRequestException('An offline asset cannot be used for feature face');
}
faceId = face.id;
}

View File

@@ -7,10 +7,12 @@
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { AssetMediaSize } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import type { SwipeCustomEvent } from 'svelte-gestures';
import { swipe } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
@@ -38,6 +40,7 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $state('');
let forceMuted = $state(false);
let isScrubbing = $state(false);
let showVideo = $state(false);
@@ -46,6 +49,7 @@
showVideo = true;
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
if (videoPlayer) {
forceMuted = false;
videoPlayer.load();
}
});
@@ -63,27 +67,23 @@
onVideoStarted();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) {
await tryForceMutedPlay(video);
return;
}
// auto-play failed
handleError(error, $t('errors.unable_to_play_video'));
} finally {
isLoading = false;
}
};
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
if (video.muted) {
return;
}
try {
video.muted = true;
await handleCanPlay(video);
} catch {
// muted auto-play failed
} catch (error) {
handleError(error, $t('errors.unable_to_play_video'));
}
};
@@ -134,14 +134,18 @@
onswipe={onSwipe}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onvolumechange={(e) => {
if (!forceMuted) {
$videoViewerMuted = e.currentTarget.muted;
}
}}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
muted={forceMuted || $videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}

View File

@@ -5,7 +5,7 @@
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetVisibility } from '@immich/sdk';
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
import {
mdiArchiveArrowDownOutline,
mdiCameraBurst,
@@ -45,6 +45,7 @@
imageClass?: ClassValue;
brokenAssetClass?: ClassValue;
dimmed?: boolean;
albumUsers?: UserResponseDto[];
onClick?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
@@ -63,6 +64,7 @@
readonly = false,
showArchiveIcon = false,
showStackedIcon = true,
albumUsers = [],
onClick = undefined,
onSelect = undefined,
onMouseEvent = undefined,
@@ -84,6 +86,8 @@
let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235);
let assetOwner = $derived(albumUsers?.find((user) => user.id === asset.ownerId) ?? null);
const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
@@ -267,6 +271,12 @@
</div>
{/if}
{#if !!assetOwner}
<div class="absolute bottom-0 end-1">
<span class="text-white font-light text-sm">{assetOwner.name}</span>
</div>
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />

View File

@@ -9,6 +9,7 @@
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { navigate } from '$lib/utils/navigation';
import type { UserResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
@@ -25,7 +26,7 @@
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
albumUsers?: UserResponseDto[];
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
@@ -40,6 +41,7 @@
monthGroup = $bindable(),
assetInteraction,
timelineManager,
albumUsers = [],
onSelect,
onSelectAssets,
onSelectAssetCandidates,
@@ -189,6 +191,7 @@
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}

View File

@@ -30,7 +30,13 @@
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import {
AssetVisibility,
getAssetInfo,
type AlbumResponseDto,
type PersonResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte';
@@ -59,6 +65,7 @@
showArchiveIcon?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
albumUsers?: UserResponseDto[];
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void;
@@ -78,6 +85,7 @@
showArchiveIcon = false,
isShared = false,
album = null,
albumUsers = [],
person = null,
isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
@@ -88,6 +96,12 @@
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore;
// const albumUsers = $derived(
// album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)
// ? [album.owner, ...album.albumUsers.map(({ user }) => user)]
// : [],
// );
let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
@@ -936,6 +950,7 @@
{isSelectionMode}
{singleSelect}
{monthGroup}
{albumUsers}
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}

View File

@@ -419,22 +419,14 @@ export class TimelineManager {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
if (monthGroup) {
return monthGroup;
}
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
if (!response) {
return;
}
const asset = toTimelineAsset(response);
const asset = toTimelineAsset(await getAssetInfo({ ...authManager.params, id }));
if (!asset || this.isExcluded(asset)) {
return;
}
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
if (monthGroup?.findAssetById({ id })) {
return monthGroup;

View File

@@ -7,8 +7,7 @@
} from '$lib/components/shared-components/album-selection/album-selection-utils';
import { albumViewSettings } from '$lib/stores/preferences.store';
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { Button, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiKeyboardReturn } from '@mdi/js';
import { Button, Modal, ModalBody } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
@@ -75,9 +74,9 @@
};
const handleMultiSubmit = () => {
const selectedAlbums = new Set(albums.filter(({ id }) => multiSelectedAlbumIds.includes(id)));
if (selectedAlbums.size > 0) {
onClose([...selectedAlbums]);
const albums = new Set(albumModalRows.filter((row) => row.multiSelected).map(({ album }) => album!));
if (albums.size > 0) {
onClose([...albums]);
} else {
onClose();
}
@@ -200,22 +199,4 @@
>
{/if}
</ModalBody>
<ModalFooter>
<div class="flex justify-around w-full">
<div class="flex gap-4">
<div class="flex gap-1 place-items-center">
<span class="bg-gray-300 dark:bg-gray-500 rounded p-1">
<Icon icon={mdiKeyboardReturn} size="1rem" />
</span>
<Text size="tiny">{$t('to_select')}</Text>
</div>
<div class="flex gap-1 place-items-center">
<span class="bg-gray-300 dark:bg-gray-500 rounded p-1">
<Text size="tiny">CTRL</Text>
</span>
<Text size="tiny">{$t('to_multi_select')}</Text>
</div>
</div>
</div>
</ModalFooter>
</Modal>

View File

@@ -122,7 +122,7 @@
</Field>
<Field label={$t('admin.quota_size_gib')}>
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" step="1" />
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" />
{#if quotaSizeWarning}
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
{/if}

View File

@@ -83,7 +83,6 @@
name="quotaSize"
placeholder={$t('unlimited')}
type="number"
step="1"
min="0"
bind:value={quotaSize}
/>

View File

@@ -71,6 +71,7 @@
} from '@immich/sdk';
import { Button, IconButton, modalManager } from '@immich/ui';
import {
mdiAccountEyeOutline,
mdiArrowLeft,
mdiCogOutline,
mdiDeleteOutline,
@@ -104,6 +105,7 @@
let isCreatingSharedAlbum = $state(false);
let isShowActivity = $state(false);
let albumOrder: AssetOrder | undefined = $state(data.album.order);
let showAlbumUsers = $state(false);
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
@@ -321,6 +323,11 @@
let album = $derived(data.album);
let albumId = $derived(album.id);
const albumUsers = $derived(
showAlbumUsers && album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)
? [album.owner, ...album.albumUsers.map(({ user }) => user)]
: [],
);
$effect(() => {
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
@@ -403,7 +410,6 @@
const handleShareLink = async () => {
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id });
if (sharedLink) {
await refreshAlbum();
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
}
};
@@ -412,7 +418,7 @@
const changed = await modalManager.show(AlbumUsersModal, { album });
if (changed) {
await refreshAlbum();
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
}
};
@@ -446,6 +452,7 @@
<AssetGrid
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
{album}
{albumUsers}
{timelineManager}
assetInteraction={currentAssetIntersection}
{isShared}
@@ -616,6 +623,15 @@
{#snippet trailing()}
<CastButton />
<IconButton
variant="ghost"
shape="round"
color="secondary"
aria-label="view asset owners"
icon={mdiAccountEyeOutline}
onclick={() => (showAlbumUsers = !showAlbumUsers)}
/>
{#if isEditor}
<IconButton
variant="ghost"