10 Commits
Author SHA1 Message Date
Codex 8b83c289be Use scoped browser globals in memory playback
CodeQL / Analyze (javascript) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
Build Mobile / pre-job (push) Canceled after 0s
Docker / pre-job (push) Canceled after 0s
Docs build / pre-job (push) Canceled after 0s
Migration Order / Verify migration ORDER (push) Canceled after 0s
Static Code Analysis / pre-job (push) Canceled after 0s
Test / pre-job (push) Canceled after 0s
Test / ShellCheck (push) Canceled after 0s
Test / OpenAPI Clients (push) Canceled after 0s
Test / SQL Schema Checks (push) Canceled after 0s
Build Mobile / Build and sign Android (push) Canceled after 0s
Build Mobile / Build and sign iOS (push) Canceled after 0s
Docker / Re-Tag ML (armnn, immich-machine-learning, linux/arm64, -armnn) (push) Canceled after 0s
Docker / Re-Tag ML (cpu, immich-machine-learning) (push) Canceled after 0s
Docker / Re-Tag ML (cuda, immich-machine-learning, linux/amd64, -cuda) (push) Canceled after 0s
Docker / Re-Tag ML (openvino, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -openvino) (push) Canceled after 0s
Docker / Re-Tag ML (rknn, immich-machine-learning, linux/arm64, -rknn) (push) Canceled after 0s
Docker / Re-Tag ML (rocm, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -rocm) (push) Canceled after 0s
Docker / Re-Tag Server (push) Canceled after 0s
Docker / Build and Push ML (armnn, immich-machine-learning, linux/arm64, -armnn) (push) Canceled after 0s
Docker / Build and Push ML (cpu, immich-machine-learning) (push) Canceled after 0s
Docker / Build and Push ML (cuda, immich-machine-learning, linux/amd64, -cuda) (push) Canceled after 0s
Docker / Build and Push ML (openvino, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -openvino) (push) Canceled after 0s
Docker / Build and Push ML (rknn, immich-machine-learning, linux/arm64, -rknn) (push) Canceled after 0s
Docker / Build and Push ML (rocm, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -rocm) (push) Canceled after 0s
Docker / Build and Push Server (push) Canceled after 0s
Docker / Mirror to Docker Hub (armnn, immich-machine-learning, linux/arm64, -armnn) (push) Canceled after 0s
Docker / Mirror to Docker Hub (cpu, immich-machine-learning) (push) Canceled after 0s
Docker / Mirror to Docker Hub (cuda, immich-machine-learning, linux/amd64, -cuda) (push) Canceled after 0s
Docker / Mirror to Docker Hub (immich-server) (push) Canceled after 0s
Docker / Mirror to Docker Hub (openvino, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -openvino) (push) Canceled after 0s
Docker / Mirror to Docker Hub (rknn, immich-machine-learning, linux/arm64, -rknn) (push) Canceled after 0s
Docker / Mirror to Docker Hub (rocm, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -rocm) (push) Canceled after 0s
Docker / Docker Build & Push Server Success (push) Canceled after 0s
Docker / Docker Build & Push ML Success (push) Canceled after 0s
Docs build / Docs Build (push) Canceled after 0s
Static Code Analysis / Run Dart Code Analysis (push) Canceled after 0s
Test / Scripts unit tests (push) Canceled after 0s
Test / Test & Lint Server (push) Canceled after 0s
Test / Unit Test CLI (push) Canceled after 0s
Test / Unit Test CLI (Windows) (push) Canceled after 0s
Test / Lint Web (push) Canceled after 0s
Test / Test Web (push) Canceled after 0s
Test / Test i18n (push) Canceled after 0s
Test / End-to-End Lint (push) Canceled after 0s
Test / Medium Tests (Server) (push) Canceled after 0s
Test / End-to-End Tests (Server & CLI) (ubuntu-24.04-arm) (push) Canceled after 0s
Test / End-to-End Tests (Server & CLI) (ubuntu-latest) (push) Canceled after 0s
Test / End-to-End Tests (Web) (ubuntu-24.04-arm) (push) Canceled after 0s
Test / End-to-End Tests (Web) (ubuntu-latest) (push) Canceled after 0s
Test / End-to-End Tests Success (push) Canceled after 0s
Test / Unit Test Mobile (push) Canceled after 0s
Test / Unit Test ML (push) Canceled after 0s
Test / .github Files Formatting (push) Canceled after 0s
2026-10-18 19:03:36 +03:00
Codex ca7766fcd6 Show on-this-day memories from the last 20 years with automatic playback 2026-10-18 19:00:06 +03:00
Codex 04a7b4104f test(web): align formatter stub with lint rules 2026-10-17 20:17:24 +03:00
Codex 97609ec6bd fix(web): avoid formatting a missing user deletion date 2026-10-17 20:09:47 +03:00
Codex 43b94defd9 fix(mobile): use JSON maps for nested config compatibility defaults 2026-10-17 19:47:28 +03:00
Codex ffb8c84e48 test: provide required user deletion request body 2026-10-17 19:41:22 +03:00
Codex 85820ca45e test: cover user admin queries and config compatibility for local.4 2026-10-17 19:37:37 +03:00
Jason RasmussenandCodex 36a6e033aa fix: public config properties (#32500)
fix: public config visibility
2026-10-17 19:32:36 +03:00
Jason RasmussenandCodex 49f65aa3b0 chore: remove unused code (#32497) 2026-10-17 19:32:36 +03:00
Jason RasmussenandCodex 4341a2f34c fix: smpt email visibility (#32492) 2026-10-17 19:32:36 +03:00
23 changed files with 867 additions and 131 deletions
-2
View File
@@ -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",
+15
View File
@@ -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
View File
@@ -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;
+76 -26
View File
@@ -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"
},
+25 -10
View File
@@ -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 -1
View File
@@ -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' }));
+6 -8
View File
@@ -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
+5 -16
View File
@@ -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
+13 -9
View File
@@ -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();
-2
View File
@@ -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);
});
});
+3 -3
View File
@@ -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 -6
View File
@@ -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" />
-16
View File
@@ -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}>
-1
View File
@@ -39,7 +39,6 @@ export const load = (async ({ fetch }) => {
return {
meta: {
title: $t('welcome') + ' 🎉',
description: $t('immich_web_interface'),
},
};
}) satisfies PageLoad;