Compare commits
10
Commits
424c26c697
...
8b83c289be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b83c289be | ||
|
|
ca7766fcd6 | ||
|
|
04a7b4104f | ||
|
|
97609ec6bd | ||
|
|
43b94defd9 | ||
|
|
ffb8c84e48 | ||
|
|
85820ca45e | ||
|
|
36a6e033aa | ||
|
|
49f65aa3b0 | ||
|
|
4341a2f34c |
@@ -1240,7 +1240,6 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1}, {person2}, and {person3} on {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1}, {person2}, and {additionalCount, number} others on {date}",
|
||||
"immich_logo": "Immich Logo",
|
||||
"immich_web_interface": "Immich Web Interface",
|
||||
"import_from_json": "Import from JSON",
|
||||
"import_path": "Import path",
|
||||
"in_albums": "In {count, plural, one {# album} other {# albums}}",
|
||||
@@ -2077,7 +2076,6 @@
|
||||
"shared_link_password_description": "Require a password to access this shared link",
|
||||
"shared_links": "Shared links",
|
||||
"shared_links_description": "Share photos and videos with a link",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
|
||||
"shared_with": "Shared with",
|
||||
"shared_with_count": "Shared ({count})",
|
||||
"shared_with_me": "Shared with me",
|
||||
|
||||
@@ -28,6 +28,16 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'albums': {'defaultAssetOrder': 'desc'},
|
||||
'recentlyAdded': RecentlyAddedResponse(sidebarWeb: false).toJson(),
|
||||
},
|
||||
'PublicConfigDto': {
|
||||
'map': PublicConfigMapDto(
|
||||
enabled: true,
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
).toJson(),
|
||||
'ffmpeg': {
|
||||
'realtime': {'enabled': false},
|
||||
},
|
||||
},
|
||||
'ServerConfigDto': {
|
||||
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
'mapDarkStyleUrl': 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
@@ -45,6 +55,11 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'PeopleResponse': {'updateStrategy': 'everyone'},
|
||||
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
|
||||
'ServerVersionResponseDto': {'featureLevel': 0},
|
||||
'UserConfigDto': {
|
||||
'notifications': {
|
||||
'smtp': {'enabled': false},
|
||||
},
|
||||
},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.3.3-local.3+3030402
|
||||
version: 3.3.3-local.4+3030403
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
|
||||
@@ -6,6 +6,51 @@ import 'package:openapi/api.dart';
|
||||
|
||||
void main() {
|
||||
group('Test OpenApi Patching', () {
|
||||
test('older public config deserializes with map and video defaults', () {
|
||||
final config = PublicConfigDto.fromJson(<String, dynamic>{
|
||||
'oauth': {'autoLaunch': false, 'buttonText': 'Login', 'enabled': false},
|
||||
'passwordLogin': {'enabled': true},
|
||||
'server': {'loginPageMessage': ''},
|
||||
});
|
||||
expect(config, isNotNull);
|
||||
expect(config!.map.enabled, isTrue);
|
||||
expect(config.map.lightStyle, 'https://tiles.immich.cloud/v1/style/light.json');
|
||||
expect(config.ffmpeg.realtime.enabled, isFalse);
|
||||
});
|
||||
|
||||
test('public config keeps explicit server map and video settings', () {
|
||||
final config = PublicConfigDto.fromJson(<String, dynamic>{
|
||||
'oauth': {'autoLaunch': false, 'buttonText': 'Login', 'enabled': false},
|
||||
'passwordLogin': {'enabled': true},
|
||||
'server': {'loginPageMessage': ''},
|
||||
'map': {'enabled': false, 'lightStyle': 'https://example.com/light', 'darkStyle': 'https://example.com/dark'},
|
||||
'ffmpeg': {
|
||||
'realtime': {'enabled': true},
|
||||
},
|
||||
});
|
||||
expect(config!.map.enabled, isFalse);
|
||||
expect(config.map.lightStyle, 'https://example.com/light');
|
||||
expect(config.ffmpeg.realtime.enabled, isTrue);
|
||||
});
|
||||
|
||||
test('older user config defaults SMTP to disabled', () {
|
||||
final value = <String, dynamic>{};
|
||||
upgradeDto(value, 'UserConfigDto');
|
||||
final notifications = UserConfigNotificationsDto.fromJson(value['notifications']);
|
||||
expect(notifications!.smtp.enabled, isFalse);
|
||||
});
|
||||
|
||||
test('user config preserves an enabled SMTP setting', () {
|
||||
final value = <String, dynamic>{
|
||||
'notifications': {
|
||||
'smtp': {'enabled': true},
|
||||
},
|
||||
};
|
||||
upgradeDto(value, 'UserConfigDto');
|
||||
final notifications = UserConfigNotificationsDto.fromJson(value['notifications']);
|
||||
expect(notifications!.smtp.enabled, isTrue);
|
||||
});
|
||||
|
||||
test('upgradeDto', () {
|
||||
dynamic value;
|
||||
String targetType;
|
||||
|
||||
@@ -25954,6 +25954,12 @@
|
||||
"PublicConfigDto": {
|
||||
"description": "Configuration properties that are visible to everyone",
|
||||
"properties": {
|
||||
"ffmpeg": {
|
||||
"$ref": "#/components/schemas/PublicConfigFFmpegDto"
|
||||
},
|
||||
"map": {
|
||||
"$ref": "#/components/schemas/PublicConfigMapDto"
|
||||
},
|
||||
"oauth": {
|
||||
"$ref": "#/components/schemas/PublicConfigOAuthDto"
|
||||
},
|
||||
@@ -25962,16 +25968,61 @@
|
||||
},
|
||||
"server": {
|
||||
"$ref": "#/components/schemas/PublicConfigServerDto"
|
||||
},
|
||||
"theme": {
|
||||
"$ref": "#/components/schemas/PublicConfigThemeDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ffmpeg",
|
||||
"map",
|
||||
"oauth",
|
||||
"passwordLogin",
|
||||
"server",
|
||||
"theme"
|
||||
"server"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PublicConfigFFmpegDto": {
|
||||
"properties": {
|
||||
"realtime": {
|
||||
"$ref": "#/components/schemas/PublicConfigFFmpegRealtimeDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"realtime"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PublicConfigFFmpegRealtimeDto": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Enable real-time HLS transcoding (alpha)",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PublicConfigMapDto": {
|
||||
"properties": {
|
||||
"darkStyle": {
|
||||
"description": "Dark map style URL",
|
||||
"format": "uri",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"lightStyle": {
|
||||
"description": "Light map style URL",
|
||||
"format": "uri",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"darkStyle",
|
||||
"enabled",
|
||||
"lightStyle"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -26021,18 +26072,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PublicConfigThemeDto": {
|
||||
"properties": {
|
||||
"customCss": {
|
||||
"description": "Custom CSS for theming",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"customCss"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PurchaseResponse": {
|
||||
"properties": {
|
||||
"hideBuyButtonUntil": {
|
||||
@@ -33113,6 +33152,9 @@
|
||||
"map": {
|
||||
"$ref": "#/components/schemas/UserConfigMapDto"
|
||||
},
|
||||
"notifications": {
|
||||
"$ref": "#/components/schemas/UserConfigNotificationsDto"
|
||||
},
|
||||
"oauth": {
|
||||
"$ref": "#/components/schemas/UserConfigOAuthDto"
|
||||
},
|
||||
@@ -33125,9 +33167,6 @@
|
||||
"server": {
|
||||
"$ref": "#/components/schemas/UserConfigServerDto"
|
||||
},
|
||||
"theme": {
|
||||
"$ref": "#/components/schemas/UserConfigThemeDto"
|
||||
},
|
||||
"trash": {
|
||||
"$ref": "#/components/schemas/UserConfigTrashDto"
|
||||
},
|
||||
@@ -33140,11 +33179,11 @@
|
||||
"image",
|
||||
"machineLearning",
|
||||
"map",
|
||||
"notifications",
|
||||
"oauth",
|
||||
"passwordLogin",
|
||||
"reverseGeocoding",
|
||||
"server",
|
||||
"theme",
|
||||
"trash",
|
||||
"user"
|
||||
],
|
||||
@@ -33317,6 +33356,17 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UserConfigNotificationsDto": {
|
||||
"properties": {
|
||||
"smtp": {
|
||||
"$ref": "#/components/schemas/UserConfigSmtpDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"smtp"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UserConfigOAuthDto": {
|
||||
"properties": {
|
||||
"autoLaunch": {
|
||||
@@ -33397,15 +33447,15 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UserConfigThemeDto": {
|
||||
"UserConfigSmtpDto": {
|
||||
"properties": {
|
||||
"customCss": {
|
||||
"description": "Custom CSS for theming",
|
||||
"type": "string"
|
||||
"enabled": {
|
||||
"description": "Whether SMTP email notifications are enabled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"customCss"
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -1599,6 +1599,13 @@ export type UserConfigMapDto = {
|
||||
/** Light map style URL */
|
||||
lightStyle: string;
|
||||
};
|
||||
export type UserConfigSmtpDto = {
|
||||
/** Whether SMTP email notifications are enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
export type UserConfigNotificationsDto = {
|
||||
smtp: UserConfigSmtpDto;
|
||||
};
|
||||
export type UserConfigOAuthDto = {
|
||||
/** Auto launch */
|
||||
autoLaunch: boolean;
|
||||
@@ -1623,10 +1630,6 @@ export type UserConfigServerDto = {
|
||||
/** Public users */
|
||||
publicUsers: boolean;
|
||||
};
|
||||
export type UserConfigThemeDto = {
|
||||
/** Custom CSS for theming */
|
||||
customCss: string;
|
||||
};
|
||||
export type UserConfigTrashDto = {
|
||||
/** Days */
|
||||
days: number;
|
||||
@@ -1642,11 +1645,11 @@ export type UserConfigDto = {
|
||||
image: UserConfigImageDto;
|
||||
machineLearning: UserConfigMachineLearningDto;
|
||||
map: UserConfigMapDto;
|
||||
notifications: UserConfigNotificationsDto;
|
||||
oauth: UserConfigOAuthDto;
|
||||
passwordLogin: UserConfigPasswordLoginDto;
|
||||
reverseGeocoding: UserConfigReverseGeocodingDto;
|
||||
server: UserConfigServerDto;
|
||||
theme: UserConfigThemeDto;
|
||||
trash: UserConfigTrashDto;
|
||||
user: UserConfigUserDto;
|
||||
};
|
||||
@@ -2172,6 +2175,21 @@ export type PluginTemplateResponseDto = {
|
||||
/** Ui hints, for example "smart-album" */
|
||||
uiHints: string[];
|
||||
};
|
||||
export type PublicConfigFFmpegRealtimeDto = {
|
||||
/** Enable real-time HLS transcoding (alpha) */
|
||||
enabled: boolean;
|
||||
};
|
||||
export type PublicConfigFFmpegDto = {
|
||||
realtime: PublicConfigFFmpegRealtimeDto;
|
||||
};
|
||||
export type PublicConfigMapDto = {
|
||||
/** Dark map style URL */
|
||||
darkStyle: string;
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
/** Light map style URL */
|
||||
lightStyle: string;
|
||||
};
|
||||
export type PublicConfigOAuthDto = {
|
||||
/** Auto launch */
|
||||
autoLaunch: boolean;
|
||||
@@ -2188,15 +2206,12 @@ export type PublicConfigServerDto = {
|
||||
/** Login page message */
|
||||
loginPageMessage: string;
|
||||
};
|
||||
export type PublicConfigThemeDto = {
|
||||
/** Custom CSS for theming */
|
||||
customCss: string;
|
||||
};
|
||||
export type PublicConfigDto = {
|
||||
ffmpeg: PublicConfigFFmpegDto;
|
||||
map: PublicConfigMapDto;
|
||||
oauth: PublicConfigOAuthDto;
|
||||
passwordLogin: PublicConfigPasswordLoginDto;
|
||||
server: PublicConfigServerDto;
|
||||
theme: PublicConfigThemeDto;
|
||||
};
|
||||
export type QueueResponseDto = {
|
||||
/** Whether the queue is paused */
|
||||
|
||||
@@ -10,12 +10,15 @@ import {
|
||||
import { getKeysDeep } from 'src/utils/misc.js';
|
||||
|
||||
const PUBLIC_PROPERTIES = [
|
||||
'ffmpeg.realtime.enabled',
|
||||
'map.darkStyle',
|
||||
'map.enabled',
|
||||
'map.lightStyle',
|
||||
'oauth.autoLaunch',
|
||||
'oauth.buttonText',
|
||||
'oauth.enabled',
|
||||
'passwordLogin.enabled',
|
||||
'server.loginPageMessage',
|
||||
'theme.customCss',
|
||||
];
|
||||
|
||||
describe('config visibility', () => {
|
||||
@@ -59,6 +62,12 @@ describe('config visibility', () => {
|
||||
expect(config.notifications).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should expose only whether smtp is enabled to users', () => {
|
||||
const config = mapUserConfig(defaults) as Record<string, any>;
|
||||
|
||||
expect(config.notifications).toEqual({ smtp: { enabled: defaults.notifications.smtp.enabled } });
|
||||
});
|
||||
|
||||
it('should keep the visibility metadata out of the schemas', () => {
|
||||
for (const schema of [AdminConfigDto.schema, UserConfigDto.schema, PublicConfigDto.schema]) {
|
||||
const json = JSON.stringify(z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }));
|
||||
|
||||
@@ -115,7 +115,7 @@ const AdminConfigFFmpegSchema = z
|
||||
tonemap: ToneMappingSchema,
|
||||
realtime: z
|
||||
.object({
|
||||
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)').meta({ visibility: User }),
|
||||
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)').meta({ visibility: Public }),
|
||||
videoCodecs: z
|
||||
.array(VideoCodecSchema)
|
||||
.describe('Video codecs to use for real-time HLS transcoding')
|
||||
@@ -131,7 +131,7 @@ const AdminConfigFFmpegSchema = z
|
||||
|
||||
const AdminConfigSmtpSchema = z
|
||||
.object({
|
||||
enabled: configBool.describe('Whether SMTP email notifications are enabled'),
|
||||
enabled: configBool.describe('Whether SMTP email notifications are enabled').meta({ visibility: User }),
|
||||
from: z.string().describe('Email address to send from'),
|
||||
replyTo: z.string().describe('Email address for replies'),
|
||||
transport: z
|
||||
@@ -262,9 +262,9 @@ const AdminConfigSchemaWithVisibility = z
|
||||
.meta({ id: 'AdminConfigMachineLearningDto' }),
|
||||
map: z
|
||||
.object({
|
||||
enabled: configBool.describe('Enabled').meta({ visibility: User }),
|
||||
lightStyle: z.url().describe('Light map style URL').meta({ visibility: User }),
|
||||
darkStyle: z.url().describe('Dark map style URL').meta({ visibility: User }),
|
||||
enabled: configBool.describe('Enabled').meta({ visibility: Public }),
|
||||
lightStyle: z.url().describe('Light map style URL').meta({ visibility: Public }),
|
||||
darkStyle: z.url().describe('Dark map style URL').meta({ visibility: Public }),
|
||||
})
|
||||
.meta({ id: 'AdminConfigMapDto' }),
|
||||
reverseGeocoding: z
|
||||
@@ -372,9 +372,7 @@ const AdminConfigSchemaWithVisibility = z
|
||||
days: z.int().min(0).describe('Days').meta({ visibility: User }),
|
||||
})
|
||||
.meta({ id: 'AdminConfigTrashDto' }),
|
||||
theme: z
|
||||
.object({ customCss: z.string().describe('Custom CSS for theming').meta({ visibility: Public }) })
|
||||
.meta({ id: 'AdminConfigThemeDto' }),
|
||||
theme: z.object({ customCss: z.string().describe('Custom CSS for theming') }).meta({ id: 'AdminConfigThemeDto' }),
|
||||
library: z
|
||||
.object({
|
||||
scan: z
|
||||
|
||||
@@ -109,18 +109,7 @@ with
|
||||
select
|
||||
make_date(year::int, $1::int, $2::int) as "date"
|
||||
from
|
||||
generate_series(
|
||||
(
|
||||
select
|
||||
date_part(
|
||||
'year',
|
||||
min(("localDateTime" at time zone 'UTC')::date)
|
||||
)::int
|
||||
from
|
||||
asset
|
||||
),
|
||||
$3
|
||||
) as "year"
|
||||
generate_series($3::int, $4::int) as "year"
|
||||
)
|
||||
select
|
||||
"a".*
|
||||
@@ -135,21 +124,21 @@ with
|
||||
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
|
||||
where
|
||||
(asset."localDateTime" at time zone 'UTC')::date = today.date
|
||||
and "asset"."ownerId" = any ($4::uuid[])
|
||||
and "asset"."visibility" = $5
|
||||
and "asset"."ownerId" = any ($5::uuid[])
|
||||
and "asset"."visibility" = $6
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $6
|
||||
and "asset_file"."type" = $7
|
||||
)
|
||||
and "asset"."deletedAt" is null
|
||||
order by
|
||||
(asset."localDateTime" at time zone 'UTC')::date desc
|
||||
limit
|
||||
$7
|
||||
$8
|
||||
) as "a" on true
|
||||
)
|
||||
select
|
||||
|
||||
@@ -464,15 +464,19 @@ export class AssetRepository {
|
||||
qb
|
||||
.with('today', (qb) =>
|
||||
qb
|
||||
.selectFrom((eb) =>
|
||||
eb
|
||||
.fn('generate_series', [
|
||||
sql`(select date_part('year', min(("localDateTime" at time zone 'UTC')::date))::int from asset)`,
|
||||
sql`${year - 1}`,
|
||||
])
|
||||
.as('year'),
|
||||
)
|
||||
.select((eb) => eb.fn('make_date', [sql`year::int`, sql`${month}::int`, sql`${day}::int`]).as('date')),
|
||||
.selectFrom((eb) => eb.fn('generate_series', [sql`${year - 20}::int`, sql`${year - 1}::int`]).as('year'))
|
||||
.select((eb) => {
|
||||
const date = eb.fn('make_date', [sql`year::int`, sql`${month}::int`, sql`${day}::int`]);
|
||||
return (
|
||||
month === 2 && day === 29
|
||||
? eb
|
||||
.case()
|
||||
.when(sql<boolean>`year::int % 4 = 0 and (year::int % 100 != 0 or year::int % 400 = 0)`)
|
||||
.then(date)
|
||||
.end()
|
||||
: date
|
||||
).as('date');
|
||||
}),
|
||||
)
|
||||
.selectFrom('today')
|
||||
.innerJoinLateral(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum.js';
|
||||
import { AssetFileType, AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum.js';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository.js';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
@@ -64,6 +64,67 @@ const keyframeRow = (assetId: string, n: number) => ({
|
||||
});
|
||||
|
||||
describe(AssetRepository.name, () => {
|
||||
describe('getByDayOfYear', () => {
|
||||
it('should include every year from one through twenty years ago on the same local calendar day', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: otherUser } = await ctx.newUser();
|
||||
const target = { year: 2026, month: 10, day: 18 };
|
||||
const expectedAssetIds = [];
|
||||
|
||||
for (let yearsAgo = 0; yearsAgo <= 21; yearsAgo++) {
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
localDateTime: `${target.year - yearsAgo}-10-18T12:00:00.000Z`,
|
||||
});
|
||||
await ctx.newJobStatus({ assetId: asset.id });
|
||||
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' });
|
||||
if (yearsAgo >= 1 && yearsAgo <= 20) {
|
||||
expectedAssetIds.push(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dto of [
|
||||
{ ownerId: user.id, localDateTime: '2025-10-17T23:59:59.999Z' },
|
||||
{ ownerId: user.id, localDateTime: '2025-10-19T00:00:00.000Z' },
|
||||
{ ownerId: otherUser.id, localDateTime: '2025-10-18T12:00:00.000Z' },
|
||||
]) {
|
||||
const { asset } = await ctx.newAsset(dto);
|
||||
await ctx.newJobStatus({ assetId: asset.id });
|
||||
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' });
|
||||
}
|
||||
|
||||
const memories = await sut.getByDayOfYear([user.id], target);
|
||||
|
||||
expect(memories.map(({ year }) => year)).toEqual(Array.from({ length: 20 }, (_, index) => 2025 - index));
|
||||
expect(memories.flatMap(({ assets }) => assets.map(({ id }) => id))).toEqual(expectedAssetIds);
|
||||
});
|
||||
|
||||
it.each([2028, 2104])('should skip non-leap years on February 29th in %i', async (year) => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const expectedYears = [];
|
||||
|
||||
for (let yearsAgo = 1; yearsAgo <= 20; yearsAgo++) {
|
||||
const assetYear = year - yearsAgo;
|
||||
const isLeapYear = assetYear % 4 === 0 && (assetYear % 100 !== 0 || assetYear % 400 === 0);
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
localDateTime: `${assetYear}-${isLeapYear ? '02-29' : '03-01'}T12:00:00.000Z`,
|
||||
});
|
||||
await ctx.newJobStatus({ assetId: asset.id });
|
||||
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: '/preview.jpg' });
|
||||
if (isLeapYear) {
|
||||
expectedYears.push(assetYear);
|
||||
}
|
||||
}
|
||||
|
||||
const memories = await sut.getByDayOfYear([user.id], { year, month: 2, day: 29 });
|
||||
|
||||
expect(memories.map(({ year }) => year)).toEqual(expectedYears);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeBucket', () => {
|
||||
it('should order assets by local day first and fileCreatedAt within each day', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
|
||||
Vendored
-2
@@ -9,8 +9,6 @@ declare namespace App {
|
||||
interface PageData {
|
||||
meta: {
|
||||
title: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { MemoryType, type MemoryResponseDto } from '@immich/sdk';
|
||||
import { cleanup, fireEvent, render } from '@testing-library/svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import MemoriesLane from './__test__/MemoriesLaneHarness.svelte';
|
||||
|
||||
const date = '2026-10-18';
|
||||
const makeMemory = (year: number, assetCount = 2): MemoryResponseDto => ({
|
||||
id: `memory-${year}`,
|
||||
type: MemoryType.OnThisDay,
|
||||
data: { year },
|
||||
assets: Array.from({ length: assetCount }, (_, index) => assetFactory.build({ id: `asset-${year}-${index}` })),
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
memoryAt: `${year}-10-18T00:00:00.000Z`,
|
||||
showAt: `${date}T00:00:00.000Z`,
|
||||
ownerId: 'owner',
|
||||
isSaved: false,
|
||||
});
|
||||
|
||||
describe('MemoriesLane', () => {
|
||||
let intersectionCallback: IntersectionObserverCallback;
|
||||
const disconnect = vi.fn();
|
||||
|
||||
const intersect = async (isIntersecting: boolean) => {
|
||||
intersectionCallback([{ isIntersecting } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
await tick();
|
||||
};
|
||||
|
||||
const advance = async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await tick();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(`${date}T12:00:00Z`));
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
vi.fn(function (callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback;
|
||||
return { observe: vi.fn(), disconnect, unobserve: vi.fn(), takeRecords: vi.fn() };
|
||||
}),
|
||||
);
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
disconnect.mockClear();
|
||||
});
|
||||
|
||||
it('shows today from 1 through 20 years ago, recent years first, and keeps birthdays', () => {
|
||||
const yesterday = { ...makeMemory(2024), memoryAt: '2024-10-17T00:00:00.000Z' };
|
||||
const stale = { ...makeMemory(2023), showAt: '2025-10-18T00:00:00.000Z' };
|
||||
const birthday = { ...makeMemory(2022), type: MemoryType.Birthday, data: { year: 2022, personName: 'Alex' } };
|
||||
const { getAllByRole } = render(MemoriesLane, {
|
||||
date,
|
||||
memories: [makeMemory(2005), makeMemory(2006), makeMemory(2026), yesterday, stale, makeMemory(2025), birthday],
|
||||
});
|
||||
|
||||
expect(getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/memories/memory-2022?assetId=asset-2022-0',
|
||||
'/memories/memory-2025?assetId=asset-2025-0',
|
||||
'/memories/memory-2006?assetId=asset-2006-0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rotates each card every five seconds, opens its displayed asset, and wraps its photos', async () => {
|
||||
const { getByRole, getAllByRole } = render(MemoriesLane, {
|
||||
date,
|
||||
memories: [makeMemory(2025), makeMemory(2024, 3)],
|
||||
});
|
||||
await intersect(true);
|
||||
await advance();
|
||||
|
||||
const link = getAllByRole('link')[0];
|
||||
expect(link.querySelector('img')?.src).toContain('asset-2025-1');
|
||||
let clickedHref: string | null = null;
|
||||
link.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
clickedHref = (event.currentTarget as HTMLAnchorElement).getAttribute('href');
|
||||
});
|
||||
await fireEvent.click(link);
|
||||
expect(clickedHref).toBe('/memories/memory-2025?assetId=asset-2025-1');
|
||||
|
||||
await advance();
|
||||
expect(link.querySelector('img')?.src).toContain('asset-2025-0');
|
||||
expect(getAllByRole('link')[1].querySelector('img')?.src).toContain('asset-2024-2');
|
||||
expect(getByRole('button', { name: 'pause_memories' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('pauses for hover, focus, hidden page, offscreen lane, and a disabled viewer state', async () => {
|
||||
const component = render(MemoriesLane, { date, memories: [makeMemory(2025)] });
|
||||
const region = component.getByRole('region');
|
||||
const image = component.getByRole('img') as HTMLImageElement;
|
||||
await intersect(true);
|
||||
await fireEvent.mouseEnter(region);
|
||||
await advance();
|
||||
expect(image.src).toContain('asset-2025-0');
|
||||
await fireEvent.mouseLeave(region);
|
||||
await fireEvent.focusIn(component.getByRole('link'));
|
||||
await advance();
|
||||
expect(image.src).toContain('asset-2025-0');
|
||||
await fireEvent.focusOut(component.getByRole('link'), { relatedTarget: null });
|
||||
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: true });
|
||||
await fireEvent(document, new Event('visibilitychange'));
|
||||
await advance();
|
||||
expect(image.src).toContain('asset-2025-0');
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: false });
|
||||
await fireEvent(document, new Event('visibilitychange'));
|
||||
await intersect(false);
|
||||
await advance();
|
||||
expect(image.src).toContain('asset-2025-0');
|
||||
await intersect(true);
|
||||
await component.rerender({ enabled: false });
|
||||
await advance();
|
||||
expect(image.src).toContain('asset-2025-0');
|
||||
await component.rerender({ enabled: true });
|
||||
await advance();
|
||||
expect(image.src).toContain('asset-2025-1');
|
||||
});
|
||||
|
||||
it('supports an explicit pause and resume and starts paused for reduced motion', async () => {
|
||||
vi.mocked(matchMedia).mockReturnValueOnce({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as MediaQueryList);
|
||||
const component = render(MemoriesLane, { date, memories: [makeMemory(2025)] });
|
||||
await intersect(true);
|
||||
await advance();
|
||||
expect(component.getByRole('img').getAttribute('src')).toContain('asset-2025-0');
|
||||
const region = component.getByRole('region');
|
||||
await fireEvent.mouseEnter(region);
|
||||
const resumeButton = component.getByRole('button', { name: 'resume' });
|
||||
resumeButton.focus();
|
||||
await tick();
|
||||
await fireEvent.click(resumeButton);
|
||||
await fireEvent.mouseLeave(region);
|
||||
await advance();
|
||||
expect(component.getByRole('img').getAttribute('src')).toContain('asset-2025-1');
|
||||
await fireEvent.click(component.getByRole('button', { name: 'pause_memories' }));
|
||||
await advance();
|
||||
expect(component.getByRole('img').getAttribute('src')).toContain('asset-2025-1');
|
||||
});
|
||||
|
||||
it('advances the horizontal lane and loops at the end, then cleans up its timer and observer', async () => {
|
||||
const component = render(MemoriesLane, { date, memories: [makeMemory(2025), makeMemory(2024), makeMemory(2023)] });
|
||||
const scroller = component.getByRole('region').firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
clientWidth: { configurable: true, value: 200 },
|
||||
scrollWidth: { configurable: true, value: 500 },
|
||||
});
|
||||
vi.spyOn(component.getAllByRole('link')[0], 'getBoundingClientRect').mockReturnValue({ width: 150 } as DOMRect);
|
||||
const scrollTo = vi.fn((options: ScrollToOptions | number | undefined) => {
|
||||
scroller.scrollLeft = typeof options === 'object' ? (options.left ?? 0) : (options ?? 0);
|
||||
});
|
||||
scroller.scrollTo = scrollTo;
|
||||
await intersect(true);
|
||||
|
||||
await advance();
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({ left: 150, behavior: 'smooth' });
|
||||
await advance();
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({ left: 300, behavior: 'smooth' });
|
||||
await advance();
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({ left: 0, behavior: 'smooth' });
|
||||
component.unmount();
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await advance();
|
||||
expect(scrollTo).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
<script lang="ts">
|
||||
import MemoryCard from '$lib/components/memories/MemoryCard.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { MemoryType, type MemoryResponseDto } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiPause, mdiPlay } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
memories: MemoryResponseDto[];
|
||||
enabled?: boolean;
|
||||
date?: string;
|
||||
};
|
||||
|
||||
const { memories, enabled = true, date = DateTime.now().toISODate() }: Props = $props();
|
||||
const interval = 5000;
|
||||
|
||||
let lane = $state<HTMLElement>();
|
||||
let scroller = $state<HTMLElement>();
|
||||
let mounted = $state(false);
|
||||
let visible = $state(false);
|
||||
let documentVisible = $state(true);
|
||||
let hovered = $state(false);
|
||||
let focused = $state(false);
|
||||
let paused = $state(false);
|
||||
let reducedMotion = $state(false);
|
||||
let frame = $state(0);
|
||||
let viewportWidth = $state(0);
|
||||
let contentWidth = $state(0);
|
||||
let scrollPosition = $state(0);
|
||||
|
||||
const currentMemories = $derived(
|
||||
memories
|
||||
.filter((memory) => {
|
||||
if (memory.type !== MemoryType.OnThisDay) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const age = Number(date.slice(0, 4)) - memory.data.year;
|
||||
return (
|
||||
age >= 1 &&
|
||||
age <= 20 &&
|
||||
memory.memoryAt.slice(5, 10) === date.slice(5, 10) &&
|
||||
(!memory.showAt || memory.showAt.slice(0, 10) === date)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return Number(b.type === MemoryType.Birthday) - Number(a.type === MemoryType.Birthday);
|
||||
}
|
||||
return b.data.year - a.data.year;
|
||||
}),
|
||||
);
|
||||
|
||||
const items = $derived(
|
||||
currentMemories.flatMap((memory) => {
|
||||
const asset = memory.assets[frame % memory.assets.length];
|
||||
return asset
|
||||
? [
|
||||
{
|
||||
id: memory.id,
|
||||
title: $memoryLaneTitle(memory),
|
||||
href: Route.viewMemory({ id: memory.id, assetId: asset.id }),
|
||||
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(asset)) } }),
|
||||
src: getAssetMediaUrl({ id: asset.id }),
|
||||
type: memory.type,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
|
||||
const canScrollPrevious = $derived(scrollPosition > 0);
|
||||
const canScrollNext = $derived(Math.ceil(scrollPosition) < Math.floor(contentWidth - viewportWidth));
|
||||
const canPlay = $derived(
|
||||
mounted && enabled && visible && documentVisible && !hovered && !focused && !paused && items.length > 0,
|
||||
);
|
||||
|
||||
const hasContentFocus = (target: EventTarget | null) =>
|
||||
target instanceof Element && !!lane?.contains(target) && !target.closest('[data-memory-playback-control]');
|
||||
|
||||
const scroll = (forward: boolean, loop = false) => {
|
||||
if (!scroller) {
|
||||
return;
|
||||
}
|
||||
|
||||
const end = scroller.scrollWidth - scroller.clientWidth;
|
||||
if (end <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = Math.abs(scroller.scrollLeft);
|
||||
const firstCard = scroller.querySelector('a');
|
||||
const step = firstCard
|
||||
? firstCard.getBoundingClientRect().width +
|
||||
(Number(getComputedStyle(firstCard).marginInlineEnd.replace('px', '')) || 0)
|
||||
: scroller.clientWidth;
|
||||
const next =
|
||||
loop && Math.ceil(position) >= Math.floor(end)
|
||||
? 0
|
||||
: Math.max(0, Math.min(end, position + (forward ? step : -step)));
|
||||
const direction = getComputedStyle(scroller).direction === 'rtl' ? -1 : 1;
|
||||
scroller.scrollTo({ left: next * direction, behavior: reducedMotion ? 'instant' : 'smooth' });
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMotion = motionQuery.matches;
|
||||
paused = motionQuery.matches;
|
||||
const onMotionChange = () => {
|
||||
reducedMotion = motionQuery.matches;
|
||||
if (reducedMotion) {
|
||||
paused = true;
|
||||
}
|
||||
};
|
||||
const onVisibilityChange = () => (documentVisible = !document.hidden);
|
||||
onVisibilityChange();
|
||||
motionQuery.addEventListener('change', onMotionChange);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
mounted = true;
|
||||
|
||||
return () => {
|
||||
motionQuery.removeEventListener('change', onMotionChange);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!mounted || !lane) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => (visible = entry.isIntersecting), { threshold: 0.1 });
|
||||
observer.observe(lane);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!canPlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
frame++;
|
||||
scroll(true, true);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if items.length > 0}
|
||||
<section
|
||||
bind:this={lane}
|
||||
aria-label={$t('memories')}
|
||||
class="relative mt-3"
|
||||
onmouseenter={() => (hovered = true)}
|
||||
onmouseleave={() => (hovered = false)}
|
||||
onfocusin={(event) => (focused = hasContentFocus(event.target))}
|
||||
onfocusout={(event) => (focused = hasContentFocus(event.relatedTarget))}
|
||||
>
|
||||
<div
|
||||
bind:this={scroller}
|
||||
bind:clientWidth={viewportWidth}
|
||||
class="overflow-x-scroll overflow-y-hidden whitespace-nowrap"
|
||||
style="scrollbar-width:none"
|
||||
onscroll={() => (scrollPosition = Math.abs(scroller?.scrollLeft ?? 0))}
|
||||
>
|
||||
{#if canScrollPrevious || canScrollNext}
|
||||
<div class="light sticky inset-s-0 z-10">
|
||||
{#if canScrollPrevious}
|
||||
<div class="absolute inset-s-4 top-27 -translate-y-1/2 max-md:top-19">
|
||||
<IconButton
|
||||
icon={mdiChevronLeft}
|
||||
directional
|
||||
shape="round"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
class="opacity-50 hover:opacity-100"
|
||||
size="giant"
|
||||
aria-label={$t('previous_memory')}
|
||||
onclick={() => scroll(false)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if canScrollNext}
|
||||
<div class="absolute inset-e-4 top-27 -translate-y-1/2 max-md:top-19">
|
||||
<IconButton
|
||||
icon={mdiChevronRight}
|
||||
directional
|
||||
shape="round"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
class="opacity-50 hover:opacity-100"
|
||||
size="giant"
|
||||
aria-label={$t('next_memory')}
|
||||
onclick={() => scroll(true)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="inline-block" bind:clientWidth={contentWidth}>
|
||||
{#each items as item (item.id)}
|
||||
<MemoryCard {item} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="light absolute inset-s-2 top-2" data-memory-playback-control>
|
||||
<IconButton
|
||||
icon={paused ? mdiPlay : mdiPause}
|
||||
shape="round"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
size="small"
|
||||
aria-label={$t(paused ? 'resume' : 'pause_memories')}
|
||||
aria-pressed={paused}
|
||||
onclick={() => (paused = !paused)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import MemoriesLane from '$lib/components/memories/MemoriesLane.svelte';
|
||||
import { TooltipProvider } from '@immich/ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
const props: ComponentProps<typeof MemoriesLane> = $props();
|
||||
</script>
|
||||
|
||||
<TooltipProvider>
|
||||
<MemoriesLane {...props} />
|
||||
</TooltipProvider>
|
||||
@@ -17,8 +17,6 @@
|
||||
data: {
|
||||
meta: {
|
||||
title: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
@@ -32,7 +30,7 @@
|
||||
const { data }: Props = $props();
|
||||
|
||||
let { sharedLink, passwordRequired, key, slug, meta } = $state(data);
|
||||
let { title, description } = $state(meta);
|
||||
let { title } = $state(meta);
|
||||
let isOwned = $derived(authManager.authenticated && authManager.user.id === sharedLink?.userId);
|
||||
let password = $state('');
|
||||
|
||||
@@ -46,9 +44,6 @@
|
||||
setSharedLink(sharedLink);
|
||||
passwordRequired = false;
|
||||
title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';
|
||||
description =
|
||||
sharedLink.description ||
|
||||
$t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } });
|
||||
await tick();
|
||||
await navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: assetViewerManager.gridScrollTarget },
|
||||
@@ -71,7 +66,6 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
</svelte:head>
|
||||
{#if passwordRequired}
|
||||
<main
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
createUserAdmin,
|
||||
deleteUserAdmin,
|
||||
restoreUserAdmin,
|
||||
searchUsersAdmin,
|
||||
updateUserAdmin,
|
||||
type UserAdminResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { QueryObserver } from '@tanstack/svelte-query';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { executeMutation, queryClient } from '$lib/queries/client';
|
||||
import {
|
||||
createUserAdminMutation,
|
||||
deleteUserAdminMutation,
|
||||
getUserAdminQuery,
|
||||
restoreUserAdminMutation,
|
||||
searchUsersAdminQuery,
|
||||
updateUserAdminMutation,
|
||||
} from '$lib/queries/user-admin.query';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
vi.mock('$lib', () => ({ getHeatmapRange: vi.fn() }));
|
||||
vi.mock('@immich/ui', () => ({ toastManager: { primary: vi.fn(), info: vi.fn() } }));
|
||||
vi.mock('$lib/utils/handle-error', () => ({ handleError: vi.fn() }));
|
||||
vi.mock('$lib/utils/i18n', () => ({ getFormatter: () => Promise.resolve((key: string) => key) }));
|
||||
vi.mock('@immich/sdk', () => ({
|
||||
CalendarHeatmapType: { Upload: 'upload' },
|
||||
createUserAdmin: vi.fn(),
|
||||
deleteUserAdmin: vi.fn(),
|
||||
restoreUserAdmin: vi.fn(),
|
||||
updateUserAdmin: vi.fn(),
|
||||
searchUsersAdmin: vi.fn(),
|
||||
getUserCalendarHeatmapAdmin: vi.fn(),
|
||||
getUserPreferencesAdmin: vi.fn(),
|
||||
getUserSessionsAdmin: vi.fn(),
|
||||
getUserStatisticsAdmin: vi.fn(),
|
||||
}));
|
||||
|
||||
const active = { id: 'active', name: 'Active', deletedAt: null } as UserAdminResponseDto;
|
||||
const deleted = { id: 'deleted', name: 'Deleted', deletedAt: '2026-10-16T00:00:00Z' } as UserAdminResponseDto;
|
||||
|
||||
describe('admin user query regressions', () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
afterEach(() => queryClient.clear());
|
||||
|
||||
it('shares one request while filtering deleted users only for active-user consumers', async () => {
|
||||
vi.mocked(searchUsersAdmin).mockResolvedValue([active, deleted]);
|
||||
await Promise.all([queryClient.query(searchUsersAdminQuery()), queryClient.query(getUserAdminQuery(deleted.id))]);
|
||||
const activeUsers = new QueryObserver(queryClient, searchUsersAdminQuery());
|
||||
const allUsers = new QueryObserver(queryClient, searchUsersAdminQuery({ withDeleted: true }));
|
||||
const detail = new QueryObserver(queryClient, getUserAdminQuery(deleted.id));
|
||||
expect(searchUsersAdmin).toHaveBeenCalledTimes(1);
|
||||
expect(activeUsers.getCurrentResult().data).toEqual([active]);
|
||||
expect(allUsers.getCurrentResult().data).toEqual([active, deleted]);
|
||||
expect(detail.getCurrentResult().data).toEqual(deleted);
|
||||
activeUsers.destroy();
|
||||
allUsers.destroy();
|
||||
detail.destroy();
|
||||
});
|
||||
|
||||
it('preserves the user list and reports an error when deletion fails', async () => {
|
||||
queryClient.setQueryData(searchUsersAdminQuery().queryKey, [active, deleted]);
|
||||
vi.mocked(deleteUserAdmin).mockRejectedValue(new Error('offline'));
|
||||
await executeMutation(deleteUserAdminMutation, [{ id: active.id, userAdminDeleteDto: {} }]);
|
||||
expect(queryClient.getQueryData(searchUsersAdminQuery().queryKey)).toEqual([active, deleted]);
|
||||
expect(handleError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('uses the server response for edits without changing another user', async () => {
|
||||
queryClient.setQueryData(searchUsersAdminQuery().queryKey, [active, deleted]);
|
||||
const saved = { ...active, name: 'Saved', quotaSizeInBytes: 1024 };
|
||||
vi.mocked(updateUserAdmin).mockResolvedValue(saved);
|
||||
await executeMutation(updateUserAdminMutation, [{ id: active.id, userAdminUpdateDto: { name: saved.name } }]);
|
||||
expect(queryClient.getQueryData(searchUsersAdminQuery().queryKey)).toEqual([saved, deleted]);
|
||||
});
|
||||
|
||||
it('makes a restored account available to active-user consumers', async () => {
|
||||
queryClient.setQueryData(searchUsersAdminQuery().queryKey, [active, deleted]);
|
||||
const restored = { ...deleted, deletedAt: null };
|
||||
vi.mocked(restoreUserAdmin).mockResolvedValue(restored);
|
||||
await executeMutation(restoreUserAdminMutation, [{ id: deleted.id }]);
|
||||
const observer = new QueryObserver(queryClient, searchUsersAdminQuery());
|
||||
expect(observer.getCurrentResult().data).toEqual([active, restored]);
|
||||
observer.destroy();
|
||||
});
|
||||
|
||||
it('refreshes the user list after account creation', async () => {
|
||||
queryClient.setQueryData(searchUsersAdminQuery().queryKey, [active]);
|
||||
vi.mocked(createUserAdmin).mockResolvedValue(deleted);
|
||||
await executeMutation(createUserAdminMutation, [
|
||||
{ userAdminCreateDto: { email: 'test@example.com', name: 'Test', password: 'test-only-password' } },
|
||||
]);
|
||||
expect(queryClient.getQueryState(searchUsersAdminQuery().queryKey)?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it('clears admin user data on logout', () => {
|
||||
queryClient.setQueryData(searchUsersAdminQuery().queryKey, [active, deleted]);
|
||||
queryClient.setQueryData(['user-admin', active.id, 'sessions'], [{ id: 'session' }]);
|
||||
eventManager.emit('AuthLogout');
|
||||
expect(queryClient.getQueryCache().getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { UserStatus, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { getUserAdminActions } from '$lib/services/user-admin.service';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
vi.mock('@immich/ui', () => ({ modalManager: { show: vi.fn(), showDialog: vi.fn() } }));
|
||||
vi.mock('$lib/managers/auth-manager.svelte', () => ({ authManager: { user: { id: 'self' } } }));
|
||||
vi.mock('$lib/managers/server-config-manager.svelte', () => ({
|
||||
serverConfigManager: { value: { userDeleteDelay: 7 } },
|
||||
}));
|
||||
vi.mock('$lib/modals/PasswordResetSuccessModal.svelte', () => ({ default: {} }));
|
||||
vi.mock('$lib/modals/UserDeleteConfirmModal.svelte', () => ({ default: {} }));
|
||||
vi.mock('$lib/modals/UserRestoreConfirmModal.svelte', () => ({ default: {} }));
|
||||
vi.mock('$lib/queries/client', () => ({ executeMutation: vi.fn() }));
|
||||
vi.mock('$lib/queries/user-admin.query', () => ({ updateUserAdminMutation: vi.fn() }));
|
||||
vi.mock('$lib/route', () => ({ Route: { newUser: vi.fn(), viewUser: vi.fn(), editUser: vi.fn() } }));
|
||||
vi.mock('$lib/utils/i18n', () => ({ getFormatter: vi.fn() }));
|
||||
|
||||
describe('admin actions for active and deleted accounts', () => {
|
||||
it('does not format a deletion date for an active account', () => {
|
||||
const format = vi.fn((key: string) => key);
|
||||
const user = { id: 'other', deletedAt: null } as UserAdminResponseDto;
|
||||
const actions = getUserAdminActions(format as MessageFormatter, user);
|
||||
expect(actions.Restore.data).toBeUndefined();
|
||||
expect((actions.Restore.$if as () => boolean)()).toBe(false);
|
||||
expect(format).not.toHaveBeenCalledWith('admin.user_restore_scheduled_removal', expect.anything());
|
||||
expect((actions.Update.$if as () => boolean)()).toBe(true);
|
||||
expect((actions.ResetPassword.$if as () => boolean)()).toBe(true);
|
||||
});
|
||||
|
||||
it('offers restoration with a valid date and hides edits for a deleted account', () => {
|
||||
const format = vi.fn((key: string, _?: unknown) => key);
|
||||
const user = { id: 'other', deletedAt: '2026-10-17T12:00:00Z', status: UserStatus.Deleted } as UserAdminResponseDto;
|
||||
const actions = getUserAdminActions(format as MessageFormatter, user);
|
||||
expect((actions.Restore.$if as () => boolean)()).toBe(true);
|
||||
expect(format).toHaveBeenCalledWith('admin.user_restore_scheduled_removal', {
|
||||
values: { date: new Date('2026-10-24T12:00:00Z') },
|
||||
});
|
||||
for (const action of [actions.Update, actions.Delete, actions.ResetPassword, actions.ResetPinCode]) {
|
||||
expect((action.$if as () => boolean)()).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps destructive account actions hidden for the current administrator', () => {
|
||||
const format = vi.fn((key: string) => key);
|
||||
const actions = getUserAdminActions(
|
||||
format as MessageFormatter,
|
||||
{ id: 'self', deletedAt: null } as UserAdminResponseDto,
|
||||
);
|
||||
expect((actions.Delete.$if as () => boolean)()).toBe(false);
|
||||
expect((actions.ResetPassword.$if as () => boolean)()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -65,9 +65,9 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
icon: mdiDeleteRestore,
|
||||
title: $t('restore'),
|
||||
color: 'primary',
|
||||
data: {
|
||||
title: $t('admin.user_restore_scheduled_removal', { values: { date: getDeleteDate(user.deletedAt!) } }),
|
||||
},
|
||||
data: user.deletedAt
|
||||
? { title: $t('admin.user_restore_scheduled_removal', { values: { date: getDeleteDate(user.deletedAt) } }) }
|
||||
: undefined,
|
||||
$if: () => !!user.deletedAt && user.status === UserStatus.Deleted,
|
||||
onAction: () => modalManager.show(UserRestoreConfirmModal, { user }),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getMySharedLink, isHttpError } from '@immich/sdk';
|
||||
import { getAssetMediaUrl, getSharedLink as getCachedSharedLink, setSharedLink } from '$lib/utils';
|
||||
import { getSharedLink as getCachedSharedLink, setSharedLink } from '$lib/utils';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
@@ -39,9 +39,6 @@ export const loadSharedLink = async ({
|
||||
try {
|
||||
const [sharedLink, asset] = await Promise.all([sharedLinkPromise, getAssetInfoFromParam(params)]);
|
||||
setSharedLink(sharedLink);
|
||||
const assetCount = sharedLink.assets.length;
|
||||
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
|
||||
const assetPath = assetId ? getAssetMediaUrl({ id: assetId }) : '/feature-panel.png';
|
||||
|
||||
return {
|
||||
...common,
|
||||
@@ -49,8 +46,6 @@ export const loadSharedLink = async ({
|
||||
asset,
|
||||
meta: {
|
||||
title: sharedLink.album ? sharedLink.album.albumName : $t('public_share'),
|
||||
description: sharedLink.description || $t('shared_photos_and_videos_count', { values: { assetCount } }),
|
||||
imageUrl: assetPath,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import MemoriesLane from '$lib/components/memories/MemoriesLane.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||
@@ -21,22 +22,19 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import { getStackBulkActions } from '$lib/services/stack.service';
|
||||
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { type OnLink, type OnUnlink } from '$lib/utils/actions';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import MemoryCard from '$lib/components/memories/MemoryCard.svelte';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
||||
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
let memoryDate = $state(DateTime.now().toISODate());
|
||||
const options = { visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true };
|
||||
|
||||
let selectedAssets = $derived(assetMultiSelectManager.assets);
|
||||
@@ -75,18 +73,29 @@
|
||||
assetMultiSelectManager.clear();
|
||||
};
|
||||
|
||||
const items = $derived(
|
||||
memoryManager.memories.map((memory) => ({
|
||||
id: memory.id,
|
||||
title: $memoryLaneTitle(memory),
|
||||
href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }),
|
||||
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
|
||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||
type: memory.type,
|
||||
})),
|
||||
);
|
||||
|
||||
memoryManager.setFilters({ $for: DateTime.now().toISODate() });
|
||||
|
||||
onMount(() => {
|
||||
let midnightTimer: ReturnType<typeof setTimeout>;
|
||||
const refreshMemoriesDate = () => {
|
||||
const now = DateTime.now();
|
||||
memoryDate = now.toISODate();
|
||||
memoryManager.setFilters({ $for: memoryDate });
|
||||
clearTimeout(midnightTimer);
|
||||
midnightTimer = setTimeout(refreshMemoriesDate, now.plus({ days: 1 }).startOf('day').diff(now).toMillis() + 1000);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
refreshMemoriesDate();
|
||||
}
|
||||
};
|
||||
refreshMemoriesDate();
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => {
|
||||
clearTimeout(midnightTimer);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
|
||||
@@ -100,11 +109,11 @@
|
||||
withStacked
|
||||
>
|
||||
{#if authManager.preferences.memories.enabled}
|
||||
<ImageCarousel {items}>
|
||||
{#snippet child(item)}
|
||||
<MemoryCard {item} />
|
||||
{/snippet}
|
||||
</ImageCarousel>
|
||||
<MemoriesLane
|
||||
memories={memoryManager.memories}
|
||||
date={memoryDate}
|
||||
enabled={!assetViewerManager.isViewing && !assetMultiSelectManager.selectionActive}
|
||||
/>
|
||||
{/if}
|
||||
{#snippet empty()}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mx-auto mt-10" />
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import VersionAnnouncement from './VersionAnnouncement.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import ServerRestartingModal from '$lib/modals/ServerRestartingModal.svelte';
|
||||
import { queryClient } from '$lib/queries/client';
|
||||
import { Route } from '$lib/route';
|
||||
@@ -241,21 +240,6 @@
|
||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||
<meta name="theme-color" content="white" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="black" media="(prefers-color-scheme: dark)" />
|
||||
|
||||
{#if page.data.meta}
|
||||
<meta name="description" content={page.data.meta.description} />
|
||||
|
||||
<!-- Facebook Meta Tags -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={page.data.meta.title} />
|
||||
<meta property="og:description" content={page.data.meta.description} />
|
||||
{#if page.data.meta.imageUrl}
|
||||
<meta
|
||||
property="og:image"
|
||||
content={new URL(page.data.meta.imageUrl, serverConfigManager.value.externalDomain || location.origin).href}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -39,7 +39,6 @@ export const load = (async ({ fetch }) => {
|
||||
return {
|
||||
meta: {
|
||||
title: $t('welcome') + ' 🎉',
|
||||
description: $t('immich_web_interface'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
|
||||
Reference in New Issue
Block a user