Compare commits

..

3 Commits

Author SHA1 Message Date
Alex Tran
b24637ba74 update schema 2025-06-28 22:20:02 -05:00
Alex Tran
d54def39ca drift logs 2025-06-28 17:50:08 -05:00
Alex Tran
f0c9163364 feat: drift store migration 2025-06-27 15:40:48 -05:00
95 changed files with 2392 additions and 1424 deletions

View File

@@ -0,0 +1,146 @@
import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto } from 'src/fixtures';
import { errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
const { email, password } = signupDto.admin;
describe(`/auth/admin-sign-up`, () => {
beforeEach(async () => {
await utils.resetDatabase();
});
describe('POST /auth/admin-sign-up', () => {
it(`should sign up the admin`, async () => {
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(201);
expect(body).toEqual(signupResponseDto.admin);
});
it('should not allow a second admin to sign up', async () => {
await signUpAdmin({ signUpDto: signupDto.admin });
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(400);
expect(body).toEqual(errorDto.alreadyHasAdmin);
});
});
});
describe('/auth/*', () => {
let admin: LoginResponseDto;
beforeEach(async () => {
await utils.resetDatabase();
await signUpAdmin({ signUpDto: signupDto.admin });
admin = await login({ loginCredentialDto: loginDto.admin });
});
describe(`POST /auth/login`, () => {
it('should reject an incorrect password', async () => {
const { status, body } = await request(app).post('/auth/login').send({ email, password: 'incorrect' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.incorrectLogin);
});
it('should accept a correct password', async () => {
const { status, body, headers } = await request(app).post('/auth/login').send({ email, password });
expect(status).toBe(201);
expect(body).toEqual(loginResponseDto.admin);
const token = body.accessToken;
expect(token).toBeDefined();
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3);
expect(cookies[0].split(';').map((item) => item.trim())).toEqual([
`immich_access_token=${token}`,
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[1].split(';').map((item) => item.trim())).toEqual([
'immich_auth_type=password',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[2].split(';').map((item) => item.trim())).toEqual([
'immich_is_authenticated=true',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'SameSite=Lax',
]);
});
});
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(app).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidToken);
});
it('should accept a valid token', async () => {
const { status, body } = await request(app)
.post(`/auth/validateToken`)
.send({})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ authStatus: true });
});
});
describe('POST /auth/change-password', () => {
it('should require the current password', async () => {
const { status, body } = await request(app)
.post(`/auth/change-password`)
.send({ password: 'wrong-password', newPassword: 'Password1234' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.wrongPassword);
});
it('should change the password', async () => {
const { status } = await request(app)
.post(`/auth/change-password`)
.send({ password, newPassword: 'Password1234' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
await login({
loginCredentialDto: {
email: 'admin@immich.cloud',
password: 'Password1234',
},
});
});
});
describe('POST /auth/logout', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/auth/logout`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should logout the user', async () => {
const { status, body } = await request(app)
.post(`/auth/logout`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
});
});
});

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/drift_store.repository.dart';
import 'package:logging/logging.dart';
/// Service responsible for handling application logging.
@@ -14,8 +14,8 @@ import 'package:logging/logging.dart';
/// writes them to a persistent [ILogRepository], and manages log levels
/// via [IStoreRepository]
class LogService {
final IsarLogRepository _logRepository;
final IsarStoreRepository _storeRepository;
final LogRepository _logRepository;
final IStoreRepository _storeRepository;
final List<LogMessage> _msgBuffer = [];
@@ -37,8 +37,8 @@ class LogService {
}
static Future<LogService> init({
required IsarLogRepository logRepository,
required IsarStoreRepository storeRepository,
required LogRepository logRepository,
required IStoreRepository storeRepository,
bool shouldBuffer = true,
}) async {
_instance ??= await create(
@@ -50,8 +50,8 @@ class LogService {
}
static Future<LogService> create({
required IsarLogRepository logRepository,
required IsarStoreRepository storeRepository,
required LogRepository logRepository,
required IStoreRepository storeRepository,
bool shouldBuffer = true,
}) async {
final instance = LogService._(logRepository, storeRepository, shouldBuffer);

View File

@@ -1,18 +1,18 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/drift_store.repository.dart';
/// Provides access to a persistent key-value store with an in-memory cache.
/// Listens for repository changes to keep the cache updated.
class StoreService {
final IsarStoreRepository _storeRepository;
final IStoreRepository _storeRepository;
/// In-memory cache. Keys are [StoreKey.id]
final Map<int, Object?> _cache = {};
late final StreamSubscription<StoreDto> _storeUpdateSubscription;
StoreService._({required IsarStoreRepository storeRepository})
StoreService._({required IStoreRepository storeRepository})
: _storeRepository = storeRepository;
// TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider
@@ -26,14 +26,14 @@ class StoreService {
// TODO: Replace the implementation with the one from create after removing the typedef
static Future<StoreService> init({
required IsarStoreRepository storeRepository,
required IStoreRepository storeRepository,
}) async {
_instance ??= await create(storeRepository: storeRepository);
return _instance!;
}
static Future<StoreService> create({
required IsarStoreRepository storeRepository,
required IStoreRepository storeRepository,
}) async {
final instance = StoreService._(storeRepository: storeRepository);
await instance._populateCache();

View File

@@ -0,0 +1,13 @@
import 'package:isar/isar.dart';
part 'isar_store.entity.g.dart';
/// Internal class for `Store`, do not use elsewhere.
@Collection(inheritance: false)
class StoreValue {
final Id id;
final int? intValue;
final String? strValue;
const StoreValue(this.id, {this.intValue, this.strValue});
}

View File

@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'store.entity.dart';
part of 'isar_store.entity.dart';
// **************************************************************************
// IsarCollectionGenerator

View File

@@ -1,5 +1,7 @@
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
import 'package:isar/isar.dart';
import 'package:drift/drift.dart';
part 'log.entity.g.dart';
@@ -45,3 +47,21 @@ class LoggerMessage {
);
}
}
class LoggerMessageEntity extends Table with DriftDefaultsMixin {
const LoggerMessageEntity();
IntColumn get id => integer().autoIncrement()();
TextColumn get message => text()();
TextColumn get details => text().nullable()();
IntColumn get level => intEnum<LogLevel>()();
DateTimeColumn get createdAt => dateTime()();
TextColumn get context1 => text().nullable()();
TextColumn get context2 => text().nullable()();
}

View File

@@ -0,0 +1,589 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/log.entity.drift.dart'
as i1;
import 'package:immich_mobile/domain/models/log.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/log.entity.dart' as i3;
typedef $$LoggerMessageEntityTableCreateCompanionBuilder
= i1.LoggerMessageEntityCompanion Function({
required int id,
required String message,
i0.Value<String?> details,
required i2.LogLevel level,
required DateTime createdAt,
i0.Value<String?> context1,
i0.Value<String?> context2,
});
typedef $$LoggerMessageEntityTableUpdateCompanionBuilder
= i1.LoggerMessageEntityCompanion Function({
i0.Value<int> id,
i0.Value<String> message,
i0.Value<String?> details,
i0.Value<i2.LogLevel> level,
i0.Value<DateTime> createdAt,
i0.Value<String?> context1,
i0.Value<String?> context2,
});
class $$LoggerMessageEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$LoggerMessageEntityTable> {
$$LoggerMessageEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<int> get id => $composableBuilder(
column: $table.id, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get message => $composableBuilder(
column: $table.message, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get details => $composableBuilder(
column: $table.details, builder: (column) => i0.ColumnFilters(column));
i0.ColumnWithTypeConverterFilters<i2.LogLevel, i2.LogLevel, int> get level =>
$composableBuilder(
column: $table.level,
builder: (column) => i0.ColumnWithTypeConverterFilters(column));
i0.ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get context1 => $composableBuilder(
column: $table.context1, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get context2 => $composableBuilder(
column: $table.context2, builder: (column) => i0.ColumnFilters(column));
}
class $$LoggerMessageEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$LoggerMessageEntityTable> {
$$LoggerMessageEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<int> get id => $composableBuilder(
column: $table.id, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get message => $composableBuilder(
column: $table.message, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get details => $composableBuilder(
column: $table.details, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<int> get level => $composableBuilder(
column: $table.level, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get context1 => $composableBuilder(
column: $table.context1, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get context2 => $composableBuilder(
column: $table.context2, builder: (column) => i0.ColumnOrderings(column));
}
class $$LoggerMessageEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$LoggerMessageEntityTable> {
$$LoggerMessageEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumn<String> get message =>
$composableBuilder(column: $table.message, builder: (column) => column);
i0.GeneratedColumn<String> get details =>
$composableBuilder(column: $table.details, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i2.LogLevel, int> get level =>
$composableBuilder(column: $table.level, builder: (column) => column);
i0.GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
i0.GeneratedColumn<String> get context1 =>
$composableBuilder(column: $table.context1, builder: (column) => column);
i0.GeneratedColumn<String> get context2 =>
$composableBuilder(column: $table.context2, builder: (column) => column);
}
class $$LoggerMessageEntityTableTableManager extends i0.RootTableManager<
i0.GeneratedDatabase,
i1.$LoggerMessageEntityTable,
i1.LoggerMessageEntityData,
i1.$$LoggerMessageEntityTableFilterComposer,
i1.$$LoggerMessageEntityTableOrderingComposer,
i1.$$LoggerMessageEntityTableAnnotationComposer,
$$LoggerMessageEntityTableCreateCompanionBuilder,
$$LoggerMessageEntityTableUpdateCompanionBuilder,
(
i1.LoggerMessageEntityData,
i0.BaseReferences<i0.GeneratedDatabase, i1.$LoggerMessageEntityTable,
i1.LoggerMessageEntityData>
),
i1.LoggerMessageEntityData,
i0.PrefetchHooks Function()> {
$$LoggerMessageEntityTableTableManager(
i0.GeneratedDatabase db, i1.$LoggerMessageEntityTable table)
: super(i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () => i1
.$$LoggerMessageEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$LoggerMessageEntityTableOrderingComposer(
$db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$LoggerMessageEntityTableAnnotationComposer(
$db: db, $table: table),
updateCompanionCallback: ({
i0.Value<int> id = const i0.Value.absent(),
i0.Value<String> message = const i0.Value.absent(),
i0.Value<String?> details = const i0.Value.absent(),
i0.Value<i2.LogLevel> level = const i0.Value.absent(),
i0.Value<DateTime> createdAt = const i0.Value.absent(),
i0.Value<String?> context1 = const i0.Value.absent(),
i0.Value<String?> context2 = const i0.Value.absent(),
}) =>
i1.LoggerMessageEntityCompanion(
id: id,
message: message,
details: details,
level: level,
createdAt: createdAt,
context1: context1,
context2: context2,
),
createCompanionCallback: ({
required int id,
required String message,
i0.Value<String?> details = const i0.Value.absent(),
required i2.LogLevel level,
required DateTime createdAt,
i0.Value<String?> context1 = const i0.Value.absent(),
i0.Value<String?> context2 = const i0.Value.absent(),
}) =>
i1.LoggerMessageEntityCompanion.insert(
id: id,
message: message,
details: details,
level: level,
createdAt: createdAt,
context1: context1,
context2: context2,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$LoggerMessageEntityTableProcessedTableManager
= i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$LoggerMessageEntityTable,
i1.LoggerMessageEntityData,
i1.$$LoggerMessageEntityTableFilterComposer,
i1.$$LoggerMessageEntityTableOrderingComposer,
i1.$$LoggerMessageEntityTableAnnotationComposer,
$$LoggerMessageEntityTableCreateCompanionBuilder,
$$LoggerMessageEntityTableUpdateCompanionBuilder,
(
i1.LoggerMessageEntityData,
i0.BaseReferences<i0.GeneratedDatabase, i1.$LoggerMessageEntityTable,
i1.LoggerMessageEntityData>
),
i1.LoggerMessageEntityData,
i0.PrefetchHooks Function()>;
class $LoggerMessageEntityTable extends i3.LoggerMessageEntity
with i0.TableInfo<$LoggerMessageEntityTable, i1.LoggerMessageEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$LoggerMessageEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
@override
late final i0.GeneratedColumn<int> id = i0.GeneratedColumn<int>(
'id', aliasedName, false,
hasAutoIncrement: true,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
defaultConstraints:
i0.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const i0.VerificationMeta _messageMeta =
const i0.VerificationMeta('message');
@override
late final i0.GeneratedColumn<String> message = i0.GeneratedColumn<String>(
'message', aliasedName, false,
type: i0.DriftSqlType.string, requiredDuringInsert: true);
static const i0.VerificationMeta _detailsMeta =
const i0.VerificationMeta('details');
@override
late final i0.GeneratedColumn<String> details = i0.GeneratedColumn<String>(
'details', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
@override
late final i0.GeneratedColumnWithTypeConverter<i2.LogLevel, int> level =
i0.GeneratedColumn<int>('level', aliasedName, false,
type: i0.DriftSqlType.int, requiredDuringInsert: true)
.withConverter<i2.LogLevel>(
i1.$LoggerMessageEntityTable.$converterlevel);
static const i0.VerificationMeta _createdAtMeta =
const i0.VerificationMeta('createdAt');
@override
late final i0.GeneratedColumn<DateTime> createdAt =
i0.GeneratedColumn<DateTime>('created_at', aliasedName, false,
type: i0.DriftSqlType.dateTime, requiredDuringInsert: true);
static const i0.VerificationMeta _context1Meta =
const i0.VerificationMeta('context1');
@override
late final i0.GeneratedColumn<String> context1 = i0.GeneratedColumn<String>(
'context1', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
static const i0.VerificationMeta _context2Meta =
const i0.VerificationMeta('context2');
@override
late final i0.GeneratedColumn<String> context2 = i0.GeneratedColumn<String>(
'context2', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
@override
List<i0.GeneratedColumn> get $columns =>
[id, message, details, level, createdAt, context1, context2];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'logger_message_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.LoggerMessageEntityData> instance,
{bool isInserting = false}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('message')) {
context.handle(_messageMeta,
message.isAcceptableOrUnknown(data['message']!, _messageMeta));
} else if (isInserting) {
context.missing(_messageMeta);
}
if (data.containsKey('details')) {
context.handle(_detailsMeta,
details.isAcceptableOrUnknown(data['details']!, _detailsMeta));
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
} else if (isInserting) {
context.missing(_createdAtMeta);
}
if (data.containsKey('context1')) {
context.handle(_context1Meta,
context1.isAcceptableOrUnknown(data['context1']!, _context1Meta));
}
if (data.containsKey('context2')) {
context.handle(_context2Meta,
context2.isAcceptableOrUnknown(data['context2']!, _context2Meta));
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {id};
@override
i1.LoggerMessageEntityData map(Map<String, dynamic> data,
{String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.LoggerMessageEntityData(
id: attachedDatabase.typeMapping
.read(i0.DriftSqlType.int, data['${effectivePrefix}id'])!,
message: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}message'])!,
details: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}details']),
level: i1.$LoggerMessageEntityTable.$converterlevel.fromSql(
attachedDatabase.typeMapping
.read(i0.DriftSqlType.int, data['${effectivePrefix}level'])!),
createdAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
context1: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}context1']),
context2: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}context2']),
);
}
@override
$LoggerMessageEntityTable createAlias(String alias) {
return $LoggerMessageEntityTable(attachedDatabase, alias);
}
static i0.JsonTypeConverter2<i2.LogLevel, int, int> $converterlevel =
const i0.EnumIndexConverter<i2.LogLevel>(i2.LogLevel.values);
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class LoggerMessageEntityData extends i0.DataClass
implements i0.Insertable<i1.LoggerMessageEntityData> {
final int id;
final String message;
final String? details;
final i2.LogLevel level;
final DateTime createdAt;
final String? context1;
final String? context2;
const LoggerMessageEntityData(
{required this.id,
required this.message,
this.details,
required this.level,
required this.createdAt,
this.context1,
this.context2});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<int>(id);
map['message'] = i0.Variable<String>(message);
if (!nullToAbsent || details != null) {
map['details'] = i0.Variable<String>(details);
}
{
map['level'] = i0.Variable<int>(
i1.$LoggerMessageEntityTable.$converterlevel.toSql(level));
}
map['created_at'] = i0.Variable<DateTime>(createdAt);
if (!nullToAbsent || context1 != null) {
map['context1'] = i0.Variable<String>(context1);
}
if (!nullToAbsent || context2 != null) {
map['context2'] = i0.Variable<String>(context2);
}
return map;
}
factory LoggerMessageEntityData.fromJson(Map<String, dynamic> json,
{i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return LoggerMessageEntityData(
id: serializer.fromJson<int>(json['id']),
message: serializer.fromJson<String>(json['message']),
details: serializer.fromJson<String?>(json['details']),
level: i1.$LoggerMessageEntityTable.$converterlevel
.fromJson(serializer.fromJson<int>(json['level'])),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
context1: serializer.fromJson<String?>(json['context1']),
context2: serializer.fromJson<String?>(json['context2']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'message': serializer.toJson<String>(message),
'details': serializer.toJson<String?>(details),
'level': serializer.toJson<int>(
i1.$LoggerMessageEntityTable.$converterlevel.toJson(level)),
'createdAt': serializer.toJson<DateTime>(createdAt),
'context1': serializer.toJson<String?>(context1),
'context2': serializer.toJson<String?>(context2),
};
}
i1.LoggerMessageEntityData copyWith(
{int? id,
String? message,
i0.Value<String?> details = const i0.Value.absent(),
i2.LogLevel? level,
DateTime? createdAt,
i0.Value<String?> context1 = const i0.Value.absent(),
i0.Value<String?> context2 = const i0.Value.absent()}) =>
i1.LoggerMessageEntityData(
id: id ?? this.id,
message: message ?? this.message,
details: details.present ? details.value : this.details,
level: level ?? this.level,
createdAt: createdAt ?? this.createdAt,
context1: context1.present ? context1.value : this.context1,
context2: context2.present ? context2.value : this.context2,
);
LoggerMessageEntityData copyWithCompanion(
i1.LoggerMessageEntityCompanion data) {
return LoggerMessageEntityData(
id: data.id.present ? data.id.value : this.id,
message: data.message.present ? data.message.value : this.message,
details: data.details.present ? data.details.value : this.details,
level: data.level.present ? data.level.value : this.level,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
context1: data.context1.present ? data.context1.value : this.context1,
context2: data.context2.present ? data.context2.value : this.context2,
);
}
@override
String toString() {
return (StringBuffer('LoggerMessageEntityData(')
..write('id: $id, ')
..write('message: $message, ')
..write('details: $details, ')
..write('level: $level, ')
..write('createdAt: $createdAt, ')
..write('context1: $context1, ')
..write('context2: $context2')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, message, details, level, createdAt, context1, context2);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.LoggerMessageEntityData &&
other.id == this.id &&
other.message == this.message &&
other.details == this.details &&
other.level == this.level &&
other.createdAt == this.createdAt &&
other.context1 == this.context1 &&
other.context2 == this.context2);
}
class LoggerMessageEntityCompanion
extends i0.UpdateCompanion<i1.LoggerMessageEntityData> {
final i0.Value<int> id;
final i0.Value<String> message;
final i0.Value<String?> details;
final i0.Value<i2.LogLevel> level;
final i0.Value<DateTime> createdAt;
final i0.Value<String?> context1;
final i0.Value<String?> context2;
const LoggerMessageEntityCompanion({
this.id = const i0.Value.absent(),
this.message = const i0.Value.absent(),
this.details = const i0.Value.absent(),
this.level = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.context1 = const i0.Value.absent(),
this.context2 = const i0.Value.absent(),
});
LoggerMessageEntityCompanion.insert({
required int id,
required String message,
this.details = const i0.Value.absent(),
required i2.LogLevel level,
required DateTime createdAt,
this.context1 = const i0.Value.absent(),
this.context2 = const i0.Value.absent(),
}) : id = i0.Value(id),
message = i0.Value(message),
level = i0.Value(level),
createdAt = i0.Value(createdAt);
static i0.Insertable<i1.LoggerMessageEntityData> custom({
i0.Expression<int>? id,
i0.Expression<String>? message,
i0.Expression<String>? details,
i0.Expression<int>? level,
i0.Expression<DateTime>? createdAt,
i0.Expression<String>? context1,
i0.Expression<String>? context2,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (message != null) 'message': message,
if (details != null) 'details': details,
if (level != null) 'level': level,
if (createdAt != null) 'created_at': createdAt,
if (context1 != null) 'context1': context1,
if (context2 != null) 'context2': context2,
});
}
i1.LoggerMessageEntityCompanion copyWith(
{i0.Value<int>? id,
i0.Value<String>? message,
i0.Value<String?>? details,
i0.Value<i2.LogLevel>? level,
i0.Value<DateTime>? createdAt,
i0.Value<String?>? context1,
i0.Value<String?>? context2}) {
return i1.LoggerMessageEntityCompanion(
id: id ?? this.id,
message: message ?? this.message,
details: details ?? this.details,
level: level ?? this.level,
createdAt: createdAt ?? this.createdAt,
context1: context1 ?? this.context1,
context2: context2 ?? this.context2,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<int>(id.value);
}
if (message.present) {
map['message'] = i0.Variable<String>(message.value);
}
if (details.present) {
map['details'] = i0.Variable<String>(details.value);
}
if (level.present) {
map['level'] = i0.Variable<int>(
i1.$LoggerMessageEntityTable.$converterlevel.toSql(level.value));
}
if (createdAt.present) {
map['created_at'] = i0.Variable<DateTime>(createdAt.value);
}
if (context1.present) {
map['context1'] = i0.Variable<String>(context1.value);
}
if (context2.present) {
map['context2'] = i0.Variable<String>(context2.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('LoggerMessageEntityCompanion(')
..write('id: $id, ')
..write('message: $message, ')
..write('details: $details, ')
..write('level: $level, ')
..write('createdAt: $createdAt, ')
..write('context1: $context1, ')
..write('context2: $context2')
..write(')'))
.toString();
}
}

View File

@@ -1,13 +1,13 @@
import 'package:isar/isar.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
part 'store.entity.g.dart';
class StoreEntity extends Table with DriftDefaultsMixin {
const StoreEntity();
/// Internal class for `Store`, do not use elsewhere.
@Collection(inheritance: false)
class StoreValue {
final Id id;
final int? intValue;
final String? strValue;
IntColumn get id => integer()();
IntColumn get intValue => integer().nullable()();
TextColumn get strValue => text().nullable()();
const StoreValue(this.id, {this.intValue, this.strValue});
@override
Set<Column> get primaryKey => {id};
}

View File

@@ -0,0 +1,364 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/store.entity.dart' as i2;
typedef $$StoreEntityTableCreateCompanionBuilder = i1.StoreEntityCompanion
Function({
required int id,
i0.Value<int?> intValue,
i0.Value<String?> strValue,
});
typedef $$StoreEntityTableUpdateCompanionBuilder = i1.StoreEntityCompanion
Function({
i0.Value<int> id,
i0.Value<int?> intValue,
i0.Value<String?> strValue,
});
class $$StoreEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$StoreEntityTable> {
$$StoreEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<int> get id => $composableBuilder(
column: $table.id, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<int> get intValue => $composableBuilder(
column: $table.intValue, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get strValue => $composableBuilder(
column: $table.strValue, builder: (column) => i0.ColumnFilters(column));
}
class $$StoreEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$StoreEntityTable> {
$$StoreEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<int> get id => $composableBuilder(
column: $table.id, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<int> get intValue => $composableBuilder(
column: $table.intValue, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get strValue => $composableBuilder(
column: $table.strValue, builder: (column) => i0.ColumnOrderings(column));
}
class $$StoreEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$StoreEntityTable> {
$$StoreEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumn<int> get intValue =>
$composableBuilder(column: $table.intValue, builder: (column) => column);
i0.GeneratedColumn<String> get strValue =>
$composableBuilder(column: $table.strValue, builder: (column) => column);
}
class $$StoreEntityTableTableManager extends i0.RootTableManager<
i0.GeneratedDatabase,
i1.$StoreEntityTable,
i1.StoreEntityData,
i1.$$StoreEntityTableFilterComposer,
i1.$$StoreEntityTableOrderingComposer,
i1.$$StoreEntityTableAnnotationComposer,
$$StoreEntityTableCreateCompanionBuilder,
$$StoreEntityTableUpdateCompanionBuilder,
(
i1.StoreEntityData,
i0.BaseReferences<i0.GeneratedDatabase, i1.$StoreEntityTable,
i1.StoreEntityData>
),
i1.StoreEntityData,
i0.PrefetchHooks Function()> {
$$StoreEntityTableTableManager(
i0.GeneratedDatabase db, i1.$StoreEntityTable table)
: super(i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$StoreEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$StoreEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$StoreEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
i0.Value<int> id = const i0.Value.absent(),
i0.Value<int?> intValue = const i0.Value.absent(),
i0.Value<String?> strValue = const i0.Value.absent(),
}) =>
i1.StoreEntityCompanion(
id: id,
intValue: intValue,
strValue: strValue,
),
createCompanionCallback: ({
required int id,
i0.Value<int?> intValue = const i0.Value.absent(),
i0.Value<String?> strValue = const i0.Value.absent(),
}) =>
i1.StoreEntityCompanion.insert(
id: id,
intValue: intValue,
strValue: strValue,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$StoreEntityTableProcessedTableManager = i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$StoreEntityTable,
i1.StoreEntityData,
i1.$$StoreEntityTableFilterComposer,
i1.$$StoreEntityTableOrderingComposer,
i1.$$StoreEntityTableAnnotationComposer,
$$StoreEntityTableCreateCompanionBuilder,
$$StoreEntityTableUpdateCompanionBuilder,
(
i1.StoreEntityData,
i0.BaseReferences<i0.GeneratedDatabase, i1.$StoreEntityTable,
i1.StoreEntityData>
),
i1.StoreEntityData,
i0.PrefetchHooks Function()>;
class $StoreEntityTable extends i2.StoreEntity
with i0.TableInfo<$StoreEntityTable, i1.StoreEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$StoreEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
@override
late final i0.GeneratedColumn<int> id = i0.GeneratedColumn<int>(
'id', aliasedName, false,
type: i0.DriftSqlType.int, requiredDuringInsert: true);
static const i0.VerificationMeta _intValueMeta =
const i0.VerificationMeta('intValue');
@override
late final i0.GeneratedColumn<int> intValue = i0.GeneratedColumn<int>(
'int_value', aliasedName, true,
type: i0.DriftSqlType.int, requiredDuringInsert: false);
static const i0.VerificationMeta _strValueMeta =
const i0.VerificationMeta('strValue');
@override
late final i0.GeneratedColumn<String> strValue = i0.GeneratedColumn<String>(
'str_value', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
@override
List<i0.GeneratedColumn> get $columns => [id, intValue, strValue];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'store_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.StoreEntityData> instance,
{bool isInserting = false}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('int_value')) {
context.handle(_intValueMeta,
intValue.isAcceptableOrUnknown(data['int_value']!, _intValueMeta));
}
if (data.containsKey('str_value')) {
context.handle(_strValueMeta,
strValue.isAcceptableOrUnknown(data['str_value']!, _strValueMeta));
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {id};
@override
i1.StoreEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.StoreEntityData(
id: attachedDatabase.typeMapping
.read(i0.DriftSqlType.int, data['${effectivePrefix}id'])!,
intValue: attachedDatabase.typeMapping
.read(i0.DriftSqlType.int, data['${effectivePrefix}int_value']),
strValue: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}str_value']),
);
}
@override
$StoreEntityTable createAlias(String alias) {
return $StoreEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class StoreEntityData extends i0.DataClass
implements i0.Insertable<i1.StoreEntityData> {
final int id;
final int? intValue;
final String? strValue;
const StoreEntityData({required this.id, this.intValue, this.strValue});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<int>(id);
if (!nullToAbsent || intValue != null) {
map['int_value'] = i0.Variable<int>(intValue);
}
if (!nullToAbsent || strValue != null) {
map['str_value'] = i0.Variable<String>(strValue);
}
return map;
}
factory StoreEntityData.fromJson(Map<String, dynamic> json,
{i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return StoreEntityData(
id: serializer.fromJson<int>(json['id']),
intValue: serializer.fromJson<int?>(json['intValue']),
strValue: serializer.fromJson<String?>(json['strValue']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'intValue': serializer.toJson<int?>(intValue),
'strValue': serializer.toJson<String?>(strValue),
};
}
i1.StoreEntityData copyWith(
{int? id,
i0.Value<int?> intValue = const i0.Value.absent(),
i0.Value<String?> strValue = const i0.Value.absent()}) =>
i1.StoreEntityData(
id: id ?? this.id,
intValue: intValue.present ? intValue.value : this.intValue,
strValue: strValue.present ? strValue.value : this.strValue,
);
StoreEntityData copyWithCompanion(i1.StoreEntityCompanion data) {
return StoreEntityData(
id: data.id.present ? data.id.value : this.id,
intValue: data.intValue.present ? data.intValue.value : this.intValue,
strValue: data.strValue.present ? data.strValue.value : this.strValue,
);
}
@override
String toString() {
return (StringBuffer('StoreEntityData(')
..write('id: $id, ')
..write('intValue: $intValue, ')
..write('strValue: $strValue')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, intValue, strValue);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.StoreEntityData &&
other.id == this.id &&
other.intValue == this.intValue &&
other.strValue == this.strValue);
}
class StoreEntityCompanion extends i0.UpdateCompanion<i1.StoreEntityData> {
final i0.Value<int> id;
final i0.Value<int?> intValue;
final i0.Value<String?> strValue;
const StoreEntityCompanion({
this.id = const i0.Value.absent(),
this.intValue = const i0.Value.absent(),
this.strValue = const i0.Value.absent(),
});
StoreEntityCompanion.insert({
required int id,
this.intValue = const i0.Value.absent(),
this.strValue = const i0.Value.absent(),
}) : id = i0.Value(id);
static i0.Insertable<i1.StoreEntityData> custom({
i0.Expression<int>? id,
i0.Expression<int>? intValue,
i0.Expression<String>? strValue,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (intValue != null) 'int_value': intValue,
if (strValue != null) 'str_value': strValue,
});
}
i1.StoreEntityCompanion copyWith(
{i0.Value<int>? id,
i0.Value<int?>? intValue,
i0.Value<String?>? strValue}) {
return i1.StoreEntityCompanion(
id: id ?? this.id,
intValue: intValue ?? this.intValue,
strValue: strValue ?? this.strValue,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<int>(id.value);
}
if (intValue.present) {
map['int_value'] = i0.Variable<int>(intValue.value);
}
if (strValue.present) {
map['str_value'] = i0.Variable<String>(strValue.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('StoreEntityCompanion(')
..write('id: $id, ')
..write('intValue: $intValue, ')
..write('strValue: $strValue')
..write(')'))
.toString();
}
}

View File

@@ -7,11 +7,13 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:isar/isar.dart';
@@ -46,6 +48,8 @@ class IsarDatabaseRepository implements IDatabaseRepository {
RemoteAlbumEntity,
RemoteAlbumAssetEntity,
RemoteAlbumUserEntity,
StoreEntity,
LoggerMessageEntity,
],
include: {
'package:immich_mobile/infrastructure/entities/merged_asset.drift',

View File

@@ -23,9 +23,13 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
as i10;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
as i11;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i12;
import 'package:drift/internal/modular.dart' as i13;
import 'package:immich_mobile/infrastructure/entities/log.entity.drift.dart'
as i13;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i14;
import 'package:drift/internal/modular.dart' as i15;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -51,8 +55,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
i10.$RemoteAlbumAssetEntityTable(this);
late final i11.$RemoteAlbumUserEntityTable remoteAlbumUserEntity =
i11.$RemoteAlbumUserEntityTable(this);
i12.MergedAssetDrift get mergedAssetDrift => i13.ReadDatabaseContainer(this)
.accessor<i12.MergedAssetDrift>(i12.MergedAssetDrift.new);
late final i12.$StoreEntityTable storeEntity = i12.$StoreEntityTable(this);
late final i13.$LoggerMessageEntityTable loggerMessageEntity =
i13.$LoggerMessageEntityTable(this);
i14.MergedAssetDrift get mergedAssetDrift => i15.ReadDatabaseContainer(this)
.accessor<i14.MergedAssetDrift>(i14.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -71,7 +78,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteExifEntity,
remoteAlbumEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity
remoteAlbumUserEntity,
storeEntity,
loggerMessageEntity
];
@override
i0.StreamQueryUpdateRules get streamUpdateRules =>
@@ -208,4 +217,8 @@ class $DriftManager {
_db, _db.remoteAlbumAssetEntity);
i11.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i11
.$$RemoteAlbumUserEntityTableTableManager(_db, _db.remoteAlbumUserEntity);
i12.$$StoreEntityTableTableManager get storeEntity =>
i12.$$StoreEntityTableTableManager(_db, _db.storeEntity);
i13.$$LoggerMessageEntityTableTableManager get loggerMessageEntity =>
i13.$$LoggerMessageEntityTableTableManager(_db, _db.loggerMessageEntity);
}

View File

@@ -0,0 +1,145 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/drift_user.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final driftStoreRepositoryProvider = Provider<DriftStoreRepository>(
(ref) => DriftStoreRepository(ref.watch(driftProvider)),
);
class DriftStoreRepository implements IStoreRepository {
final Drift _db;
final validStoreKeys = StoreKey.values.map((e) => e.id).toSet();
DriftStoreRepository(this._db);
@override
Future<bool> deleteAll() async {
return await _db.transaction(() async {
await _db.delete(_db.storeEntity).go();
return true;
});
}
@override
Stream<StoreDto<Object>> watchAll() {
return (_db.select(_db.storeEntity)
..where((tbl) => tbl.id.isIn(validStoreKeys)))
.watch()
.asyncExpand(
(entities) => Stream.fromFutures(
entities.map((e) async => _toUpdateEvent(e)),
),
);
}
@override
Future<void> delete<T>(StoreKey<T> key) async {
return await _db.transaction(() async {
await (_db.delete(_db.storeEntity)..where((tbl) => tbl.id.equals(key.id)))
.go();
});
}
@override
Future<bool> insert<T>(StoreKey<T> key, T value) async {
return await _db.transaction(() async {
await _db
.into(_db.storeEntity)
.insertOnConflictUpdate(await _fromValue(key, value));
return true;
});
}
@override
Future<T?> tryGet<T>(StoreKey<T> key) async {
final entity = await (_db.select(_db.storeEntity)
..where((tbl) => tbl.id.equals(key.id)))
.getSingleOrNull();
if (entity == null) {
return null;
}
return await _toValue(key, entity);
}
@override
Future<bool> update<T>(StoreKey<T> key, T value) async {
return await _db.transaction(() async {
await _db
.into(_db.storeEntity)
.insertOnConflictUpdate(await _fromValue(key, value));
return true;
});
}
@override
Stream<T?> watch<T>(StoreKey<T> key) async* {
yield* (_db.select(_db.storeEntity)..where((tbl) => tbl.id.equals(key.id)))
.watchSingleOrNull()
.asyncMap((e) async => e == null ? null : await _toValue(key, e));
}
Future<StoreDto<Object>> _toUpdateEvent(StoreEntityData entity) async {
final key = StoreKey.values.firstWhere((e) => e.id == entity.id)
as StoreKey<Object>;
final value = await _toValue(key, entity);
return StoreDto(key, value);
}
Future<T?> _toValue<T>(StoreKey<T> key, StoreEntityData entity) async =>
switch (key.type) {
const (int) => entity.intValue,
const (String) => entity.strValue,
const (bool) => entity.intValue == 1,
const (DateTime) => entity.intValue == null
? null
: DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
const (UserDto) => entity.strValue == null
? null
: await DriftUserRepository(_db).getByUserId(entity.strValue!),
_ => null,
} as T?;
Future<StoreEntityData> _fromValue<T>(StoreKey<T> key, T value) async {
final (int? intValue, String? strValue) = switch (key.type) {
const (int) => (value as int, null),
const (String) => (null, value as String),
const (bool) => ((value as bool) ? 1 : 0, null),
const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null),
const (UserDto) => (
null,
(await DriftUserRepository(_db).update(value as UserDto)).id,
),
_ => throw UnsupportedError(
"Unsupported primitive type: ${key.type} for key: ${key.name}",
),
};
return StoreEntityData(
id: key.id,
intValue: intValue,
strValue: strValue,
);
}
@override
Future<List<StoreDto<Object>>> getAll() async {
final entities = await (_db.select(_db.storeEntity)
..where((tbl) => tbl.id.isIn(validStoreKeys)))
.get();
return Future.wait(entities.map((e) => _toUpdateEvent(e)).toList());
}
}
abstract class IStoreRepository {
Future<bool> deleteAll();
Stream<StoreDto<Object>> watchAll();
Future<void> delete<T>(StoreKey<T> key);
Future<bool> insert<T>(StoreKey<T> key, T value);
Future<T?> tryGet<T>(StoreKey<T> key);
Future<bool> update<T>(StoreKey<T> key, T value);
Stream<T?> watch<T>(StoreKey<T> key);
Future<List<StoreDto<Object>>> getAll();
}

View File

@@ -0,0 +1,117 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
class DriftUserRepository {
final Drift _db;
const DriftUserRepository(this._db);
Future<void> delete(List<String> ids) async {
await _db.transaction(() async {
await (_db.delete(_db.userEntity)..where((tbl) => tbl.id.isIn(ids))).go();
});
}
Future<void> deleteAll() async {
await _db.transaction(() async {
await _db.delete(_db.userEntity).go();
});
}
Future<List<UserDto>> getAll({SortUserBy? sortBy}) async {
var query = _db.select(_db.userEntity);
if (sortBy != null) {
switch (sortBy) {
case SortUserBy.id:
query = query..orderBy([(u) => OrderingTerm.asc(u.id)]);
}
}
final users = await query.get();
return users.map((u) => _toDto(u)).toList();
}
Future<UserDto?> getByUserId(String id) async {
final user = await (_db.select(_db.userEntity)
..where((tbl) => tbl.id.equals(id)))
.getSingleOrNull();
return user != null ? _toDto(user) : null;
}
Future<List<UserDto?>> getByUserIds(List<String> ids) async {
final users = await (_db.select(_db.userEntity)
..where((tbl) => tbl.id.isIn(ids)))
.get();
// Create a map for quick lookup
final userMap = {for (var user in users) user.id: _toDto(user)};
// Return results in the same order as input ids
return ids.map((id) => userMap[id]).toList();
}
Future<bool> insert(UserDto user) async {
await _db.transaction(() async {
await _db.into(_db.userEntity).insertOnConflictUpdate(_fromDto(user));
});
return true;
}
Future<UserDto> update(UserDto user) async {
await _db.transaction(() async {
await _db.into(_db.userEntity).insertOnConflictUpdate(_fromDto(user));
});
return user;
}
Future<bool> updateAll(List<UserDto> users) async {
await _db.transaction(() async {
await _db.batch((batch) {
for (final user in users) {
batch.insert(_db.userEntity, _fromDto(user),
mode: InsertMode.insertOrReplace);
}
});
});
return true;
}
UserDto _toDto(UserEntityData entity) {
return UserDto(
id: entity.id,
updatedAt: entity.updatedAt,
email: entity.email,
name: entity.name,
isAdmin: entity.isAdmin,
profileImagePath: entity.profileImagePath ?? '',
// Note: These fields are not in the current UserEntity table but are in UserDto
// You may need to add them to the table or provide defaults
isPartnerSharedBy: false,
isPartnerSharedWith: false,
avatarColor: AvatarColor.primary,
memoryEnabled: true,
inTimeline: false,
quotaUsageInBytes: entity.quotaUsageInBytes,
quotaSizeInBytes: entity.quotaSizeInBytes ?? 0,
);
}
UserEntityCompanion _fromDto(UserDto dto) {
return UserEntityCompanion(
id: Value(dto.id),
name: Value(dto.name),
isAdmin: Value(dto.isAdmin),
email: Value(dto.email),
profileImagePath: Value.absentIfNull(
dto.profileImagePath?.isEmpty == true ? null : dto.profileImagePath),
updatedAt: Value(dto.updatedAt),
quotaSizeInBytes: Value.absentIfNull(
dto.quotaSizeInBytes == 0 ? null : dto.quotaSizeInBytes),
quotaUsageInBytes: Value(dto.quotaUsageInBytes),
);
}
}

View File

@@ -1,46 +1,99 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:isar/isar.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class IsarLogRepository extends IsarDatabaseRepository {
final Isar _db;
const IsarLogRepository(super.db) : _db = db;
final driftLogRepositoryProvider = Provider<LogRepository>(
(ref) => LogRepository(ref.watch(driftProvider)),
);
class LogRepository {
final Drift _db;
const LogRepository(this._db);
Future<bool> deleteAll() async {
await transaction(() async => await _db.loggerMessages.clear());
await _db.transaction(() async {
await _db.delete(_db.loggerMessageEntity).go();
});
return true;
}
Future<List<LogMessage>> getAll() async {
final logs =
await _db.loggerMessages.where().sortByCreatedAtDesc().findAll();
return logs.map((l) => l.toDto()).toList();
final query = _db.select(_db.loggerMessageEntity)
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]);
final results = await query.get();
return results
.map(
(row) => LogMessage(
message: row.message,
level: row.level,
createdAt: row.createdAt,
logger: row.context1,
error: row.details,
stack: row.context2,
),
)
.toList();
}
Future<bool> insert(LogMessage log) async {
final logEntity = LoggerMessage.fromDto(log);
await transaction(() async {
await _db.loggerMessages.put(logEntity);
await _db.transaction(() async {
await _db.into(_db.loggerMessageEntity).insert(
LoggerMessageEntityCompanion.insert(
id: 0, // Will be auto-incremented by the database
message: log.message,
details: Value(log.error),
level: log.level,
createdAt: log.createdAt,
context1: Value(log.logger),
context2: Value(log.stack),
),
);
});
return true;
}
Future<bool> insertAll(Iterable<LogMessage> logs) async {
await transaction(() async {
final logEntities =
logs.map((log) => LoggerMessage.fromDto(log)).toList();
await _db.loggerMessages.putAll(logEntities);
await _db.transaction(() async {
for (final log in logs) {
await _db.into(_db.loggerMessageEntity).insert(
LoggerMessageEntityCompanion.insert(
id: 0, // Will be auto-incremented by the database
message: log.message,
details: Value(log.error),
level: log.level,
createdAt: log.createdAt,
context1: Value(log.logger),
context2: Value(log.stack),
),
);
}
});
return true;
}
Future<void> truncate({int limit = 250}) async {
await transaction(() async {
final count = await _db.loggerMessages.count();
await _db.transaction(() async {
final countQuery = _db.selectOnly(_db.loggerMessageEntity)
..addColumns([_db.loggerMessageEntity.id.count()]);
final countResult = await countQuery.getSingle();
final count = countResult.read(_db.loggerMessageEntity.id.count()) ?? 0;
if (count <= limit) return;
final toRemove = count - limit;
await _db.loggerMessages.where().limit(toRemove).deleteAll();
final oldestIds = await (_db.select(_db.loggerMessageEntity)
..orderBy([(t) => OrderingTerm.asc(t.createdAt)])
..limit(toRemove))
.get();
final idsToDelete = oldestIds.map((row) => row.id).toList();
await (_db.delete(_db.loggerMessageEntity)
..where((tbl) => tbl.id.isIn(idsToDelete)))
.go();
});
}
}

View File

@@ -1,16 +1,19 @@
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/isar_store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/drift_store.repository.dart';
import 'package:isar/isar.dart';
class IsarStoreRepository extends IsarDatabaseRepository {
class IsarStoreRepository extends IsarDatabaseRepository
implements IStoreRepository {
final Isar _db;
final validStoreKeys = StoreKey.values.map((e) => e.id).toSet();
IsarStoreRepository(super.db) : _db = db;
@override
Future<bool> deleteAll() async {
return await transaction(() async {
await _db.storeValues.clear();
@@ -18,6 +21,7 @@ class IsarStoreRepository extends IsarDatabaseRepository {
});
}
@override
Stream<StoreDto<Object>> watchAll() {
return _db.storeValues
.filter()
@@ -30,10 +34,12 @@ class IsarStoreRepository extends IsarDatabaseRepository {
);
}
@override
Future<void> delete<T>(StoreKey<T> key) async {
return await transaction(() async => await _db.storeValues.delete(key.id));
}
@override
Future<bool> insert<T>(StoreKey<T> key, T value) async {
return await transaction(() async {
await _db.storeValues.put(await _fromValue(key, value));
@@ -41,6 +47,7 @@ class IsarStoreRepository extends IsarDatabaseRepository {
});
}
@override
Future<T?> tryGet<T>(StoreKey<T> key) async {
final entity = (await _db.storeValues.get(key.id));
if (entity == null) {
@@ -49,6 +56,7 @@ class IsarStoreRepository extends IsarDatabaseRepository {
return await _toValue(key, entity);
}
@override
Future<bool> update<T>(StoreKey<T> key, T value) async {
return await transaction(() async {
await _db.storeValues.put(await _fromValue(key, value));
@@ -56,6 +64,7 @@ class IsarStoreRepository extends IsarDatabaseRepository {
});
}
@override
Stream<T?> watch<T>(StoreKey<T> key) async* {
yield* _db.storeValues
.watchObject(key.id, fireImmediately: true)
@@ -100,6 +109,7 @@ class IsarStoreRepository extends IsarDatabaseRepository {
return StoreValue(key.id, intValue: intValue, strValue: strValue);
}
@override
Future<List<StoreDto<Object>>> getAll() async {
final entities = await _db.storeValues
.filter()

View File

@@ -1,12 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
// ignore: import_rule_isar
import 'package:isar/isar.dart';
const kDevLoggerTag = 'DEV';
@@ -14,28 +13,38 @@ abstract final class DLog {
const DLog();
static Stream<List<LogMessage>> watchLog() {
final db = Isar.getInstance();
if (db == null) {
return const Stream.empty();
}
final db = Drift();
return db.loggerMessages
.filter()
.context1EqualTo(kDevLoggerTag)
.sortByCreatedAtDesc()
.watch(fireImmediately: true)
.map((logs) => logs.map((log) => log.toDto()).toList());
final query = db.select(db.loggerMessageEntity)
..where((tbl) => tbl.context1.equals(kDevLoggerTag))
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]);
return query.watch().map(
(rows) => rows
.map(
(row) => LogMessage(
message: row.message,
level: row.level,
createdAt: row.createdAt,
logger: row.context1,
error: row.details,
stack: row.context2,
),
)
.toList(),
);
}
static void clearLog() {
final db = Isar.getInstance();
if (db == null) {
return;
}
final db = Drift();
db.writeTxnSync(() {
db.loggerMessages.filter().context1EqualTo(kDevLoggerTag).deleteAllSync();
});
unawaited(
db.transaction(() async {
await (db.delete(db.loggerMessageEntity)
..where((tbl) => tbl.context1.equals(kDevLoggerTag)))
.go();
}),
);
}
static void log(String message, [Object? error, StackTrace? stackTrace]) {
@@ -49,10 +58,7 @@ abstract final class DLog {
debugPrint('StackTrace: $stackTrace');
}
final isar = Isar.getInstance();
if (isar == null) {
return;
}
final db = Drift();
final record = LogMessage(
message: message,
@@ -63,6 +69,6 @@ abstract final class DLog {
stack: stackTrace?.toString(),
);
unawaited(IsarLogRepository(isar).insert(record));
unawaited(LogRepository(db).insert(record));
}
}

View File

@@ -5,8 +5,11 @@ import 'package:drift/drift.dart' hide Column;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/drift_store.repository.dart';
import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@@ -14,6 +17,32 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/routing/router.dart';
final _features = [
_Feature(
name: 'test',
icon: Icons.abc,
onTap: (_, ref) {
final UserDto value = UserDto(
id: "1234",
email: "alex@email.com",
name: "alex",
isAdmin: true,
updatedAt: DateTime.now(),
);
ref
.read(driftStoreRepositoryProvider)
.insert(StoreKey.serverUrl, "https://example.com");
final readback =
ref.read(driftStoreRepositoryProvider).tryGet(StoreKey.serverUrl);
readback.then((value) {
print("Read back: $value");
});
return Future.value();
},
),
_Feature(
name: 'Sync Local',
icon: Icons.photo_album_rounded,

View File

@@ -14,7 +14,7 @@ part of 'router.dart';
/// [ActivitiesPage]
class ActivitiesRoute extends PageRouteInfo<void> {
const ActivitiesRoute({List<PageRouteInfo>? children})
: super(ActivitiesRoute.name, initialChildren: children);
: super(ActivitiesRoute.name, initialChildren: children);
static const String name = 'ActivitiesRoute';
@@ -35,13 +35,13 @@ class AlbumAdditionalSharedUserSelectionRoute
required Album album,
List<PageRouteInfo>? children,
}) : super(
AlbumAdditionalSharedUserSelectionRoute.name,
args: AlbumAdditionalSharedUserSelectionRouteArgs(
key: key,
album: album,
),
initialChildren: children,
);
AlbumAdditionalSharedUserSelectionRoute.name,
args: AlbumAdditionalSharedUserSelectionRouteArgs(
key: key,
album: album,
),
initialChildren: children,
);
static const String name = 'AlbumAdditionalSharedUserSelectionRoute';
@@ -83,14 +83,14 @@ class AlbumAssetSelectionRoute
bool canDeselect = false,
List<PageRouteInfo>? children,
}) : super(
AlbumAssetSelectionRoute.name,
args: AlbumAssetSelectionRouteArgs(
key: key,
existingAssets: existingAssets,
canDeselect: canDeselect,
),
initialChildren: children,
);
AlbumAssetSelectionRoute.name,
args: AlbumAssetSelectionRouteArgs(
key: key,
existingAssets: existingAssets,
canDeselect: canDeselect,
),
initialChildren: children,
);
static const String name = 'AlbumAssetSelectionRoute';
@@ -130,7 +130,7 @@ class AlbumAssetSelectionRouteArgs {
/// [AlbumOptionsPage]
class AlbumOptionsRoute extends PageRouteInfo<void> {
const AlbumOptionsRoute({List<PageRouteInfo>? children})
: super(AlbumOptionsRoute.name, initialChildren: children);
: super(AlbumOptionsRoute.name, initialChildren: children);
static const String name = 'AlbumOptionsRoute';
@@ -150,10 +150,10 @@ class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
required Album album,
List<PageRouteInfo>? children,
}) : super(
AlbumPreviewRoute.name,
args: AlbumPreviewRouteArgs(key: key, album: album),
initialChildren: children,
);
AlbumPreviewRoute.name,
args: AlbumPreviewRouteArgs(key: key, album: album),
initialChildren: children,
);
static const String name = 'AlbumPreviewRoute';
@@ -188,10 +188,10 @@ class AlbumSharedUserSelectionRoute
required Set<Asset> assets,
List<PageRouteInfo>? children,
}) : super(
AlbumSharedUserSelectionRoute.name,
args: AlbumSharedUserSelectionRouteArgs(key: key, assets: assets),
initialChildren: children,
);
AlbumSharedUserSelectionRoute.name,
args: AlbumSharedUserSelectionRouteArgs(key: key, assets: assets),
initialChildren: children,
);
static const String name = 'AlbumSharedUserSelectionRoute';
@@ -225,10 +225,10 @@ class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
required int albumId,
List<PageRouteInfo>? children,
}) : super(
AlbumViewerRoute.name,
args: AlbumViewerRouteArgs(key: key, albumId: albumId),
initialChildren: children,
);
AlbumViewerRoute.name,
args: AlbumViewerRouteArgs(key: key, albumId: albumId),
initialChildren: children,
);
static const String name = 'AlbumViewerRoute';
@@ -258,7 +258,7 @@ class AlbumViewerRouteArgs {
/// [AlbumsPage]
class AlbumsRoute extends PageRouteInfo<void> {
const AlbumsRoute({List<PageRouteInfo>? children})
: super(AlbumsRoute.name, initialChildren: children);
: super(AlbumsRoute.name, initialChildren: children);
static const String name = 'AlbumsRoute';
@@ -274,7 +274,7 @@ class AlbumsRoute extends PageRouteInfo<void> {
/// [AllMotionPhotosPage]
class AllMotionPhotosRoute extends PageRouteInfo<void> {
const AllMotionPhotosRoute({List<PageRouteInfo>? children})
: super(AllMotionPhotosRoute.name, initialChildren: children);
: super(AllMotionPhotosRoute.name, initialChildren: children);
static const String name = 'AllMotionPhotosRoute';
@@ -290,7 +290,7 @@ class AllMotionPhotosRoute extends PageRouteInfo<void> {
/// [AllPeoplePage]
class AllPeopleRoute extends PageRouteInfo<void> {
const AllPeopleRoute({List<PageRouteInfo>? children})
: super(AllPeopleRoute.name, initialChildren: children);
: super(AllPeopleRoute.name, initialChildren: children);
static const String name = 'AllPeopleRoute';
@@ -306,7 +306,7 @@ class AllPeopleRoute extends PageRouteInfo<void> {
/// [AllPlacesPage]
class AllPlacesRoute extends PageRouteInfo<void> {
const AllPlacesRoute({List<PageRouteInfo>? children})
: super(AllPlacesRoute.name, initialChildren: children);
: super(AllPlacesRoute.name, initialChildren: children);
static const String name = 'AllPlacesRoute';
@@ -322,7 +322,7 @@ class AllPlacesRoute extends PageRouteInfo<void> {
/// [AllVideosPage]
class AllVideosRoute extends PageRouteInfo<void> {
const AllVideosRoute({List<PageRouteInfo>? children})
: super(AllVideosRoute.name, initialChildren: children);
: super(AllVideosRoute.name, initialChildren: children);
static const String name = 'AllVideosRoute';
@@ -342,10 +342,10 @@ class AppLogDetailRoute extends PageRouteInfo<AppLogDetailRouteArgs> {
required LogMessage logMessage,
List<PageRouteInfo>? children,
}) : super(
AppLogDetailRoute.name,
args: AppLogDetailRouteArgs(key: key, logMessage: logMessage),
initialChildren: children,
);
AppLogDetailRoute.name,
args: AppLogDetailRouteArgs(key: key, logMessage: logMessage),
initialChildren: children,
);
static const String name = 'AppLogDetailRoute';
@@ -375,7 +375,7 @@ class AppLogDetailRouteArgs {
/// [AppLogPage]
class AppLogRoute extends PageRouteInfo<void> {
const AppLogRoute({List<PageRouteInfo>? children})
: super(AppLogRoute.name, initialChildren: children);
: super(AppLogRoute.name, initialChildren: children);
static const String name = 'AppLogRoute';
@@ -391,7 +391,7 @@ class AppLogRoute extends PageRouteInfo<void> {
/// [ArchivePage]
class ArchiveRoute extends PageRouteInfo<void> {
const ArchiveRoute({List<PageRouteInfo>? children})
: super(ArchiveRoute.name, initialChildren: children);
: super(ArchiveRoute.name, initialChildren: children);
static const String name = 'ArchiveRoute';
@@ -407,7 +407,7 @@ class ArchiveRoute extends PageRouteInfo<void> {
/// [BackupAlbumSelectionPage]
class BackupAlbumSelectionRoute extends PageRouteInfo<void> {
const BackupAlbumSelectionRoute({List<PageRouteInfo>? children})
: super(BackupAlbumSelectionRoute.name, initialChildren: children);
: super(BackupAlbumSelectionRoute.name, initialChildren: children);
static const String name = 'BackupAlbumSelectionRoute';
@@ -423,7 +423,7 @@ class BackupAlbumSelectionRoute extends PageRouteInfo<void> {
/// [BackupControllerPage]
class BackupControllerRoute extends PageRouteInfo<void> {
const BackupControllerRoute({List<PageRouteInfo>? children})
: super(BackupControllerRoute.name, initialChildren: children);
: super(BackupControllerRoute.name, initialChildren: children);
static const String name = 'BackupControllerRoute';
@@ -439,7 +439,7 @@ class BackupControllerRoute extends PageRouteInfo<void> {
/// [BackupOptionsPage]
class BackupOptionsRoute extends PageRouteInfo<void> {
const BackupOptionsRoute({List<PageRouteInfo>? children})
: super(BackupOptionsRoute.name, initialChildren: children);
: super(BackupOptionsRoute.name, initialChildren: children);
static const String name = 'BackupOptionsRoute';
@@ -455,7 +455,7 @@ class BackupOptionsRoute extends PageRouteInfo<void> {
/// [ChangePasswordPage]
class ChangePasswordRoute extends PageRouteInfo<void> {
const ChangePasswordRoute({List<PageRouteInfo>? children})
: super(ChangePasswordRoute.name, initialChildren: children);
: super(ChangePasswordRoute.name, initialChildren: children);
static const String name = 'ChangePasswordRoute';
@@ -475,10 +475,10 @@ class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
List<Asset>? assets,
List<PageRouteInfo>? children,
}) : super(
CreateAlbumRoute.name,
args: CreateAlbumRouteArgs(key: key, assets: assets),
initialChildren: children,
);
CreateAlbumRoute.name,
args: CreateAlbumRouteArgs(key: key, assets: assets),
initialChildren: children,
);
static const String name = 'CreateAlbumRoute';
@@ -515,10 +515,10 @@ class CropImageRoute extends PageRouteInfo<CropImageRouteArgs> {
required Asset asset,
List<PageRouteInfo>? children,
}) : super(
CropImageRoute.name,
args: CropImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
CropImageRoute.name,
args: CropImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'CropImageRoute';
@@ -560,15 +560,15 @@ class EditImageRoute extends PageRouteInfo<EditImageRouteArgs> {
required bool isEdited,
List<PageRouteInfo>? children,
}) : super(
EditImageRoute.name,
args: EditImageRouteArgs(
key: key,
asset: asset,
image: image,
isEdited: isEdited,
),
initialChildren: children,
);
EditImageRoute.name,
args: EditImageRouteArgs(
key: key,
asset: asset,
image: image,
isEdited: isEdited,
),
initialChildren: children,
);
static const String name = 'EditImageRoute';
@@ -612,7 +612,7 @@ class EditImageRouteArgs {
/// [FailedBackupStatusPage]
class FailedBackupStatusRoute extends PageRouteInfo<void> {
const FailedBackupStatusRoute({List<PageRouteInfo>? children})
: super(FailedBackupStatusRoute.name, initialChildren: children);
: super(FailedBackupStatusRoute.name, initialChildren: children);
static const String name = 'FailedBackupStatusRoute';
@@ -628,7 +628,7 @@ class FailedBackupStatusRoute extends PageRouteInfo<void> {
/// [FavoritesPage]
class FavoritesRoute extends PageRouteInfo<void> {
const FavoritesRoute({List<PageRouteInfo>? children})
: super(FavoritesRoute.name, initialChildren: children);
: super(FavoritesRoute.name, initialChildren: children);
static const String name = 'FavoritesRoute';
@@ -644,7 +644,7 @@ class FavoritesRoute extends PageRouteInfo<void> {
/// [FeatInDevPage]
class FeatInDevRoute extends PageRouteInfo<void> {
const FeatInDevRoute({List<PageRouteInfo>? children})
: super(FeatInDevRoute.name, initialChildren: children);
: super(FeatInDevRoute.name, initialChildren: children);
static const String name = 'FeatInDevRoute';
@@ -665,10 +665,10 @@ class FilterImageRoute extends PageRouteInfo<FilterImageRouteArgs> {
required Asset asset,
List<PageRouteInfo>? children,
}) : super(
FilterImageRoute.name,
args: FilterImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
FilterImageRoute.name,
args: FilterImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'FilterImageRoute';
@@ -712,10 +712,10 @@ class FolderRoute extends PageRouteInfo<FolderRouteArgs> {
RecursiveFolder? folder,
List<PageRouteInfo>? children,
}) : super(
FolderRoute.name,
args: FolderRouteArgs(key: key, folder: folder),
initialChildren: children,
);
FolderRoute.name,
args: FolderRouteArgs(key: key, folder: folder),
initialChildren: children,
);
static const String name = 'FolderRoute';
@@ -754,16 +754,16 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
bool showStack = false,
List<PageRouteInfo>? children,
}) : super(
GalleryViewerRoute.name,
args: GalleryViewerRouteArgs(
key: key,
renderList: renderList,
initialIndex: initialIndex,
heroOffset: heroOffset,
showStack: showStack,
),
initialChildren: children,
);
GalleryViewerRoute.name,
args: GalleryViewerRouteArgs(
key: key,
renderList: renderList,
initialIndex: initialIndex,
heroOffset: heroOffset,
showStack: showStack,
),
initialChildren: children,
);
static const String name = 'GalleryViewerRoute';
@@ -811,7 +811,7 @@ class GalleryViewerRouteArgs {
/// [HeaderSettingsPage]
class HeaderSettingsRoute extends PageRouteInfo<void> {
const HeaderSettingsRoute({List<PageRouteInfo>? children})
: super(HeaderSettingsRoute.name, initialChildren: children);
: super(HeaderSettingsRoute.name, initialChildren: children);
static const String name = 'HeaderSettingsRoute';
@@ -827,7 +827,7 @@ class HeaderSettingsRoute extends PageRouteInfo<void> {
/// [LibraryPage]
class LibraryRoute extends PageRouteInfo<void> {
const LibraryRoute({List<PageRouteInfo>? children})
: super(LibraryRoute.name, initialChildren: children);
: super(LibraryRoute.name, initialChildren: children);
static const String name = 'LibraryRoute';
@@ -843,7 +843,7 @@ class LibraryRoute extends PageRouteInfo<void> {
/// [LocalAlbumsPage]
class LocalAlbumsRoute extends PageRouteInfo<void> {
const LocalAlbumsRoute({List<PageRouteInfo>? children})
: super(LocalAlbumsRoute.name, initialChildren: children);
: super(LocalAlbumsRoute.name, initialChildren: children);
static const String name = 'LocalAlbumsRoute';
@@ -859,7 +859,7 @@ class LocalAlbumsRoute extends PageRouteInfo<void> {
/// [LocalMediaSummaryPage]
class LocalMediaSummaryRoute extends PageRouteInfo<void> {
const LocalMediaSummaryRoute({List<PageRouteInfo>? children})
: super(LocalMediaSummaryRoute.name, initialChildren: children);
: super(LocalMediaSummaryRoute.name, initialChildren: children);
static const String name = 'LocalMediaSummaryRoute';
@@ -879,10 +879,10 @@ class LocalTimelineRoute extends PageRouteInfo<LocalTimelineRouteArgs> {
required String albumId,
List<PageRouteInfo>? children,
}) : super(
LocalTimelineRoute.name,
args: LocalTimelineRouteArgs(key: key, albumId: albumId),
initialChildren: children,
);
LocalTimelineRoute.name,
args: LocalTimelineRouteArgs(key: key, albumId: albumId),
initialChildren: children,
);
static const String name = 'LocalTimelineRoute';
@@ -912,7 +912,7 @@ class LocalTimelineRouteArgs {
/// [LockedPage]
class LockedRoute extends PageRouteInfo<void> {
const LockedRoute({List<PageRouteInfo>? children})
: super(LockedRoute.name, initialChildren: children);
: super(LockedRoute.name, initialChildren: children);
static const String name = 'LockedRoute';
@@ -928,7 +928,7 @@ class LockedRoute extends PageRouteInfo<void> {
/// [LoginPage]
class LoginRoute extends PageRouteInfo<void> {
const LoginRoute({List<PageRouteInfo>? children})
: super(LoginRoute.name, initialChildren: children);
: super(LoginRoute.name, initialChildren: children);
static const String name = 'LoginRoute';
@@ -944,7 +944,7 @@ class LoginRoute extends PageRouteInfo<void> {
/// [MainTimelinePage]
class MainTimelineRoute extends PageRouteInfo<void> {
const MainTimelineRoute({List<PageRouteInfo>? children})
: super(MainTimelineRoute.name, initialChildren: children);
: super(MainTimelineRoute.name, initialChildren: children);
static const String name = 'MainTimelineRoute';
@@ -964,13 +964,13 @@ class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
LatLng initialLatLng = const LatLng(0, 0),
List<PageRouteInfo>? children,
}) : super(
MapLocationPickerRoute.name,
args: MapLocationPickerRouteArgs(
key: key,
initialLatLng: initialLatLng,
),
initialChildren: children,
);
MapLocationPickerRoute.name,
args: MapLocationPickerRouteArgs(
key: key,
initialLatLng: initialLatLng,
),
initialChildren: children,
);
static const String name = 'MapLocationPickerRoute';
@@ -1008,11 +1008,11 @@ class MapLocationPickerRouteArgs {
/// [MapPage]
class MapRoute extends PageRouteInfo<MapRouteArgs> {
MapRoute({Key? key, LatLng? initialLocation, List<PageRouteInfo>? children})
: super(
MapRoute.name,
args: MapRouteArgs(key: key, initialLocation: initialLocation),
initialChildren: children,
);
: super(
MapRoute.name,
args: MapRouteArgs(key: key, initialLocation: initialLocation),
initialChildren: children,
);
static const String name = 'MapRoute';
@@ -1049,14 +1049,14 @@ class MemoryRoute extends PageRouteInfo<MemoryRouteArgs> {
Key? key,
List<PageRouteInfo>? children,
}) : super(
MemoryRoute.name,
args: MemoryRouteArgs(
memories: memories,
memoryIndex: memoryIndex,
key: key,
),
initialChildren: children,
);
MemoryRoute.name,
args: MemoryRouteArgs(
memories: memories,
memoryIndex: memoryIndex,
key: key,
),
initialChildren: children,
);
static const String name = 'MemoryRoute';
@@ -1103,16 +1103,16 @@ class NativeVideoViewerRoute extends PageRouteInfo<NativeVideoViewerRouteArgs> {
int playbackDelayFactor = 1,
List<PageRouteInfo>? children,
}) : super(
NativeVideoViewerRoute.name,
args: NativeVideoViewerRouteArgs(
key: key,
asset: asset,
image: image,
showControls: showControls,
playbackDelayFactor: playbackDelayFactor,
),
initialChildren: children,
);
NativeVideoViewerRoute.name,
args: NativeVideoViewerRouteArgs(
key: key,
asset: asset,
image: image,
showControls: showControls,
playbackDelayFactor: playbackDelayFactor,
),
initialChildren: children,
);
static const String name = 'NativeVideoViewerRoute';
@@ -1164,10 +1164,10 @@ class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
required UserDto partner,
List<PageRouteInfo>? children,
}) : super(
PartnerDetailRoute.name,
args: PartnerDetailRouteArgs(key: key, partner: partner),
initialChildren: children,
);
PartnerDetailRoute.name,
args: PartnerDetailRouteArgs(key: key, partner: partner),
initialChildren: children,
);
static const String name = 'PartnerDetailRoute';
@@ -1197,7 +1197,7 @@ class PartnerDetailRouteArgs {
/// [PartnerPage]
class PartnerRoute extends PageRouteInfo<void> {
const PartnerRoute({List<PageRouteInfo>? children})
: super(PartnerRoute.name, initialChildren: children);
: super(PartnerRoute.name, initialChildren: children);
static const String name = 'PartnerRoute';
@@ -1213,7 +1213,7 @@ class PartnerRoute extends PageRouteInfo<void> {
/// [PeopleCollectionPage]
class PeopleCollectionRoute extends PageRouteInfo<void> {
const PeopleCollectionRoute({List<PageRouteInfo>? children})
: super(PeopleCollectionRoute.name, initialChildren: children);
: super(PeopleCollectionRoute.name, initialChildren: children);
static const String name = 'PeopleCollectionRoute';
@@ -1229,7 +1229,7 @@ class PeopleCollectionRoute extends PageRouteInfo<void> {
/// [PermissionOnboardingPage]
class PermissionOnboardingRoute extends PageRouteInfo<void> {
const PermissionOnboardingRoute({List<PageRouteInfo>? children})
: super(PermissionOnboardingRoute.name, initialChildren: children);
: super(PermissionOnboardingRoute.name, initialChildren: children);
static const String name = 'PermissionOnboardingRoute';
@@ -1250,14 +1250,14 @@ class PersonResultRoute extends PageRouteInfo<PersonResultRouteArgs> {
required String personName,
List<PageRouteInfo>? children,
}) : super(
PersonResultRoute.name,
args: PersonResultRouteArgs(
key: key,
personId: personId,
personName: personName,
),
initialChildren: children,
);
PersonResultRoute.name,
args: PersonResultRouteArgs(
key: key,
personId: personId,
personName: personName,
),
initialChildren: children,
);
static const String name = 'PersonResultRoute';
@@ -1297,7 +1297,7 @@ class PersonResultRouteArgs {
/// [PhotosPage]
class PhotosRoute extends PageRouteInfo<void> {
const PhotosRoute({List<PageRouteInfo>? children})
: super(PhotosRoute.name, initialChildren: children);
: super(PhotosRoute.name, initialChildren: children);
static const String name = 'PhotosRoute';
@@ -1317,10 +1317,10 @@ class PinAuthRoute extends PageRouteInfo<PinAuthRouteArgs> {
bool createPinCode = false,
List<PageRouteInfo>? children,
}) : super(
PinAuthRoute.name,
args: PinAuthRouteArgs(key: key, createPinCode: createPinCode),
initialChildren: children,
);
PinAuthRoute.name,
args: PinAuthRouteArgs(key: key, createPinCode: createPinCode),
initialChildren: children,
);
static const String name = 'PinAuthRoute';
@@ -1356,13 +1356,13 @@ class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> {
LatLng? currentLocation,
List<PageRouteInfo>? children,
}) : super(
PlacesCollectionRoute.name,
args: PlacesCollectionRouteArgs(
key: key,
currentLocation: currentLocation,
),
initialChildren: children,
);
PlacesCollectionRoute.name,
args: PlacesCollectionRouteArgs(
key: key,
currentLocation: currentLocation,
),
initialChildren: children,
);
static const String name = 'PlacesCollectionRoute';
@@ -1397,7 +1397,7 @@ class PlacesCollectionRouteArgs {
/// [RecentlyTakenPage]
class RecentlyTakenRoute extends PageRouteInfo<void> {
const RecentlyTakenRoute({List<PageRouteInfo>? children})
: super(RecentlyTakenRoute.name, initialChildren: children);
: super(RecentlyTakenRoute.name, initialChildren: children);
static const String name = 'RecentlyTakenRoute';
@@ -1413,7 +1413,7 @@ class RecentlyTakenRoute extends PageRouteInfo<void> {
/// [RemoteMediaSummaryPage]
class RemoteMediaSummaryRoute extends PageRouteInfo<void> {
const RemoteMediaSummaryRoute({List<PageRouteInfo>? children})
: super(RemoteMediaSummaryRoute.name, initialChildren: children);
: super(RemoteMediaSummaryRoute.name, initialChildren: children);
static const String name = 'RemoteMediaSummaryRoute';
@@ -1433,10 +1433,10 @@ class RemoteTimelineRoute extends PageRouteInfo<RemoteTimelineRouteArgs> {
required String albumId,
List<PageRouteInfo>? children,
}) : super(
RemoteTimelineRoute.name,
args: RemoteTimelineRouteArgs(key: key, albumId: albumId),
initialChildren: children,
);
RemoteTimelineRoute.name,
args: RemoteTimelineRouteArgs(key: key, albumId: albumId),
initialChildren: children,
);
static const String name = 'RemoteTimelineRoute';
@@ -1470,10 +1470,10 @@ class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
SearchFilter? prefilter,
List<PageRouteInfo>? children,
}) : super(
SearchRoute.name,
args: SearchRouteArgs(key: key, prefilter: prefilter),
initialChildren: children,
);
SearchRoute.name,
args: SearchRouteArgs(key: key, prefilter: prefilter),
initialChildren: children,
);
static const String name = 'SearchRoute';
@@ -1505,7 +1505,7 @@ class SearchRouteArgs {
/// [SettingsPage]
class SettingsRoute extends PageRouteInfo<void> {
const SettingsRoute({List<PageRouteInfo>? children})
: super(SettingsRoute.name, initialChildren: children);
: super(SettingsRoute.name, initialChildren: children);
static const String name = 'SettingsRoute';
@@ -1525,10 +1525,10 @@ class SettingsSubRoute extends PageRouteInfo<SettingsSubRouteArgs> {
Key? key,
List<PageRouteInfo>? children,
}) : super(
SettingsSubRoute.name,
args: SettingsSubRouteArgs(section: section, key: key),
initialChildren: children,
);
SettingsSubRoute.name,
args: SettingsSubRouteArgs(section: section, key: key),
initialChildren: children,
);
static const String name = 'SettingsSubRoute';
@@ -1562,10 +1562,10 @@ class ShareIntentRoute extends PageRouteInfo<ShareIntentRouteArgs> {
required List<ShareIntentAttachment> attachments,
List<PageRouteInfo>? children,
}) : super(
ShareIntentRoute.name,
args: ShareIntentRouteArgs(key: key, attachments: attachments),
initialChildren: children,
);
ShareIntentRoute.name,
args: ShareIntentRouteArgs(key: key, attachments: attachments),
initialChildren: children,
);
static const String name = 'ShareIntentRoute';
@@ -1601,15 +1601,15 @@ class SharedLinkEditRoute extends PageRouteInfo<SharedLinkEditRouteArgs> {
String? albumId,
List<PageRouteInfo>? children,
}) : super(
SharedLinkEditRoute.name,
args: SharedLinkEditRouteArgs(
key: key,
existingLink: existingLink,
assetsList: assetsList,
albumId: albumId,
),
initialChildren: children,
);
SharedLinkEditRoute.name,
args: SharedLinkEditRouteArgs(
key: key,
existingLink: existingLink,
assetsList: assetsList,
albumId: albumId,
),
initialChildren: children,
);
static const String name = 'SharedLinkEditRoute';
@@ -1655,7 +1655,7 @@ class SharedLinkEditRouteArgs {
/// [SharedLinkPage]
class SharedLinkRoute extends PageRouteInfo<void> {
const SharedLinkRoute({List<PageRouteInfo>? children})
: super(SharedLinkRoute.name, initialChildren: children);
: super(SharedLinkRoute.name, initialChildren: children);
static const String name = 'SharedLinkRoute';
@@ -1671,7 +1671,7 @@ class SharedLinkRoute extends PageRouteInfo<void> {
/// [SplashScreenPage]
class SplashScreenRoute extends PageRouteInfo<void> {
const SplashScreenRoute({List<PageRouteInfo>? children})
: super(SplashScreenRoute.name, initialChildren: children);
: super(SplashScreenRoute.name, initialChildren: children);
static const String name = 'SplashScreenRoute';
@@ -1687,7 +1687,7 @@ class SplashScreenRoute extends PageRouteInfo<void> {
/// [TabControllerPage]
class TabControllerRoute extends PageRouteInfo<void> {
const TabControllerRoute({List<PageRouteInfo>? children})
: super(TabControllerRoute.name, initialChildren: children);
: super(TabControllerRoute.name, initialChildren: children);
static const String name = 'TabControllerRoute';
@@ -1703,7 +1703,7 @@ class TabControllerRoute extends PageRouteInfo<void> {
/// [TabShellPage]
class TabShellRoute extends PageRouteInfo<void> {
const TabShellRoute({List<PageRouteInfo>? children})
: super(TabShellRoute.name, initialChildren: children);
: super(TabShellRoute.name, initialChildren: children);
static const String name = 'TabShellRoute';
@@ -1719,7 +1719,7 @@ class TabShellRoute extends PageRouteInfo<void> {
/// [TrashPage]
class TrashRoute extends PageRouteInfo<void> {
const TrashRoute({List<PageRouteInfo>? children})
: super(TrashRoute.name, initialChildren: children);
: super(TrashRoute.name, initialChildren: children);
static const String name = 'TrashRoute';

View File

@@ -13,10 +13,12 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/isar_store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/drift_store.repository.dart';
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
@@ -48,15 +50,32 @@ abstract final class Bootstrap {
);
}
static Future<Drift> initDrift() async {
return Drift();
}
static Future<void> initDomain(
Isar db, {
bool shouldBufferLogs = true,
}) async {
final driftDb = Drift();
await StoreService.init(storeRepository: IsarStoreRepository(db));
await LogService.init(
logRepository: IsarLogRepository(db),
logRepository: LogRepository(driftDb),
storeRepository: IsarStoreRepository(db),
shouldBuffer: shouldBufferLogs,
);
}
static Future<void> initDomainWithDrift(
Drift db, {
bool shouldBufferLogs = true,
}) async {
await StoreService.init(storeRepository: DriftStoreRepository(db));
await LogService.init(
logRepository: LogRepository(db),
storeRepository: DriftStoreRepository(db),
shouldBuffer: shouldBufferLogs,
);
}
}

View File

@@ -14,8 +14,8 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/isar_store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/diff.dart';

View File

@@ -1,12 +1,13 @@
import 'package:drift/drift.dart' hide isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:isar/isar.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/drift_store.repository.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
const _kTestAccessToken = "#TestToken";
final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45);
@@ -14,16 +15,28 @@ const _kTestVersion = 10;
const _kTestColorfulInterface = false;
final _kTestUser = UserStub.admin;
Future<void> _addIntStoreValue(Isar db, StoreKey key, int? value) async {
await db.storeValues.put(StoreValue(key.id, intValue: value, strValue: null));
Future<void> _addIntStoreValue(Drift db, StoreKey key, int? value) async {
await db.into(db.storeEntity).insert(
StoreEntityCompanion.insert(
id: key.id,
intValue: Value(value),
strValue: const Value(null),
),
);
}
Future<void> _addStrStoreValue(Isar db, StoreKey key, String? value) async {
await db.storeValues.put(StoreValue(key.id, intValue: null, strValue: value));
Future<void> _addStrStoreValue(Drift db, StoreKey key, String? value) async {
await db.into(db.storeEntity).insert(
StoreEntityCompanion.insert(
id: key.id,
intValue: const Value(null),
strValue: Value(value),
),
);
}
Future<void> _populateStore(Isar db) async {
await db.writeTxn(() async {
Future<void> _populateStore(Drift db) async {
await db.transaction(() async {
await _addIntStoreValue(
db,
StoreKey.colorfulInterface,
@@ -40,12 +53,12 @@ Future<void> _populateStore(Isar db) async {
}
void main() {
late Isar db;
late IsarStoreRepository sut;
late Drift db;
late DriftStoreRepository sut;
setUp(() async {
db = await TestUtils.initIsar();
sut = IsarStoreRepository(db);
db = Drift(NativeDatabase.memory());
sut = DriftStoreRepository(db);
});
group('Store Repository converters:', () {
@@ -105,10 +118,16 @@ void main() {
});
test('deleteAll()', () async {
final count = await db.storeValues.count();
final countQuery = db.selectOnly(db.storeEntity)
..addColumns([db.storeEntity.id.count()]);
final countResult = await countQuery.getSingle();
final count = countResult.read(db.storeEntity.id.count()) ?? 0;
expect(count, isNot(isZero));
await sut.deleteAll();
expectLater(await db.storeValues.count(), isZero);
final newCountResult = await countQuery.getSingle();
final newCount = newCountResult.read(db.storeEntity.id.count()) ?? 0;
expect(newCount, isZero);
});
});

View File

@@ -12,7 +12,7 @@ import 'package:mocktail/mocktail.dart';
class MockStoreRepository extends Mock implements IsarStoreRepository {}
class MockLogRepository extends Mock implements IsarLogRepository {}
class MockLogRepository extends Mock implements LogRepository {}
class MockIsarUserRepository extends Mock implements IsarUserRepository {}

View File

@@ -1,3 +1,4 @@
import 'package:drift/native.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
@@ -8,6 +9,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
@@ -80,12 +82,12 @@ void main() {
setUpAll(() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await TestUtils.initIsar();
final driftDb = Drift(NativeDatabase.memory());
db.writeTxnSync(() => db.clearSync());
await StoreService.init(storeRepository: IsarStoreRepository(db));
await Store.put(StoreKey.currentUser, owner);
await LogService.init(
logRepository: IsarLogRepository(db),
logRepository: LogRepository(driftDb),
storeRepository: IsarStoreRepository(db),
);
});

View File

@@ -15,7 +15,6 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
@@ -41,7 +40,6 @@ abstract final class TestUtils {
final db = await Isar.open(
[
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,

View File

@@ -2,7 +2,6 @@ import { AuthController } from 'src/controllers/auth.controller';
import { LoginResponseDto } from 'src/dtos/auth.dto';
import { AuthService } from 'src/services/auth.service';
import request from 'supertest';
import { mediumFactory } from 'test/medium.factory';
import { errorDto } from 'test/medium/responses';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
@@ -133,50 +132,6 @@ describe(AuthController.name, () => {
expect(status).toEqual(201);
expect(service.login).toHaveBeenCalledWith(expect.objectContaining({ email: 'admin@local' }), expect.anything());
});
it('should auth cookies on a secure connection', async () => {
const loginResponse = mediumFactory.loginResponse();
service.login.mockResolvedValue(loginResponse);
const { status, body, headers } = await request(ctx.getHttpServer())
.post('/auth/login')
.send({ name: 'admin', email: 'admin@local', password: 'password' });
expect(status).toEqual(201);
expect(body).toEqual(loginResponse);
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3);
expect(cookies[0].split(';').map((item) => item.trim())).toEqual([
`immich_access_token=${loginResponse.accessToken}`,
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[1].split(';').map((item) => item.trim())).toEqual([
'immich_auth_type=password',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[2].split(';').map((item) => item.trim())).toEqual([
'immich_is_authenticated=true',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'SameSite=Lax',
]);
});
});
describe('POST /auth/logout', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/auth/logout');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /auth/change-password', () => {

View File

@@ -97,16 +97,6 @@ where
"users"."id" = $1
and "users"."deletedAt" is null
-- UserRepository.getForChangePassword
select
"users"."id",
"users"."password"
from
"users"
where
"users"."id" = $1
and "users"."deletedAt" is null
-- UserRepository.getByEmail
select
"id",

View File

@@ -100,16 +100,6 @@ export class UserRepository {
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForChangePassword(id: string) {
return this.db
.selectFrom('users')
.select(['users.id', 'users.password'])
.where('users.id', '=', id)
.where('users.deletedAt', 'is', null)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.EMAIL] })
getByEmail(email: string, options?: { withPassword?: boolean }) {
return this.db

View File

@@ -116,33 +116,46 @@ describe(AuthService.name, () => {
const auth = factory.auth({ user });
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
mocks.user.getByEmail.mockResolvedValue({ ...user, password: 'hash-password' });
mocks.user.update.mockResolvedValue(user);
await sut.changePassword(auth, dto);
expect(mocks.user.getForChangePassword).toHaveBeenCalledWith(user.id);
expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPassword: true });
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
});
it('should throw when auth user email is not found', async () => {
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getByEmail.mockResolvedValue(void 0);
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should throw when password does not match existing password', async () => {
const user = factory.user();
const auth = factory.auth({ user });
const auth = { user: { email: 'test@imimch.com' } as UserAdmin };
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.crypto.compareBcrypt.mockReturnValue(false);
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
mocks.user.getByEmail.mockResolvedValue({
email: 'test@immich.com',
password: 'hash-password',
} as UserAdmin & { password: string });
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
});
it('should throw when user does not have a password', async () => {
const user = factory.user();
const auth = factory.auth({ user });
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' });
mocks.user.getByEmail.mockResolvedValue({
email: 'test@immich.com',
password: '',
} as UserAdmin & { password: string });
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
});

View File

@@ -91,7 +91,11 @@ export class AuthService extends BaseService {
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
const { password, newPassword } = dto;
const user = await this.userRepository.getForChangePassword(auth.user.id);
const user = await this.userRepository.getByEmail(auth.user.email, { withPassword: true });
if (!user) {
throw new UnauthorizedException();
}
const valid = this.validateSecret(password, user.password);
if (!valid) {
throw new BadRequestException('Wrong password');

View File

@@ -5,7 +5,7 @@ import { createHash, randomBytes } from 'node:crypto';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { Albums, AssetJobStatus, Assets, DB, Exif, FaceSearch, Memories, Person, Sessions } from 'src/db';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumUserRole, AssetType, AssetVisibility, MemoryType, SourceType, SyncRequestType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
@@ -17,7 +17,6 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
@@ -306,10 +305,6 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
return automock(EmailRepository, { args: [{ setContext: () => {} }] });
}
case EventRepository: {
return automock(EventRepository, { args: [undefined, undefined, { setContext: () => {} }] });
}
case JobRepository: {
return automock(JobRepository, {
args: [
@@ -466,13 +461,10 @@ const sessionInsert = ({ id = newUuid(), userId, ...session }: Partial<Insertabl
const userInsert = (user: Partial<Insertable<UserTable>> = {}) => {
const id = user.id || newUuid();
const defaults = {
const defaults: Insertable<UserTable> = {
email: `${id}@immich.cloud`,
name: `User ${id}`,
deletedAt: null,
isAdmin: false,
profileImagePath: '',
shouldChangePassword: true,
};
return { ...defaults, ...user, id };
@@ -521,24 +513,6 @@ const syncStream = () => {
return new CustomWritable();
};
const loginDetails = () => {
return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '' };
};
const loginResponse = (): LoginResponseDto => {
const user = userInsert({});
return {
accessToken: 'access-token',
userId: user.id,
userEmail: user.email,
name: user.name,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
shouldChangePassword: user.shouldChangePassword,
isOnboarded: false,
};
};
export const mediumFactory = {
assetInsert,
assetFaceInsert,
@@ -550,6 +524,4 @@ export const mediumFactory = {
syncStream,
userInsert,
memoryInsert,
loginDetails,
loginResponse,
};

View File

@@ -1,163 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { hash } from 'bcrypt';
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AuthType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { AuthService } from 'src/services/auth.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AuthService, {
database: db || defaultDatabase,
real: [
AccessRepository,
ConfigRepository,
CryptoRepository,
DatabaseRepository,
SessionRepository,
SystemMetadataRepository,
UserRepository,
],
mock: [LoggingRepository, StorageRepository, EventRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AuthService.name, () => {
describe('adminSignUp', () => {
it(`should sign up the admin`, async () => {
const { sut } = setup();
const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' };
await expect(sut.adminSignUp(dto)).resolves.toEqual(
expect.objectContaining({
id: expect.any(String),
email: dto.email,
name: dto.name,
isAdmin: true,
}),
);
});
it('should not allow a second admin to sign up', async () => {
const { sut, ctx } = setup();
await ctx.newUser({ isAdmin: true });
const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' };
const response = sut.adminSignUp(dto);
await expect(response).rejects.toThrow(BadRequestException);
await expect(response).rejects.toThrow('The server already has an admin');
});
});
describe('login', () => {
it('should reject an incorrect password', async () => {
const { sut, ctx } = setup();
const password = 'password';
const passwordHashed = await hash(password, 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password: 'wrong-password' };
await expect(sut.login(dto, mediumFactory.loginDetails())).rejects.toThrow('Incorrect email or password');
});
it('should accept a correct password and return a login response', async () => {
const { sut, ctx } = setup();
const password = 'password';
const passwordHashed = await hash(password, 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password };
await expect(sut.login(dto, mediumFactory.loginDetails())).resolves.toEqual({
accessToken: expect.any(String),
isAdmin: user.isAdmin,
isOnboarded: false,
name: user.name,
profileImagePath: user.profileImagePath,
userId: user.id,
userEmail: user.email,
shouldChangePassword: user.shouldChangePassword,
});
});
});
describe('logout', () => {
it('should logout', async () => {
const { sut } = setup();
const auth = factory.auth();
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
});
it('should cleanup the session', async () => {
const { sut, ctx } = setup();
const sessionRepo = ctx.get(SessionRepository);
const eventRepo = ctx.getMock(EventRepository);
const { user } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user.id });
const auth = factory.auth({ session, user });
eventRepo.emit.mockResolvedValue();
await expect(sessionRepo.get(session.id)).resolves.toEqual(expect.objectContaining({ id: session.id }));
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
await expect(sessionRepo.get(session.id)).resolves.toBeUndefined();
});
});
describe('changePassword', () => {
it('should change the password and login with it', async () => {
const { sut, ctx } = setup();
const dto = { password: 'password', newPassword: 'new-password' };
const passwordHashed = await hash(dto.password, 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const auth = factory.auth({ user });
const response = await sut.changePassword(auth, dto);
expect(response).toEqual(
expect.objectContaining({
id: user.id,
email: user.email,
}),
);
expect((response as any).password).not.toBeDefined();
await expect(
sut.login({ email: user.email, password: dto.newPassword }, mediumFactory.loginDetails()),
).resolves.toBeDefined();
});
it('should validate the current password', async () => {
const { sut, ctx } = setup();
const dto = { password: 'wrong-password', newPassword: 'new-password' };
const passwordHashed = await hash('password', 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const auth = factory.auth({ user });
const response = sut.changePassword(auth, dto);
await expect(response).rejects.toThrow(BadRequestException);
await expect(response).rejects.toThrow('Wrong password');
});
});
});

20
web/package-lock.json generated
View File

@@ -26,7 +26,6 @@
"intl-messageformat": "^10.7.11",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"lru-cache": "^11.1.0",
"luxon": "^3.4.4",
"maplibre-gl": "^5.3.0",
"pmtiles": "^4.3.0",
@@ -6593,13 +6592,11 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/lru-queue": {
"version": "0.1.0",
@@ -7338,13 +7335,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",

View File

@@ -43,7 +43,6 @@
"intl-messageformat": "^10.7.11",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"lru-cache": "^11.1.0",
"luxon": "^3.4.4",
"maplibre-gl": "^5.3.0",
"pmtiles": "^4.3.0",

View File

@@ -0,0 +1,25 @@
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { useZoomImageWheel } from '@zoom-image/svelte';
import { get } from 'svelte/store';
export const zoomImageAction = (node: HTMLElement) => {
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(node, {
maxZoom: 10,
});
const state = get(photoZoomState);
if (state) {
setZoomImageState(state);
}
const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)];
return {
destroy() {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
},
};
};

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte';
import { navigate } from '$lib/utils/navigation';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiMapOutline } from '@mdi/js';
@@ -10,12 +9,12 @@
import { t } from 'svelte-i18n';
interface Props {
assetManager: AssetManager;
album: AlbumResponseDto;
}
let { assetManager = $bindable(), album }: Props = $props();
let { album }: Props = $props();
let abortController: AbortController;
let { setAssetId } = assetViewingStore;
let mapMarkers: MapMarkerResponseDto[] = $state([]);
@@ -25,7 +24,7 @@
onDestroy(() => {
abortController?.abort();
assetManager.showAssetViewer = false;
assetViewingStore.showAssetViewer(false);
});
async function loadMapMarkers() {
@@ -57,7 +56,7 @@
const assetIds = await modalManager.show(MapModal, { mapMarkers });
if (assetIds) {
await navigate({ targetRoute: 'current', assetId: assetIds[0] });
await setAssetId(assetIds[0]);
}
}
</script>

View File

@@ -4,9 +4,9 @@
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
@@ -25,15 +25,16 @@
import AlbumSummary from './album-summary.svelte';
interface Props {
assetManager: AssetManager;
sharedLink: SharedLinkResponseDto;
user?: UserResponseDto | undefined;
}
let { assetManager = $bindable(), sharedLink, user = undefined }: Props = $props();
let { sharedLink, user = undefined }: Props = $props();
const album = sharedLink.album as AlbumResponseDto;
let { isViewing: showAssetViewer } = assetViewingStore;
const timelineManager = new TimelineManager();
$effect(() => void timelineManager.updateOptions({ albumId: album.id, order: album.order }));
onDestroy(() => timelineManager.destroy());
@@ -52,7 +53,7 @@
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
if (!assetManager.showAssetViewer && assetInteraction.selectionActive) {
if (!$showAssetViewer && assetInteraction.selectionActive) {
cancelMultiselect(assetInteraction);
}
},
@@ -60,7 +61,7 @@
/>
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
<AssetGrid enableRouting={true} {album} {timelineManager} {assetInteraction} {assetManager}>
<AssetGrid enableRouting={true} {album} {timelineManager} {assetInteraction}>
<section class="pt-8 md:pt-24 px-2 md:px-0">
<!-- ALBUM TITLE -->
<h1
@@ -128,7 +129,7 @@
/>
{/if}
{#if sharedLink.showMetadata && $featureFlags.loaded && $featureFlags.map}
<AlbumMap {assetManager} {album} />
<AlbumMap {album} />
{/if}
<ThemeButton />
{/snippet}

View File

@@ -1,20 +1,22 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { downloadFile } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiFolderDownloadOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
assetManager: AssetManager;
asset: TimelineAsset;
menuItem?: boolean;
}
let { assetManager = $bindable(), menuItem = false }: Props = $props();
let { asset, menuItem = false }: Props = $props();
const onDownloadFile = async () => downloadFile(assetManager.asset);
const onDownloadFile = async () => downloadFile(await getAssetInfo({ id: asset.id, key: authManager.key }));
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />

View File

@@ -21,8 +21,8 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
@@ -32,6 +32,7 @@
AssetTypeEnum,
AssetVisibility,
type AlbumResponseDto,
type AssetResponseDto,
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
@@ -54,7 +55,7 @@
import { t } from 'svelte-i18n';
interface Props {
assetManager: AssetManager;
asset: AssetResponseDto;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
@@ -74,7 +75,7 @@
}
let {
assetManager = $bindable(),
asset,
album = null,
person = null,
stack = null,
@@ -92,9 +93,6 @@
motionPhoto,
}: Props = $props();
let asset = $derived(assetManager.asset!);
let zoomImageState = $derived(assetManager.zoomImageState);
const sharedLink = getSharedLink();
let isOwner = $derived($user && asset.ownerId === $user?.id);
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
@@ -143,7 +141,7 @@
color="secondary"
variant="ghost"
shape="round"
icon={zoomImageState && zoomImageState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
aria-label={$t('zoom_image')}
onclick={onZoomImage}
/>
@@ -160,7 +158,7 @@
{/if}
{#if !isOwner && showDownloadButton}
<DownloadAction {assetManager} />
<DownloadAction asset={toTimelineAsset(asset)} />
{/if}
{#if showDetailButton}
@@ -179,7 +177,7 @@
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
{#if showDownloadButton}
<DownloadAction {assetManager} menuItem />
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
{/if}
{#if !isLocked}

View File

@@ -7,21 +7,22 @@
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
AssetTypeEnum,
getAllAlbums,
getStack,
runAssetJobs,
type AlbumResponseDto,
@@ -47,7 +48,8 @@
type HasAsset = boolean;
interface Props {
assetManager: AssetManager;
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[];
showNavigation?: boolean;
withStacked?: boolean;
isShared?: boolean;
@@ -59,12 +61,13 @@
onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<HasAsset>;
onRandom: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>;
}
let {
assetManager = $bindable(),
asset = $bindable(),
preloadAssets = $bindable([]),
showNavigation = true,
withStacked = false,
isShared = false,
@@ -80,6 +83,7 @@
copyImage = $bindable(),
}: Props = $props();
const { setAssetId } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
@@ -90,13 +94,10 @@
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
let asset = $derived(assetManager.asset);
let preloadAssets = $derived(assetManager.preloadAssets);
let albums = $derived(assetManager.albums);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let shouldPlayMotionPhoto = $state(false);
let sharedLink = getSharedLink();
let enableDetailPanel = $derived(asset?.hasMetadata ?? false);
let enableDetailPanel = asset.hasMetadata;
let slideshowStateUnsubscribe: () => void;
let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined = $state();
@@ -105,6 +106,7 @@
let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
let selectedEditType: string = $state('');
let stack: StackResponseDto | null = $state(null);
let zoomToggle = $state(() => void 0);
@@ -144,7 +146,7 @@
}
};
onMount(() => {
onMount(async () => {
unsubscribes.push(
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
@@ -167,7 +169,9 @@
}
});
// TODO: empty shared link returns 404.
if (!sharedLink) {
await handleGetAllAlbums();
}
});
onDestroy(() => {
@@ -186,6 +190,18 @@
activityManager.reset();
});
const handleGetAllAlbums = async () => {
if (authManager.key) {
return;
}
try {
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
} catch (error) {
console.error('Error getting album that asset belong to', error);
}
};
const handleOpenActivity = () => {
if ($isShowDetail) {
$isShowDetail = false;
@@ -222,11 +238,11 @@
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? await slideshowHistory.previous() : await slideshowHistory.next();
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
await onRandom();
if (assetManager.asset) {
slideshowHistory.queue(assetManager.asset);
const asset = await onRandom();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
@@ -265,9 +281,8 @@
let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory(async (asset) => {
await navigate({ targetRoute: 'current', assetId: asset.id });
$restartSlideshowProgress = true;
const slideshowHistory = new SlideshowHistory((asset) => {
handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
});
const handleVideoStarted = () => {
@@ -310,7 +325,7 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ADD_TO_ALBUM: {
await assetManager.refreshAlbums();
await handleGetAllAlbums();
break;
}
case AssetAction.SET_STACK_PRIMARY_ASSET: {
@@ -349,6 +364,11 @@
handlePromiseError(activityManager.init(album.id, asset.id));
}
});
$effect(() => {
if (asset.id) {
handlePromiseError(handleGetAllAlbums());
}
});
</script>
<svelte:document bind:fullscreenElement />
@@ -363,7 +383,7 @@
{#if $slideshowState === SlideshowState.None && !isShowEditor}
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{assetManager}
{asset}
{album}
{person}
{stack}
@@ -441,7 +461,8 @@
{#if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
{assetManager}
assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
@@ -451,14 +472,15 @@
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<ImagePanoramaViewer {assetManager} />
<ImagePanoramaViewer {asset} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} />
{:else}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{assetManager}
{asset}
{preloadAssets}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
@@ -467,7 +489,8 @@
{/if}
{:else}
<VideoViewer
{assetManager}
assetId={asset.id}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
@@ -506,7 +529,7 @@
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} {albums} onClose={() => ($isShowDetail = false)} />
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
</div>
{/if}

View File

@@ -13,7 +13,7 @@
import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { delay, isFlipped } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
@@ -22,6 +22,7 @@
import { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAssetInfo,
updateAsset,
type AlbumResponseDto,
type AssetResponseDto,
@@ -82,6 +83,19 @@
let isOwner = $derived($user?.id === asset.ownerId);
const handleNewAsset = async (newAsset: AssetResponseDto) => {
// TODO: check if reloading asset data is necessary
if (newAsset.id && !authManager.key) {
const data = await getAssetInfo({ id: asset.id });
people = data?.people || [];
unassignedFaces = data?.unassignedFaces || [];
}
};
$effect(() => {
handlePromiseError(handleNewAsset(asset));
});
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
@@ -113,8 +127,11 @@
return undefined;
};
// TODO: refresh people
const handleRefreshPeople = () => {
const handleRefreshPeople = async () => {
await getAssetInfo({ id: asset.id }).then((data) => {
people = data?.people || [];
unassignedFaces = data?.unassignedFaces || [];
});
showEditFaces = false;
};

View File

@@ -5,7 +5,6 @@
import { onDestroy, onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import {
changedOriention,
cropAspectRatio,
@@ -14,6 +13,7 @@
rotateDegrees,
} from '$lib/stores/asset-editor.store';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { animateCropChange, recalculateCrop } from './crop-settings';
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
import { draw } from './drawing';
@@ -21,10 +21,10 @@
import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
interface Props {
assetManager: AssetManager;
asset: AssetResponseDto;
}
let { assetManager = $bindable() }: Props = $props();
let { asset }: Props = $props();
let img = $state<HTMLImageElement>();

View File

@@ -2,6 +2,7 @@
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { notificationController } from '$lib/components/shared-components/notification/notification';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -302,8 +303,7 @@
},
});
// TODO: manual tag face
// await assetViewingStore.setAssetId(assetId);
await assetViewingStore.setAssetId(assetId);
} catch (error) {
handleError(error, 'Error tagging face');
} finally {

View File

@@ -1,19 +1,17 @@
<script lang="ts">
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAssetOriginalUrl } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { AssetMediaSize, viewAsset } from '@immich/sdk';
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
interface Props {
assetManager: AssetManager;
asset: AssetResponseDto;
}
// TODO: do not preload assets.
const { assetManager = $bindable() }: Props = $props();
const { asset }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ id, size: AssetMediaSize.Preview, key: authManager.key });

View File

@@ -1,28 +1,26 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { type AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import {
cancelImageLoad,
mediaLoadError,
mediaLoaded,
} from '$lib/managers/asset-manager/internal/load-support.svelte';
import { zoomImageAttachment } from '$lib/managers/asset-manager/internal/zoom-support.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy } from 'svelte';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -30,7 +28,8 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
interface Props {
assetManager: AssetManager;
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[] | undefined;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
@@ -41,7 +40,8 @@
}
let {
assetManager = $bindable(),
asset,
preloadAssets = undefined,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
@@ -53,25 +53,51 @@
const { slideshowState, slideshowLook } = slideshowStore;
let zoomImageState = $derived(assetManager.zoomImageState);
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
zoomToggle = () => {
if (zoomImageState) {
zoomImageState.currentZoom = zoomImageState.currentZoom > 1 ? 1 : 2;
}
};
let loader = $state<HTMLImageElement>();
photoZoomState.set({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
onDestroy(() => {
$boundingBoxesArray = [];
});
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.isImage) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
}
}
};
const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
copyImage = async () => {
if (!canCopyImageToClipboard()) {
return;
}
try {
await copyImageToClipboard($photoViewerImgElement ?? assetManager.url!);
await copyImageToClipboard($photoViewerImgElement ?? assetFileUrl);
notificationController.show({
type: NotificationType.Info,
message: $t('copied_image_to_clipboard'),
@@ -82,10 +108,17 @@
}
};
zoomToggle = () => {
photoZoomState.set({
...$photoZoomState,
currentZoom: $photoZoomState.currentZoom > 1 ? 1 : 2,
});
};
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
$effect(() => {
if (isFaceEditMode.value && zoomImageState && zoomImageState.currentZoom > 1) {
if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) {
zoomToggle();
}
});
@@ -99,7 +132,7 @@
};
const onSwipe = (event: SwipeCustomEvent) => {
if (!zoomImageState || zoomImageState.currentZoom > 1) {
if ($photoZoomState.currentZoom > 1) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
@@ -110,10 +143,21 @@
}
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
});
$effect(() => {
if (assetManager.url) {
if (assetFileUrl) {
// this can't be in an async context with $effect
void cast(assetManager.url);
void cast(assetFileUrl);
}
});
@@ -131,10 +175,35 @@
}
};
onDestroy(() => {
cancelImageLoad(assetManager);
const onload = () => {
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
imageError = imageLoaded = true;
};
$effect(() => {
preload(targetImageSize, preloadAssets);
});
onMount(() => {
if (loader?.complete) {
onload();
}
loader?.addEventListener('load', onload, { passive: true });
loader?.addEventListener('error', onerror, { passive: true });
return () => {
loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror);
cancelImageUrl(imageLoaderUrl);
};
});
let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash));
let containerWidth = $state(0);
let containerHeight = $state(0);
</script>
@@ -148,32 +217,27 @@
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
]}
/>
{#if assetManager.loadError}
{#if imageError}
<div class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<div
bind:this={element}
class="relative h-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
<img
style="display:none"
src={assetManager.url}
alt=""
aria-hidden="true"
onload={() => mediaLoaded(assetManager)}
onerror={() => mediaLoadError(assetManager)}
/>
{#if !assetManager.isLoaded}
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !assetManager.loadError}
{:else if !imageError}
<div
{@attach zoomImageAttachment(assetManager)}
use:zoomImageAction
use:swipe={() => ({})}
onswipe={onSwipe}
class="h-full w-full"
@@ -181,7 +245,7 @@
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetManager.url}
src={assetFileUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
@@ -189,15 +253,15 @@
{/if}
<img
bind:this={$photoViewerImgElement}
src={assetManager.url}
alt={$getAltText(toTimelineAsset(assetManager.asset!))}
src={assetFileUrl}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, zoomImageState!, $photoViewerImgElement) as boundingbox}
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
@@ -206,12 +270,7 @@
</div>
{#if isFaceEditMode.value}
<FaceEditor
htmlElement={$photoViewerImgElement}
{containerWidth}
{containerHeight}
assetId={assetManager.asset!.id}
/>
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{/if}
</div>

View File

@@ -3,7 +3,6 @@
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
@@ -17,8 +16,9 @@
import { fade } from 'svelte/transition';
interface Props {
assetManager: AssetManager;
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
@@ -27,8 +27,9 @@
}
let {
assetManager = $bindable(),
assetId,
loopVideo,
cacheKey,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},

View File

@@ -3,13 +3,12 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
interface Props {
assetManager: AssetManager;
assetId: string;
}
const { assetManager = $bindable() }: Props = $props();
const { assetId }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { ProjectionType } from '$lib/constants';
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
interface Props {
assetManager: AssetManager;
assetId: string;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
@@ -15,10 +15,10 @@
onVideoStarted?: () => void;
}
// TODO: do not preload assets.
let {
assetManager = $bindable(),
assetId,
projectionType,
cacheKey,
loopVideo,
onPreviousAsset,
onClose,
@@ -29,11 +29,12 @@
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {assetManager} />
<VideoPanoramaViewer {assetId} />
{:else}
<VideoNativeViewer
{loopVideo}
{assetManager}
{cacheKey}
{assetId}
{onPreviousAsset}
{onNextAsset}
{onVideoEnded}

View File

@@ -3,6 +3,7 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { websocketEvents } from '$lib/stores/websocket';
@@ -19,7 +20,6 @@
type AssetFaceResponseDto,
type PersonResponseDto,
} from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -28,6 +28,7 @@
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import { IconButton } from '@immich/ui';
interface Props {
assetId: string;
@@ -183,8 +184,7 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
// TODO: manual tag face
// await assetViewingStore.setAssetId(assetId);
await assetViewingStore.setAssetId(assetId);
} catch (error) {
handleError(error, $t('error_delete_face'));
}

View File

@@ -26,10 +26,10 @@
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
@@ -61,12 +61,6 @@
import { t } from 'svelte-i18n';
import { Tween } from 'svelte/motion';
interface Props {
assetManager: AssetManager;
}
let { assetManager = $bindable() }: Props = $props();
let memoryGallery: HTMLElement | undefined = $state();
let memoryWrapper: HTMLElement | undefined = $state();
let galleryInView = $state(false);
@@ -82,6 +76,7 @@
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
const { isViewing } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
// need to include padding in the viewport for gallery
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
@@ -90,7 +85,7 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: { id: string }) => {
if (assetManager.showAssetViewer) {
if ($isViewing) {
return asset;
}
@@ -256,7 +251,7 @@
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
return;
}
if (assetManager.showAssetViewer) {
if ($isViewing) {
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
} else {
handlePromiseError(handleAction('initPlayer[AssetViewClosed]', 'reset'));
@@ -301,7 +296,7 @@
</script>
<svelte:document
use:shortcuts={assetManager.showAssetViewer
use:shortcuts={$isViewing
? []
: [
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNextAsset() },
@@ -645,7 +640,6 @@
bind:this={memoryGallery}
>
<GalleryViewer
{assetManager}
onNext={handleNextAsset}
onPrevious={handlePreviousAsset}
assets={currentTimelineAssets}

View File

@@ -12,7 +12,7 @@
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@@ -20,6 +20,7 @@
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
@@ -35,7 +36,7 @@
type ScrubberListener,
type TimelinePlainYearMonth,
} from '$lib/utils/timeline-util';
import { AssetVisibility, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
@@ -52,7 +53,6 @@
enableRouting: boolean;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
assetManager: AssetManager;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
@@ -78,7 +78,6 @@
enableRouting,
timelineManager = $bindable(),
assetInteraction,
assetManager = $bindable(),
removeAction = null,
withStacked = false,
showArchiveIcon = false,
@@ -92,6 +91,8 @@
empty,
}: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
@@ -102,8 +103,6 @@
let scrubOverallPercent: number = $state(0);
let scrubberWidth = $state(0);
let asset = $derived(assetManager.asset);
// 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60;
let leadout = $state(false);
@@ -178,7 +177,7 @@
};
const completeNav = async () => {
const scrollTarget = assetManager.gridScrollTarget?.at;
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollToAssetId(scrollTarget);
@@ -213,9 +212,9 @@
setTimeout(() => {
const asset = $page.url.searchParams.get('at');
if (asset) {
assetManager.gridScrollTarget = { at: asset };
$gridScrollTarget = { at: asset };
void navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: assetManager.gridScrollTarget },
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
} else {
@@ -439,50 +438,46 @@
};
const handlePrevious = async () => {
let laterAsset = undefined;
if (asset) {
laterAsset = await timelineManager.getLaterAsset(asset);
if (laterAsset) {
// TODO: If preloadAsset is undefined, throw an exception.
// assetManager.preloadAssets = [await timelineManager.getLaterAsset(laterAsset)];
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
if (laterAsset) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ id: laterAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
return !!laterAsset;
};
const handleNext = async () => {
let earlierAsset = undefined;
if (asset) {
earlierAsset = await timelineManager.getEarlierAsset(asset);
if (earlierAsset) {
// assetManager.preloadAssets = [await timelineManager.getEarlierAsset(earlierAsset)];
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
if (earlierAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ id: earlierAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
return !!earlierAsset;
};
const handleRandom = async () => {
let randomAsset = undefined;
if (asset) {
randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
}
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
}
return !!randomAsset;
};
const handleClose = async (asset: { id: string }) => {
assetManager.showAssetViewer = false;
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
assetManager.gridScrollTarget = { at: asset.id };
await navigate({
targetRoute: 'current',
assetId: null,
assetGridRouteSearchParams: assetManager.gridScrollTarget,
});
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
const handlePreAction = async (action: Action) => {
@@ -727,7 +722,7 @@
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || assetManager.showAssetViewer) {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
@@ -780,8 +775,8 @@
});
$effect(() => {
if (assetManager.showAssetViewer && asset) {
const { localDateTime } = getTimes(asset.fileCreatedAt, DateTime.local().offset / 60);
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
}
});
@@ -928,11 +923,12 @@
</section>
<Portal target="body">
{#if assetManager.showAssetViewer}
{#if $showAssetViewer}
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{assetManager}
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}

View File

@@ -3,18 +3,16 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { addSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
@@ -24,14 +22,14 @@
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { IconButton } from '@immich/ui';
interface Props {
assetManager: AssetManager;
sharedLink: SharedLinkResponseDto;
isOwned: boolean;
}
let { assetManager = $bindable(), sharedLink = $bindable(), isOwned }: Props = $props();
let { sharedLink = $bindable(), isOwned }: Props = $props();
const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction();
@@ -88,13 +86,6 @@
}
}
};
$effect(() => {
// TODO: defer init until trigger updateOptions
if (assets.length === 1) {
void assetManager.updateOptions({ assetId: assets[0].id });
}
});
</script>
<section>
@@ -151,17 +142,19 @@
</ControlAppBar>
{/if}
<section class="my-[160px] mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
<GalleryViewer {assets} {assetInteraction} {assetManager} {viewport} />
<GalleryViewer {assets} {assetInteraction} {viewport} />
</section>
{:else if assets.length === 1}
<AssetViewer
{assetManager}
showCloseButton={false}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(false)}
onClose={() => {}}
/>
{#await getAssetInfo({ id: assets[0].id, key: authManager.key }) then asset}
<AssetViewer
{asset}
showCloseButton={false}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)}
onClose={() => {}}
/>
{/await}
{/if}
</section>

View File

@@ -4,11 +4,11 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
@@ -29,7 +29,6 @@
interface Props {
assets: (TimelineAsset | AssetResponseDto)[];
assetInteraction: AssetInteraction;
assetManager: AssetManager;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
viewport: Viewport;
@@ -47,7 +46,6 @@
let {
assets = $bindable(),
assetInteraction,
assetManager = $bindable(),
disableAssetSelect = false,
showArchiveIcon = false,
viewport,
@@ -62,6 +60,8 @@
pageHeaderOffset = 0,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
let geometry: CommonJustifiedLayout | undefined = $state();
$effect(() => {
@@ -151,7 +151,8 @@
});
const viewAssetHandler = async (asset: TimelineAsset) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
await navigate({ targetRoute: 'current', assetId: assets[currentViewAssetIndex].id });
await setAssetId(assets[currentViewAssetIndex].id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
};
const selectAllAssets = () => {
@@ -291,7 +292,7 @@
const shortcutList = $derived(
(() => {
if (assetManager.showAssetViewer) {
if ($isViewerOpen) {
return [];
}
@@ -343,7 +344,7 @@
}
};
const handleRandom = async (): Promise<boolean> => {
const handleRandom = async (): Promise<{ id: string } | undefined> => {
try {
let asset: { id: string } | undefined;
if (onRandom) {
@@ -356,14 +357,14 @@
}
if (!asset) {
return false;
return;
}
await navigateToAsset(asset);
return true;
return asset;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_next_asset'));
return false;
return;
}
};
@@ -394,8 +395,9 @@
};
const navigateToAsset = async (asset?: { id: string }) => {
if (asset && asset.id !== assetManager.asset.id) {
await navigate({ targetRoute: 'current', assetId: asset.id });
if (asset && asset.id !== $viewingAsset.id) {
await setAssetId(asset.id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
}
};
@@ -413,7 +415,7 @@
} else if (currentViewAssetIndex === assets.length) {
await handlePrevious();
} else {
await navigate({ targetRoute: 'current', assetId: assets[currentViewAssetIndex].id });
await setAssetId(assets[currentViewAssetIndex].id);
}
break;
}
@@ -510,16 +512,16 @@
{/if}
<!-- Overlay Asset Viewer -->
{#if assetManager.showAssetViewer}
{#if $isViewerOpen}
<Portal target="body">
<AssetViewer
{assetManager}
asset={$viewingAsset}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={() => {
assetManager.showAssetViewer = false;
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>

View File

@@ -2,7 +2,7 @@
import { shortcuts } from '$lib/actions/shortcut';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { navigate } from '$lib/utils/navigation';
@@ -15,12 +15,12 @@
interface Props {
assets: AssetResponseDto[];
assetManager: AssetManager;
onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
onStack: (assets: AssetResponseDto[]) => void;
}
let { assets, assetManager, onResolve, onStack }: Props = $props();
let { assets, onResolve, onStack }: Props = $props();
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
// eslint-disable-next-line svelte/no-unnecessary-state-wrap
@@ -39,34 +39,35 @@
});
onDestroy(() => {
assetManager.showAssetViewer = false;
assetViewingStore.showAssetViewer(false);
});
const onNext = async () => {
const index = getAssetIndex(assetManager.asset.id) + 1;
const onNext = () => {
const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) {
return false;
return Promise.resolve(false);
}
await navigate({ targetRoute: 'current', assetId: assets[index].id });
return true;
setAsset(assets[index]);
return Promise.resolve(true);
};
const onPrevious = async () => {
const index = getAssetIndex(assetManager.asset.id) - 1;
const onPrevious = () => {
const index = getAssetIndex($viewingAsset.id) - 1;
if (index < 0) {
return false;
return Promise.resolve(false);
}
await navigate({ targetRoute: 'current', assetId: assets[index].id });
return true;
setAsset(assets[index]);
return Promise.resolve(true);
};
const onRandom = async () => {
const onRandom = () => {
if (assets.length <= 0) {
return false;
return Promise.resolve(undefined);
}
const index = Math.floor(Math.random() * assets.length);
await navigate({ targetRoute: 'current', assetId: assets[index].id });
return true;
const asset = assets[index];
setAsset(asset);
return Promise.resolve(asset);
};
const onSelectAsset = (asset: AssetResponseDto) => {
@@ -101,7 +102,9 @@
{ shortcut: { key: 'a' }, onShortcut: onSelectAll },
{
shortcut: { key: 's' },
onShortcut: () => navigate({ targetRoute: 'current', assetId: assets[0].id }),
onShortcut: () => {
setAsset(assets[0]);
},
},
{ shortcut: { key: 'd' }, onShortcut: onSelectNone },
{ shortcut: { key: 'c', shift: true }, onShortcut: handleResolve },
@@ -167,23 +170,23 @@
{asset}
{onSelectAsset}
isSelected={selectedAssetIds.has(asset.id)}
onViewAsset={(asset) => navigate({ targetRoute: 'current', assetId: asset.id })}
onViewAsset={(asset) => setAsset(asset)}
/>
{/each}
</div>
</div>
{#if assetManager.showAssetViewer}
{#if $showAssetViewer}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
{assetManager}
asset={$viewingAsset}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
{onRandom}
onClose={() => {
assetManager.showAssetViewer = false;
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>

View File

@@ -1,202 +0,0 @@
import type { AssetPackage } from '$lib/managers/asset-manager/asset-package.svelte';
import { loadFromAssetPackage } from '$lib/managers/asset-manager/internal/load-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { type ZoomImageWheelState } from '@zoom-image/core';
import { isEqual } from 'lodash-es';
import { LRUCache } from 'lru-cache';
export enum AssetMediaSize {
Original = 'original',
Fullsize = 'fullsize',
Preview = 'preview',
Thumbnail = 'thumbnail',
Playback = 'playback',
}
export type LoadAssetOptions = {
loadAlbums?: boolean;
loadStack?: boolean;
};
export type AssetManagerOptions = {};
export class AssetManager {
isInitialized = $state(false);
isLoaded = $state(false);
loadError = $state(false);
// The queue waited for load. The first is the currect and the next is preload.
// The preload asset is not need to loading immediately.
assetLoadingQueue: AssetPackage[] = $state([]);
// url: string | undefined = $derived.by(() => {
// if (this.asset) {
// return this.#getAssetUrl(toTimelineAsset(this.asset!));
// }
// });
#maximumLRUCache: number = $state(10);
// TODO: This function is used to test.
dispose(value: AssetPackage, key: string) {
console.log(key);
console.log(value);
}
assetCache: LRUCache<string, AssetPackage> = $state(
new LRUCache({ max: this.#maximumLRUCache, dispose: this.dispose }),
);
showAssetViewer: boolean = $state(false);
gridScrollTarget: AssetGridRouteSearchParams | undefined = $state();
zoomImageState: ZoomImageWheelState | undefined = $state();
initTask = new CancellableTask(
() => (this.isInitialized = true),
() => {
this.assetLoadingQueue = [];
this.assetCache.clear();
this.isInitialized = false;
},
() => void 0,
);
static #INIT_OPTIONS = {};
#options: AssetManagerOptions = AssetManager.#INIT_OPTIONS;
static #DEFAULT_LOAD_ASSET_OPTIONS: LoadAssetOptions = {
loadAlbums: false,
loadStack: false,
};
constructor() {}
async loadAssetPackage(options?: LoadAssetOptions, cancelable?: boolean): Promise<void> {
cancelable = cancelable ?? true;
options = options ?? AssetManager.#DEFAULT_LOAD_ASSET_OPTIONS;
const assetPackage = this.assetLoadingQueue[0];
if (!assetPackage) {
return;
}
if (assetPackage.loader?.executed) {
return;
}
const result = await assetPackage.loader?.execute(async (signal: AbortSignal) => {
await loadFromAssetPackage(this, assetPackage, options, signal);
}, cancelable);
}
async #initializeAsset() {
// TODO: Preload assets.
}
async updateOptions(options: AssetManagerOptions) {
if (this.#options !== AssetManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
return;
}
await this.initTask.reset();
await this.#init(options);
}
async #init(options: AssetManagerOptions) {
this.isInitialized = false;
this.assetLoadingQueue = [];
this.assetCache.clear();
await this.initTask.execute(async () => {
this.#options = options;
await this.#initializeAsset();
}, true);
}
public destroy() {
this.isInitialized = false;
}
// #checkOptions() {
// this.#options.size = AssetMediaSize.Original;
// if (!this.asset || !this.zoomImageState) {
// return;
// }
// if (this.asset.originalMimeType === 'image/gif' || this.zoomImageState.currentZoom > 1) {
// // TODO: use original image forcely and according to the setting.
// }
// }
// #preload() {
// for (const preloadAsset of this.preloadAssets) {
// if (preloadAsset.isImage) {
// let img = new Image();
// const preloadUrl = this.#getAssetUrl(preloadAsset);
// if (preloadUrl) {
// img.src = preloadUrl;
// } else {
// throw new Error('AssetManager is not initialized.');
// }
// }
// }
// }
// #getAssetUrl(asset: TimelineAsset) {
// if (!this.asset) {
// return;
// }
// let path = undefined;
// const searchParameters = new URLSearchParams();
// if (authManager.key) {
// searchParameters.set('key', authManager.key);
// }
// if (this.cacheKey) {
// searchParameters.set('c', this.cacheKey);
// }
// switch (this.#options.size) {
// case AssetMediaSize.Original: {
// path = getAssetOriginalPath(this.asset.id);
// break;
// }
// case AssetMediaSize.Fullsize:
// case AssetMediaSize.Thumbnail:
// case AssetMediaSize.Preview: {
// path = getAssetThumbnailPath(this.asset.id);
// break;
// }
// case AssetMediaSize.Playback: {
// path = getAssetPlaybackPath(this.asset.id);
// break;
// }
// default:
// // TODO: default AssetMediaSize
// }
// return getBaseUrl() + path + '?' + searchParameters.toString();
// }
// get isOriginalImage() {
// return this.#options.size === AssetMediaSize.Original || this.#options.size === AssetMediaSize.Fullsize;
// }
}
// const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
// if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
// return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
// }
// return targetSize === 'original'
// ? getAssetOriginalUrl({ id, cacheKey })
// : getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
// };
// $effect(() => {
// if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
// assetManager.updateOptions({
// size: isWebCompatibleImage(asset) ? AssetMediaSize.Original : AssetMediaSize.Fullsize,
// });
// }
// assetManager.updateOptions({ size: AssetMediaSize.Preview });
// });

View File

@@ -1,48 +0,0 @@
import { CancellableTask } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error';
import type { AlbumResponseDto, AssetResponseDto, StackResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { AssetManager, AssetManagerOptions } from './asset-manager.svelte';
export class AssetPackage {
isLoaded: boolean = $state(false);
asset: AssetResponseDto | undefined = $state();
albums: AlbumResponseDto[] = $state([]);
stack: StackResponseDto | undefined = $state();
readonly assetId: string;
readonly assetManager: AssetManager;
// To ensure albums and stack is need to reloading.
options: AssetManagerOptions | undefined = $state();
loader: CancellableTask | undefined;
constructor(store: AssetManager, assetId: string) {
this.assetManager = store;
this.assetId = assetId;
this.loader = new CancellableTask(
() => {
this.isLoaded = true;
},
() => {
this.asset = undefined;
this.albums = [];
this.stack = undefined;
this.isLoaded = false;
},
this.#handleLoadError,
);
}
// TODO: Add error message to translation.
#handleLoadError(error: unknown) {
const _$t = get(t);
handleError(error, _$t('errors.failed_to_load_asset'));
}
cancel() {
this.loader?.cancel();
}
}

View File

@@ -1,81 +0,0 @@
import type { AssetManager, LoadAssetOptions } from '$lib/managers/asset-manager/asset-manager.svelte';
import { AssetPackage } from '$lib/managers/asset-manager/asset-package.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
// import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAllAlbums, getAssetInfo, getStack } from '@immich/sdk';
export async function loadFromAssetPackage(
assetManager: AssetManager,
assetPackage: AssetPackage,
options: LoadAssetOptions,
signal: AbortSignal,
): Promise<void> {
const assetId = assetPackage.assetId;
const assetCache = assetManager.assetCache.get(assetId);
if (assetCache && assetCache.options === options) {
return;
}
// TODO: Compare between assetCache and assetCache.options to ensure whether we need update or not.
// If there is assetCache, then asset info is not need to update.
if (!assetCache) {
const key = authManager.key;
const assetResponse = await getAssetInfo(
{
id: assetId,
key,
},
{ signal },
);
if (!assetResponse) {
throw new Error('get AssetInfo error');
}
assetPackage.asset = assetResponse;
}
// TODO: need to update albums
if (options.loadAlbums) {
const albumsResponse = await getAllAlbums(
{
assetId,
},
{ signal },
);
if (!albumsResponse) {
throw new Error('get AllAlbums error');
}
assetPackage.albums = albumsResponse;
}
if (options.loadStack) {
const stackResponse = await getStack(
{
id: assetId,
},
{ signal },
);
if (!stackResponse) {
throw new Error('get Stack error');
}
assetPackage.stack = stackResponse;
}
}
export function mediaLoaded(assetManager: AssetManager) {
assetManager.isLoaded = true;
}
export function mediaLoadError(assetManager: AssetManager) {
assetManager.isLoaded = assetManager.loadError = true;
}
// export function cancelImageLoad(assetManager: AssetManager) {
// if (assetManager.url) {
// cancelImageUrl(assetManager.url);
// }
// assetManager.isLoaded = assetManager.loadError = false;
// }

View File

@@ -1,24 +0,0 @@
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { useZoomImageWheel } from '@zoom-image/svelte';
import type { Attachment } from 'svelte/attachments';
export function zoomImageAttachment(assetManager: AssetManager): Attachment<HTMLElement> {
return (element) => {
let zoomImage = $derived(assetManager.zoomImageState);
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(element, { maxZoom: 10 });
$effect(() => {
if (zoomImage) {
setZoomImageState(zoomImage);
}
});
const unsubscribe = zoomImageState.subscribe((value) => (zoomImage = value));
return () => {
unsubscribe();
};
};
}

View File

@@ -1,3 +0,0 @@
import type { AssetPackage } from '$lib/managers/asset-manager/asset-package.svelte';
export const assetPackage = (assetPackage: AssetPackage): AssetPackage => $state.snapshot(assetPackage);

View File

@@ -0,0 +1,40 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>();
const preloadAssets = writable<TimelineAsset[]>([]);
const viewState = writable<boolean>(false);
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => {
preloadAssets.set(assetsToPreload);
viewingAssetStoreState.set(asset);
viewState.set(true);
};
const setAssetId = async (id: string): Promise<AssetResponseDto> => {
const asset = await getAssetInfo({ id, key: authManager.key });
setAsset(asset);
return asset;
};
const showAssetViewer = (show: boolean) => {
viewState.set(show);
};
return {
asset: readonly(viewingAssetStoreState),
preloadAssets: readonly(preloadAssets),
isViewing: viewState,
gridScrollTarget,
setAsset,
setAssetId,
showAssetViewer,
};
}
export const assetViewingStore = createAssetViewingStore();

View File

@@ -0,0 +1,4 @@
import type { ZoomImageWheelState } from '@zoom-image/core';
import { writable } from 'svelte/store';
export const photoZoomState = writable<ZoomImageWheelState>();

View File

@@ -1,6 +1,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { AppRoute } from '$lib/constants';
import { getAssetInfo } from '@immich/sdk';
import type { NavigationTarget } from '@sveltejs/kit';
import { get } from 'svelte/store';
@@ -21,6 +22,10 @@ export const isLockedFolderRoute = (route?: string | null) => !!route?.startsWit
export const isAssetViewerRoute = (target?: NavigationTarget | null) =>
!!(target?.route.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export function getAssetInfoFromParam({ assetId, key }: { assetId?: string; key?: string }) {
return assetId ? getAssetInfo({ id: assetId, key }) : undefined;
}
function currentUrlWithoutAsset() {
const $page = get(page);
// This contains special casing for the /photos/:assetId route, which hangs directly

View File

@@ -2,7 +2,7 @@ export class SlideshowHistory {
private history: { id: string }[] = [];
private index = 0;
constructor(private onChange: (asset: { id: string }) => Promise<void>) {}
constructor(private onChange: (asset: { id: string }) => void) {}
reset() {
this.history = [];
@@ -18,23 +18,23 @@ export class SlideshowHistory {
}
}
async next(): Promise<boolean> {
next(): boolean {
if (this.index === this.history.length - 1) {
return false;
}
this.index++;
await this.onChange(this.history[this.index]);
this.onChange(this.history[this.index]);
return true;
}
async previous(): Promise<boolean> {
previous(): boolean {
if (this.index === 0) {
return false;
}
this.index--;
await this.onChange(this.history[this.index]);
this.onChange(this.history[this.index]);
return true;
}
}

View File

@@ -1,23 +1,32 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { page } from '$app/stores';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Snippet } from 'svelte';
interface Props {
children?: Snippet;
}
let { children }: Props = $props();
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
// page.data.asset is loaded by route specific +page.ts loaders if that
// $page.data.asset is loaded by route specific +page.ts loaders if that
// route contains the assetId path.
// $effect(() => {
// TODO: navigation to the asset grid.
// const asset = page.url.searchParams.get('at');
// gridScrollTarget = { at: asset };
// });
run(() => {
if ($page.data.asset) {
setAsset($page.data.asset);
} else {
$showAssetViewer = false;
}
const asset = $page.url.searchParams.get('at');
$gridScrollTarget = { at: asset };
});
</script>
<!-- display-none is based on assetManager.showAssetViewer -->
<div class:display-none={false}>
<div class:display-none={$showAssetViewer}>
{@render children?.()}
</div>
<UploadCover />

View File

@@ -34,7 +34,6 @@
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -44,6 +43,7 @@
import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { preferences, user } from '$lib/stores/user.store';
@@ -95,6 +95,7 @@
let { data = $bindable() }: Props = $props();
let { isViewing: showAssetViewer, setAssetId, gridScrollTarget } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore;
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
@@ -108,15 +109,6 @@
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
afterNavigate(({ from }) => {
let url: string | undefined = from?.url?.pathname;
@@ -156,8 +148,7 @@
? await timelineManager.getRandomAsset()
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
if (asset) {
await navigate({ targetRoute: 'current', assetId: asset.id });
$slideshowState = SlideshowState.PlaySlideshow;
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
}
};
@@ -175,7 +166,7 @@
viewMode = AlbumPageViewMode.VIEW;
return;
}
if (assetManager.showAssetViewer) {
if ($showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
@@ -355,7 +346,7 @@
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
$effect(() => {
if (assetManager.showAssetViewer || !isShared) {
if ($showAssetViewer || !isShared) {
return;
}
@@ -370,9 +361,7 @@
let isOwned = $derived($user.id == album.ownerId);
let showActivityStatus = $derived(
album.albumUsers.length > 0 &&
!assetManager.showAssetViewer &&
(album.isActivityEnabled || activityManager.commentCount > 0),
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || activityManager.commentCount > 0),
);
let isEditor = $derived(
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
@@ -460,7 +449,6 @@
{album}
{timelineManager}
assetInteraction={currentAssetIntersection}
{assetManager}
{isShared}
{isSelectionMode}
{singleSelect}
@@ -638,7 +626,7 @@
onclick={async () => {
timelineManager.suspendTransitions = true;
viewMode = AlbumPageViewMode.SELECT_ASSETS;
oldAt = { at: assetManager.gridScrollTarget?.at };
oldAt = { at: $gridScrollTarget?.at };
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } },
{ replaceState: true },
@@ -660,7 +648,7 @@
{/if}
{#if $featureFlags.loaded && $featureFlags.map}
<AlbumMap {assetManager} {album} />
<AlbumMap {album} />
{/if}
{#if album.assetCount > 0}
@@ -747,7 +735,7 @@
{/if}
{/if}
</div>
{#if album.albumUsers.length > 0 && album && isShowActivity && $user && !assetManager.showAssetViewer}
{#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}
<div class="flex">
<div
transition:fly={{ duration: 150 }}

View File

@@ -1,14 +1,18 @@
import { authenticate } from '$lib/utils/auth';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getAlbumInfo } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const album = await getAlbumInfo({ id: params.albumId, withoutAssets: true });
const [album, asset] = await Promise.all([
getAlbumInfo({ id: params.albumId, withoutAssets: true }),
getAssetInfoFromParam(params),
]);
return {
album,
assetId: params.assetId,
asset,
meta: {
title: album.albumName,
},

View File

@@ -14,7 +14,6 @@
import { AssetAction } from '$lib/constants';
import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility } from '@immich/sdk';
@@ -34,15 +33,6 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleEscape = () => {
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
@@ -61,7 +51,6 @@
enableRouting={true}
{timelineManager}
{assetInteraction}
{assetManager}
removeAction={AssetAction.UNARCHIVE}
onEscape={handleEscape}
>

View File

@@ -1,13 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
assetId: params.assetId,
asset,
meta: {
title: $t('archive'),
},

View File

@@ -17,7 +17,6 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences } from '$lib/stores/user.store';
@@ -38,15 +37,6 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleEscape = () => {
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
@@ -66,7 +56,6 @@
withStacked={true}
{timelineManager}
{assetInteraction}
{assetManager}
removeAction={AssetAction.UNFAVORITE}
onEscape={handleEscape}
>

View File

@@ -1,13 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
assetId: params.assetId,
asset,
meta: {
title: $t('favorites'),
},

View File

@@ -21,8 +21,8 @@
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { foldersStore } from '$lib/stores/folders.svelte';
import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils';
@@ -32,8 +32,6 @@
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { onDestroy } from 'svelte';
interface Props {
data: PageData;
@@ -45,15 +43,6 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
function getLinkForPath(path: string) {
@@ -117,7 +106,6 @@
<GalleryViewer
assets={data.pathAssets}
{assetInteraction}
{assetManager}
{viewport}
showAssetName={true}
pageHeaderOffset={54}

View File

@@ -2,11 +2,12 @@ import { QueryParameter } from '$lib/constants';
import { foldersStore } from '$lib/stores/folders.svelte';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const [, $t] = await Promise.all([foldersStore.fetchTree(), getFormatter()]);
const [, asset, $t] = await Promise.all([foldersStore.fetchTree(), getAssetInfoFromParam(params), getFormatter()]);
let tree = foldersStore.folders!;
const path = url.searchParams.get(QueryParameter.PATH);
@@ -22,7 +23,7 @@ export const load = (async ({ params, url }) => {
const pathAssets = tree.hasAssets ? await foldersStore.fetchAssetsByPath(tree.path) : null;
return {
assetId: params.assetId,
asset,
tree,
pathAssets,
meta: {

View File

@@ -12,7 +12,6 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility, lockAuthSession } from '@immich/sdk';
@@ -34,15 +33,6 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleEscape = () => {
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
@@ -72,7 +62,6 @@
enableRouting={true}
{timelineManager}
{assetInteraction}
{assetManager}
onEscape={handleEscape}
removeAction={AssetAction.SET_VISIBILITY_TIMELINE}
>

View File

@@ -1,6 +1,7 @@
import { AppRoute } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getAuthStatus } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
@@ -13,10 +14,11 @@ export const load = (async ({ params, url }) => {
redirect(302, `${AppRoute.AUTH_PIN_PROMPT}?continue=${encodeURIComponent(url.pathname + url.search)}`);
}
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
assetId: params.assetId,
asset,
meta: {
title: $t('locked_folder'),
},

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import Map from '$lib/components/shared-components/map/map.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AppRoute } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { navigate } from '$lib/utils/navigation';
@@ -17,23 +19,16 @@
let { data }: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let viewingAssets: string[] = $state([]);
let viewingAssetCursor = 0;
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
onDestroy(() => {
assetManager.showAssetViewer = false;
assetViewingStore.showAssetViewer(false);
});
$effect(() => {
run(() => {
if (!$featureFlags.map) {
handlePromiseError(goto(AppRoute.PHOTOS));
}
@@ -42,12 +37,13 @@
async function onViewAssets(assetIds: string[]) {
viewingAssets = assetIds;
viewingAssetCursor = 0;
await navigate({ targetRoute: 'current', assetId: assetIds[0] });
await setAssetId(assetIds[0]);
}
async function navigateNext() {
if (viewingAssetCursor < viewingAssets.length - 1) {
await navigate({ targetRoute: 'current', assetId: viewingAssets[++viewingAssetCursor] });
await setAssetId(viewingAssets[++viewingAssetCursor]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return true;
}
return false;
@@ -55,19 +51,21 @@
async function navigatePrevious() {
if (viewingAssetCursor > 0) {
await navigate({ targetRoute: 'current', assetId: viewingAssets[--viewingAssetCursor] });
await setAssetId(viewingAssets[--viewingAssetCursor]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return true;
}
return false;
}
async function navigateRandom() {
if (viewingAssets.length > 0) {
const index = Math.floor(Math.random() * viewingAssets.length);
await navigate({ targetRoute: 'current', assetId: viewingAssets[index] });
return true;
if (viewingAssets.length <= 0) {
return undefined;
}
return false;
const index = Math.floor(Math.random() * viewingAssets.length);
const asset = await setAssetId(viewingAssets[index]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return asset;
}
</script>
@@ -78,16 +76,16 @@
</div>
</UserPageLayout>
<Portal target="body">
{#if assetManager.showAssetViewer}
{#if $showAssetViewer}
{#await import('../../../../../lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{assetManager}
asset={$viewingAsset}
showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
onRandom={navigateRandom}
onClose={() => {
assetManager.showAssetViewer = false;
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
isShared={false}

View File

@@ -1,12 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
assetId: params.assetId,
asset,
meta: {
title: $t('map'),
},

View File

@@ -1,23 +1,5 @@
<script lang="ts">
<script>
import MemoryViewer from '$lib/components/memory-page/memory-viewer.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { onDestroy } from 'svelte';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
</script>
<MemoryViewer {assetManager} />
<MemoryViewer />

View File

@@ -1,14 +1,16 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
const user = await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
user,
assetId: params.assetId,
asset,
meta: {
title: $t('memory'),
},

View File

@@ -8,7 +8,6 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import { AppRoute } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility } from '@immich/sdk';
@@ -35,15 +34,6 @@
onDestroy(() => timelineManager.destroy());
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleEscape = () => {
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
@@ -53,7 +43,7 @@
</script>
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
<AssetGrid enableRouting={true} {timelineManager} {assetInteraction} {assetManager} onEscape={handleEscape} />
<AssetGrid enableRouting={true} {timelineManager} {assetInteraction} onEscape={handleEscape} />
</main>
{#if assetInteraction.selectionActive}

View File

@@ -1,5 +1,6 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getUser } from '@immich/sdk';
import type { PageLoad } from './$types';
@@ -7,10 +8,11 @@ export const load = (async ({ params, url }) => {
await authenticate(url);
const partner = await getUser({ id: params.userId });
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
assetId: params.assetId,
asset,
partner,
meta: {
title: $t('partner'),

View File

@@ -31,13 +31,13 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { locale } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
@@ -75,6 +75,7 @@
let { data }: Props = $props();
let numberOfAssets = $state(data.statistics.assets);
let { isViewing: showAssetViewer } = assetViewingStore;
const timelineManager = new TimelineManager();
$effect(() => void timelineManager.updateOptions({ visibility: AssetVisibility.Timeline, personId: data.person.id }));
@@ -82,15 +83,6 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS);
let isEditingName = $state(false);
let previousRoute: string = $state(AppRoute.EXPLORE);
@@ -131,7 +123,7 @@
});
const handleEscape = async () => {
if (assetManager.showAssetViewer) {
if ($showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
@@ -396,7 +388,6 @@
{person}
{timelineManager}
{assetInteraction}
{assetManager}
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
onSelect={handleSelectFeaturePhoto}

View File

@@ -1,21 +1,23 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getPerson, getPersonStatistics } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const [person, statistics] = await Promise.all([
const [person, statistics, asset] = await Promise.all([
getPerson({ id: params.personId }),
getPersonStatistics({ id: params.personId }),
getAssetInfoFromParam(params),
]);
const $t = await getFormatter();
return {
person,
statistics,
assetId: params.assetId,
asset,
meta: {
title: person.name || $t('person'),
},

View File

@@ -22,9 +22,9 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { preferences, user } from '$lib/stores/user.store';
import {
@@ -39,27 +39,12 @@
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let { isViewing: showAssetViewer } = assetViewingStore;
const timelineManager = new TimelineManager();
void timelineManager.updateOptions({ visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true });
onDestroy(() => timelineManager.destroy());
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const assetInteraction = new AssetInteraction();
let selectedAssets = $derived(assetInteraction.selectedAssets);
@@ -74,7 +59,7 @@
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
});
const handleEscape = () => {
if (assetManager.showAssetViewer) {
if ($showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
@@ -108,7 +93,6 @@
enableRouting={true}
{timelineManager}
{assetInteraction}
{assetManager}
removeAction={AssetAction.ARCHIVE}
onEscape={handleEscape}
withStacked

View File

@@ -1,13 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
assetId: params.assetId,
asset,
meta: {
title: $t('photos'),
},

View File

@@ -23,10 +23,10 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences } from '$lib/stores/user.store';
@@ -47,17 +47,11 @@
} from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { onDestroy, tick } from 'svelte';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
// The GalleryViewer pushes it's own history state, which causes weird
@@ -89,17 +83,8 @@
let timelineManager = new TimelineManager();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const onEscape = () => {
if (assetManager.showAssetViewer) {
if ($showAssetViewer) {
return;
}
@@ -394,7 +379,6 @@
<GalleryViewer
assets={searchResultAssets}
{assetInteraction}
{assetManager}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}

View File

@@ -1,13 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
assetId: params.assetId,
asset,
meta: {
title: $t('search'),
},

View File

@@ -5,14 +5,14 @@
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
import PasswordField from '$lib/components/shared-components/password-field.svelte';
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { user } from '$lib/stores/user.store';
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { getMySharedLink, SharedLinkType } from '@immich/sdk';
import { Button } from '@immich/ui';
import { onDestroy, tick } from 'svelte';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -22,20 +22,12 @@
let { data }: Props = $props();
let { gridScrollTarget } = assetViewingStore;
let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = $state(data);
let { title, description } = $state(meta);
let isOwned = $derived($user ? $user.id === sharedLink?.userId : false);
let password = $state('');
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handlePasswordSubmit = async () => {
try {
sharedLink = await getMySharedLink({ password, key });
@@ -47,7 +39,7 @@
$t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } });
await tick();
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: assetManager.gridScrollTarget },
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ forceNavigate: true, replaceState: true },
);
} catch (error) {
@@ -96,10 +88,10 @@
{/if}
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Album}
<AlbumViewer {assetManager} {sharedLink} />
<AlbumViewer {sharedLink} />
{/if}
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Individual}
<div class="immich-scrollbar">
<IndividualSharedViewer {assetManager} {sharedLink} {isOwned} />
<IndividualSharedViewer {sharedLink} {isOwned} />
</div>
{/if}

View File

@@ -1,6 +1,7 @@
import { getAssetThumbnailUrl, setSharedLink } from '$lib/utils';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getMySharedLink, isHttpError } from '@immich/sdk';
import type { PageLoad } from './$types';
@@ -11,7 +12,7 @@ export const load = (async ({ params, url }) => {
const $t = await getFormatter();
try {
const sharedLink = await getMySharedLink({ key });
const [sharedLink, asset] = await Promise.all([getMySharedLink({ key }), getAssetInfoFromParam(params)]);
setSharedLink(sharedLink);
const assetCount = sharedLink.assets.length;
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
@@ -20,7 +21,7 @@ export const load = (async ({ params, url }) => {
return {
sharedLink,
sharedLinkKey: key,
assetId: params.assetId,
asset,
meta: {
title: sharedLink.album ? sharedLink.album.albumName : $t('public_share'),
description: sharedLink.description || $t('shared_photos_and_videos_count', { values: { assetCount } }),

View File

@@ -20,7 +20,6 @@
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
interface Props {
data: PageData;
@@ -34,15 +33,6 @@
$effect(() => void timelineManager.updateOptions({ deferInit: !tag, tagId: tag?.id }));
onDestroy(() => timelineManager.destroy());
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
let tags = $derived<TagResponseDto[]>(data.tags);
const tree = $derived(TreeNode.fromTags(tags));
const tag = $derived(tree.traverse(data.path));
@@ -128,13 +118,7 @@
<section class="mt-2 h-[calc(100%-(--spacing(20)))] overflow-auto immich-scrollbar">
{#if tag.hasAssets}
<AssetGrid
enableRouting={true}
{timelineManager}
{assetInteraction}
{assetManager}
removeAction={AssetAction.UNARCHIVE}
>
<AssetGrid enableRouting={true} {timelineManager} {assetInteraction} removeAction={AssetAction.UNARCHIVE}>
{#snippet empty()}
<TreeItemThumbnails items={tag.children} icon={mdiTag} onClick={handleNavigation} />
{/snippet}

View File

@@ -1,11 +1,13 @@
import { QueryParameter } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getAllTags } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
const tags = await getAllTags();
@@ -13,7 +15,7 @@ export const load = (async ({ params, url }) => {
return {
path: url.searchParams.get(QueryParameter.PATH) ?? '',
tags,
assetId: params.assetId,
asset,
meta: {
title: $t('tags'),
},

View File

@@ -13,7 +13,6 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
@@ -43,15 +42,6 @@
const assetInteraction = new AssetInteraction();
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const handleEmptyTrash = async () => {
const isConfirmed = await modalManager.showDialog({ prompt: $t('empty_trash_confirmation') });
if (!isConfirmed) {
@@ -127,7 +117,7 @@
</HStack>
{/snippet}
<AssetGrid enableRouting={true} {timelineManager} {assetInteraction} {assetManager} onEscape={handleEscape}>
<AssetGrid enableRouting={true} {timelineManager} {assetInteraction} onEscape={handleEscape}>
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
{$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}
</p>

View File

@@ -1,13 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
assetId: params.assetId,
asset,
meta: {
title: $t('trash'),
},

View File

@@ -1,12 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
asset,
meta: {
title: $t('utilities'),
},

View File

@@ -19,8 +19,6 @@
import { mdiCheckOutline, mdiInformationOutline, mdiKeyboard, mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { onDestroy } from 'svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
interface Props {
data: PageData;
@@ -38,15 +36,6 @@
info?: string;
}
const assetManager = new AssetManager();
$effect(() => {
if (data.assetId) {
assetManager.showAssetViewer = true;
void assetManager.updateOptions({ assetId: data.assetId });
}
});
onDestroy(() => assetManager.destroy());
const duplicateShortcuts: Shortcuts = {
general: [],
actions: [
@@ -218,7 +207,6 @@
{#key duplicates[0].duplicateId}
<DuplicatesCompareControl
assets={duplicates[0].assets}
{assetManager}
onResolve={(duplicateAssetIds, trashIds) =>
handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
onStack={(assets) => handleStack(duplicates[0].duplicateId, assets)}

View File

@@ -1,15 +1,17 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getAssetDuplicates } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const duplicates = await getAssetDuplicates();
const $t = await getFormatter();
return {
assetId: params.assetId,
asset,
duplicates,
meta: {
title: $t('duplicates'),