Compare commits

..

11 Commits

Author SHA1 Message Date
Alex Tran
c29919e8aa fix: memory generation and display on first load 2026-01-28 20:52:11 +00:00
Daniel Dietzler
4e0e1b2c5c fix: escape handling (#25627) 2026-01-28 15:05:21 -05:00
Noel S
84c3980844 fix(mobile): show controls by default on motion photos (#25638)
fix: show controls by default on motion photos
2026-01-28 13:46:29 -06:00
Daniel Dietzler
e50579eefc fix: album card ranges (#25639) 2026-01-28 14:38:09 -05:00
Timon
0cb153a971 chore: update uv version setting command in release script (#25583) 2026-01-28 13:56:25 +00:00
Alex
12d23e987b chore: post release tasks (#25582) 2026-01-28 08:55:58 -05:00
Timon
9486eed97e chore(mise): use explicit monorepo config (#25575)
chore(mise): add monorepo configuration with multiple config roots
2026-01-28 08:55:11 -05:00
Paul Makles
913e939606 fix(server): don't assume maintenance action is set (#25622) 2026-01-28 13:55:18 +01:00
Daniel Dietzler
9be01e79f7 fix: correctly show owner in album options modal (#25618) 2026-01-28 06:24:02 -06:00
Daniel Dietzler
2d09853c3d fix: escape handling in search asset viewer (#25621) 2026-01-28 06:23:15 -06:00
Min Idzelis
91831f68e2 fix: deleting asset from asset-viewer on search results (#25596) 2026-01-28 12:31:23 +01:00
29 changed files with 277 additions and 778 deletions

View File

@@ -0,0 +1,116 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
TimelineAssetConfig,
TimelineData,
} from 'src/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { assetViewerUtils } from 'src/web/specs/timeline/utils';
const buildSearchUrl = (assetId: string) => {
const searchQuery = encodeURIComponent(JSON.stringify({ originalFileName: 'test' }));
return `/search/photos/${assetId}?query=${searchQuery}`;
};
test.describe.configure({ mode: 'parallel' });
test.describe('search gallery-viewer', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
await context.route('**/api/search/metadata', async (route, request) => {
if (request.method() === 'POST') {
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: { total: 0, count: 0, items: [], facets: [] },
assets: {
total: searchAssets.length,
count: searchAssets.length,
items: searchAssets,
facets: [],
nextPage: null,
},
},
});
}
await route.fallback();
});
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/search/photos/:id', () => {
test('Deleting a photo advances to the next photo', async ({ page }) => {
const asset = assets[0];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
});
test('Deleting two photos in a row advances to the next photo each time', async ({ page }) => {
const asset = assets[0];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
});
test('Navigating backward then deleting advances to the next photo', async ({ page }) => {
const asset = assets[1];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
});
test('Deleting the last photo advances to the previous photo', async ({ page }) => {
const lastAsset = assets[4];
await page.goto(buildSearchUrl(lastAsset.id));
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
await expect(page.getByLabel('View next asset')).toHaveCount(0);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[3]);
await expect(page.getByLabel('View previous asset')).toBeVisible();
});
});
});

View File

@@ -75,7 +75,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
pnpm --prefix server run build
( cd ./open-api && bash ./bin/generate-open-api.sh )
uvx --from=toml-cli toml set --toml-path=machine-learning/pyproject.toml project.version "$NEXT_SERVER"
uv version --directory machine-learning "$NEXT_SERVER"
./misc/release/archive-version.js "$NEXT_SERVER"
fi

View File

@@ -1,5 +1,18 @@
experimental_monorepo_root = true
[monorepo]
config_roots = [
"plugins",
"server",
"cli",
"deployment",
"mobile",
"e2e",
"web",
"docs",
".github",
]
[tools]
node = "24.13.0"
flutter = "3.35.7"

View File

@@ -741,7 +741,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -885,7 +885,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -915,7 +915,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -949,7 +949,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -992,7 +992,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1032,7 +1032,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1071,7 +1071,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1115,7 +1115,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1156,7 +1156,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CURRENT_PROJECT_VERSION = 240;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -107,7 +107,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>233</string>
<string>240</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -39,6 +39,14 @@ iOS Release to TestFlight
iOS Manual Release
### ios gha_build_only
```sh
[bundle exec] fastlane ios gha_build_only
```
iOS Build Only (no TestFlight upload)
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.

View File

@@ -91,18 +91,6 @@ class ImmichUIShowcasePage extends StatelessWidget {
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
),
),
const _ComponentTitle("HtmlText"),
ImmichHtmlText(
'This is an <b>example</b> of <test-link>HTML text</test-link> with <b>bold</b> and <link>links</link>.',
linkHandlers: {
'link': () {
context.showSnackBar(const SnackBar(content: Text('Link tapped!')));
},
'test-link': () {
context.showSnackBar(const SnackBar(content: Text('Test-link tapped!')));
},
},
),
],
),
),

View File

@@ -92,7 +92,9 @@ class AssetViewer extends ConsumerStatefulWidget {
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
// Hide controls by default for videos and motion photos
}
// Hide controls by default for videos
if (asset.isVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
}

View File

@@ -1,12 +0,0 @@
# Build artifacts
build/
# Platform-specific files are not needed as this is a Flutter UI package
android/
ios/
# Test cache and generated files
.dart_tool/
.packages
.flutter-plugins
.flutter-plugins-dependencies

View File

@@ -1,6 +1,5 @@
export 'src/components/close_button.dart';
export 'src/components/form.dart';
export 'src/components/html_text.dart';
export 'src/components/icon_button.dart';
export 'src/components/password_input.dart';
export 'src/components/text_button.dart';

View File

@@ -1,189 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
enum _HtmlTagType {
bold,
link,
unsupported,
}
class _HtmlTag {
final _HtmlTagType type;
final String tagName;
const _HtmlTag._({required this.type, required this.tagName});
static const unsupported = _HtmlTag._(type: _HtmlTagType.unsupported, tagName: 'unsupported');
static _HtmlTag? fromString(dom.Node node) {
final tagName = (node is dom.Element) ? node.localName : null;
if (tagName == null) {
return null;
}
final tag = tagName.toLowerCase();
return switch (tag) {
'b' || 'strong' => _HtmlTag._(type: _HtmlTagType.bold, tagName: tag),
// Convert <a> back to 'link' for handler lookup
'a' => const _HtmlTag._(type: _HtmlTagType.link, tagName: 'link'),
_ when tag.endsWith('-link') => _HtmlTag._(type: _HtmlTagType.link, tagName: tag),
_ => _HtmlTag.unsupported,
};
}
}
/// A widget that renders text with optional HTML-style formatting.
///
/// Supports the following tags:
/// - `<b>` or `<strong>` for bold text
/// - `<link>` or any tag ending with `-link` for tappable links
///
/// Example:
/// ```dart
/// ImmichHtmlText(
/// 'Refer to <link>docs</link> and <other-link>other</other-link>',
/// linkHandlers: {
/// 'link': () => launchUrl(docsUrl),
/// 'other-link': () => launchUrl(otherUrl),
/// },
/// )
/// ```
class ImmichHtmlText extends StatefulWidget {
final String text;
final TextStyle? style;
final TextAlign? textAlign;
final TextOverflow? overflow;
final int? maxLines;
final bool? softWrap;
final Map<String, VoidCallback>? linkHandlers;
final TextStyle? linkStyle;
const ImmichHtmlText(
this.text, {
super.key,
this.style,
this.textAlign,
this.overflow,
this.maxLines,
this.softWrap,
this.linkHandlers,
this.linkStyle,
});
@override
State<ImmichHtmlText> createState() => _ImmichHtmlTextState();
}
class _ImmichHtmlTextState extends State<ImmichHtmlText> {
final _recognizers = <GestureRecognizer>[];
dom.DocumentFragment _document = dom.DocumentFragment();
@override
void initState() {
super.initState();
_document = html_parser.parseFragment(_preprocessHtml(widget.text));
}
@override
void didUpdateWidget(covariant ImmichHtmlText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.text != widget.text) {
_document = html_parser.parseFragment(_preprocessHtml(widget.text));
}
}
/// `<link>` tags are preprocessed to `<a>` tags because `<link>` is a
/// void element in HTML5 and cannot have children. The linkHandlers still use
/// 'link' as the key.
String _preprocessHtml(String html) {
return html
.replaceAllMapped(
RegExp(r'<(link)>(.*?)</\1>', caseSensitive: false),
(match) => '<a>${match.group(2)}</a>',
)
.replaceAllMapped(
RegExp(r'<(link)\s*/>', caseSensitive: false),
(match) => '<a></a>',
);
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
List<InlineSpan> _buildSpans() {
_disposeRecognizers();
return _document.nodes.expand((node) => _buildNode(node, null, null)).toList();
}
Iterable<InlineSpan> _buildNode(
dom.Node node,
TextStyle? style,
_HtmlTag? parentTag,
) sync* {
if (node is dom.Text) {
if (node.text.isEmpty) {
return;
}
GestureRecognizer? recognizer;
if (parentTag?.type == _HtmlTagType.link) {
final handler = widget.linkHandlers?[parentTag?.tagName];
if (handler != null) {
recognizer = TapGestureRecognizer()..onTap = handler;
_recognizers.add(recognizer);
}
}
yield TextSpan(text: node.text, style: style, recognizer: recognizer);
} else if (node is dom.Element) {
final htmlTag = _HtmlTag.fromString(node);
final tagStyle = _styleForTag(htmlTag);
final mergedStyle = style?.merge(tagStyle) ?? tagStyle;
final newParentTag = htmlTag?.type == _HtmlTagType.link ? htmlTag : parentTag;
for (final child in node.nodes) {
yield* _buildNode(child, mergedStyle, newParentTag);
}
}
}
TextStyle? _styleForTag(_HtmlTag? tag) {
if (tag == null) {
return null;
}
return switch (tag.type) {
_HtmlTagType.bold => const TextStyle(fontWeight: FontWeight.bold),
_HtmlTagType.link => widget.linkStyle ??
TextStyle(
color: Theme.of(context).primaryColor,
decoration: TextDecoration.underline,
),
_HtmlTagType.unsupported => null,
};
}
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(style: widget.style, children: _buildSpans()),
textAlign: widget.textAlign,
overflow: widget.overflow,
maxLines: widget.maxLines,
softWrap: widget.softWrap,
);
}
}

View File

@@ -1,22 +1,6 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
@@ -25,14 +9,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
@@ -41,72 +17,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
html:
dependency: "direct main"
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
@@ -119,71 +34,15 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
version: "1.17.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
vector_math:
dependency: transitive
description:
@@ -192,14 +51,5 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
sdks:
dart: ">=3.8.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

@@ -7,11 +7,6 @@ environment:
dependencies:
flutter:
sdk: flutter
html: ^0.15.6
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true

View File

@@ -1,266 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/components/html_text.dart';
import 'test_utils.dart';
/// Text.rich creates a nested structure: root -> wrapper -> actual children
List<InlineSpan> _getContentSpans(WidgetTester tester) {
final richText = tester.widget<RichText>(find.byType(RichText));
final root = richText.text as TextSpan;
if (root.children?.isNotEmpty ?? false) {
final wrapper = root.children!.first;
if (wrapper is TextSpan && wrapper.children != null) {
return wrapper.children!;
}
}
return [];
}
TextSpan _findSpan(List<InlineSpan> spans, String text) {
return spans.firstWhere(
(span) => span is TextSpan && span.text == text,
orElse: () => throw StateError('No span found with text: "$text"'),
) as TextSpan;
}
String _concatenateText(List<InlineSpan> spans) {
return spans.whereType<TextSpan>().map((s) => s.text ?? '').join();
}
void _triggerTap(TextSpan span) {
final recognizer = span.recognizer;
if (recognizer is TapGestureRecognizer) {
recognizer.onTap?.call();
}
}
void main() {
group('ImmichHtmlText', () {
testWidgets('renders plain text without HTML tags', (tester) async {
await tester.pumpTestWidget(
const ImmichHtmlText('This is plain text'),
);
expect(find.text('This is plain text'), findsOneWidget);
});
testWidgets('handles mixed content with bold and links', (tester) async {
await tester.pumpTestWidget(
ImmichHtmlText(
'This is an <b>example</b> of <b><link>HTML text</link></b> with <b>bold</b>.',
linkHandlers: {'link': () {}},
),
);
final spans = _getContentSpans(tester);
final exampleSpan = _findSpan(spans, 'example');
expect(exampleSpan.style?.fontWeight, FontWeight.bold);
final boldSpan = _findSpan(spans, 'bold');
expect(boldSpan.style?.fontWeight, FontWeight.bold);
final linkSpan = _findSpan(spans, 'HTML text');
expect(linkSpan.style?.decoration, TextDecoration.underline);
expect(linkSpan.style?.fontWeight, FontWeight.bold);
expect(linkSpan.recognizer, isA<TapGestureRecognizer>());
expect(_concatenateText(spans), 'This is an example of HTML text with bold.');
});
testWidgets('applies text style properties', (tester) async {
await tester.pumpTestWidget(
const ImmichHtmlText(
'Test text',
style: TextStyle(
fontSize: 16,
color: Colors.purple,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
final text = tester.widget<Text>(find.byType(Text));
final richText = text.textSpan as TextSpan;
expect(richText.style?.fontSize, 16);
expect(richText.style?.color, Colors.purple);
expect(text.textAlign, TextAlign.center);
expect(text.maxLines, 2);
expect(text.overflow, TextOverflow.ellipsis);
});
testWidgets('handles text with special characters', (tester) async {
await tester.pumpTestWidget(
const ImmichHtmlText('Text with & < > " \' characters'),
);
expect(find.byType(RichText), findsOneWidget);
final spans = _getContentSpans(tester);
expect(_concatenateText(spans), 'Text with & < > " \' characters');
});
group('bold', () {
testWidgets('renders bold text with <b> tag', (tester) async {
await tester.pumpTestWidget(
const ImmichHtmlText('This is <b>bold</b> text'),
);
final spans = _getContentSpans(tester);
final boldSpan = _findSpan(spans, 'bold');
expect(boldSpan.style?.fontWeight, FontWeight.bold);
expect(_concatenateText(spans), 'This is bold text');
});
testWidgets('renders bold text with <strong> tag', (tester) async {
await tester.pumpTestWidget(
const ImmichHtmlText('This is <strong>strong</strong> text'),
);
final spans = _getContentSpans(tester);
final strongSpan = _findSpan(spans, 'strong');
expect(strongSpan.style?.fontWeight, FontWeight.bold);
});
testWidgets('handles nested bold tags', (tester) async {
await tester.pumpTestWidget(
const ImmichHtmlText('Text with <b>bold and <strong>nested</strong></b>'),
);
final spans = _getContentSpans(tester);
final nestedSpan = _findSpan(spans, 'nested');
expect(nestedSpan.style?.fontWeight, FontWeight.bold);
final boldSpan = _findSpan(spans, 'bold and ');
expect(boldSpan.style?.fontWeight, FontWeight.bold);
expect(_concatenateText(spans), 'Text with bold and nested');
});
});
group('link', () {
testWidgets('renders link text with <link> tag', (tester) async {
await tester.pumpTestWidget(
ImmichHtmlText(
'This is a <link>custom link</link> text',
linkHandlers: {'link': () {}},
),
);
final spans = _getContentSpans(tester);
final linkSpan = _findSpan(spans, 'custom link');
expect(linkSpan.style?.decoration, TextDecoration.underline);
expect(linkSpan.recognizer, isA<TapGestureRecognizer>());
});
testWidgets('handles link tap with callback', (tester) async {
var linkTapped = false;
await tester.pumpTestWidget(
ImmichHtmlText(
'Tap <link>here</link>',
linkHandlers: {'link': () => linkTapped = true},
),
);
final spans = _getContentSpans(tester);
final linkSpan = _findSpan(spans, 'here');
expect(linkSpan.recognizer, isA<TapGestureRecognizer>());
_triggerTap(linkSpan);
expect(linkTapped, isTrue);
});
testWidgets('handles custom prefixed link tags', (tester) async {
await tester.pumpTestWidget(
ImmichHtmlText(
'Refer to <docs-link>docs</docs-link> and <other-link>other</other-link>',
linkHandlers: {
'docs-link': () {},
'other-link': () {},
},
),
);
final spans = _getContentSpans(tester);
final docsSpan = _findSpan(spans, 'docs');
final otherSpan = _findSpan(spans, 'other');
expect(docsSpan.style?.decoration, TextDecoration.underline);
expect(otherSpan.style?.decoration, TextDecoration.underline);
});
testWidgets('applies custom link style', (tester) async {
const customLinkStyle = TextStyle(
color: Colors.red,
decoration: TextDecoration.overline,
);
await tester.pumpTestWidget(
ImmichHtmlText(
'Click <link>here</link>',
linkStyle: customLinkStyle,
linkHandlers: {'link': () {}},
),
);
final spans = _getContentSpans(tester);
final linkSpan = _findSpan(spans, 'here');
expect(linkSpan.style?.color, Colors.red);
expect(linkSpan.style?.decoration, TextDecoration.overline);
});
testWidgets('link without handler renders but is not tappable', (tester) async {
await tester.pumpTestWidget(
ImmichHtmlText(
'Link without handler: <link>click me</link>',
linkHandlers: {'other-link': () {}},
),
);
final spans = _getContentSpans(tester);
final linkSpan = _findSpan(spans, 'click me');
expect(linkSpan.style?.decoration, TextDecoration.underline);
expect(linkSpan.recognizer, isNull);
});
testWidgets('handles multiple links with different handlers', (tester) async {
var firstLinkTapped = false;
var secondLinkTapped = false;
await tester.pumpTestWidget(
ImmichHtmlText(
'Go to <docs-link>docs</docs-link> or <help-link>help</help-link>',
linkHandlers: {
'docs-link': () => firstLinkTapped = true,
'help-link': () => secondLinkTapped = true,
},
),
);
final spans = _getContentSpans(tester);
final docsSpan = _findSpan(spans, 'docs');
final helpSpan = _findSpan(spans, 'help');
_triggerTap(docsSpan);
expect(firstLinkTapped, isTrue);
expect(secondLinkTapped, isFalse);
_triggerTap(helpSpan);
expect(secondLinkTapped, isTrue);
});
});
});
}

View File

@@ -1,9 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
extension WidgetTesterExtension on WidgetTester {
/// Pumps a widget wrapped in MaterialApp and Scaffold for testing.
Future<void> pumpTestWidget(Widget widget) {
return pumpWidget(MaterialApp(home: Scaffold(body: widget)));
}
}

View File

@@ -79,7 +79,7 @@ export class MaintenanceWorkerService {
this.#secret = state.secret;
this.#status = {
active: true,
action: state.action.action,
action: state.action?.action ?? MaintenanceAction.Start,
};
StorageCore.setMediaLocation(this.detectMediaLocation());
@@ -88,7 +88,10 @@ export class MaintenanceWorkerService {
this.maintenanceWebsocketRepository.setStatusUpdateFn((status) => (this.#status = status));
await this.logSecret();
void this.runAction(state.action);
if (state.action) {
void this.runAction(state.action);
}
}
/**

View File

@@ -248,6 +248,9 @@ export class MediaService extends BaseService {
await this.assetRepository.update({ id: asset.id, thumbhash });
}
const now = new Date();
await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: now, thumbnailAt: now });
return JobStatus.Success;
}

View File

@@ -490,7 +490,7 @@ export interface MemoryData {
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
export type MaintenanceModeState =
| { isMaintenanceMode: true; secret: string; action: SetMaintenanceModeDto }
| { isMaintenanceMode: true; secret: string; action?: SetMaintenanceModeDto }
| { isMaintenanceMode: false };
export type MemoriesState = {
/** memories have already been created through this date */

View File

@@ -302,6 +302,7 @@
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
1,
@@ -309,10 +310,8 @@
if (assets.length === 0) {
return await goto(Route.photos());
}
if (assetCursor.nextAsset) {
await navigateToAsset(assetCursor.nextAsset);
} else if (assetCursor.previousAsset) {
await navigateToAsset(assetCursor.previousAsset);
if (nextAsset) {
await navigateToAsset(nextAsset);
}
break;
}

View File

@@ -97,7 +97,6 @@
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
invisible = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });

View File

@@ -141,43 +141,41 @@
}
};
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcutList = $derived.by(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return shortcuts;
})(),
);
return shortcuts;
});
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />

View File

@@ -3,13 +3,13 @@
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import {
getAlbumActions,
handleRemoveUserFromAlbum,
handleUpdateAlbum,
handleUpdateUserAlbumRole,
} from '$lib/services/album.service';
import { user } from '$lib/stores/user.store';
import {
AlbumUserRole,
AssetOrder,
@@ -56,7 +56,7 @@
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
};
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album, AlbumPageViewMode.OPTIONS));
let sharedLinks: SharedLinkResponseDto[] = $state([]);
@@ -108,9 +108,9 @@
<div class="ps-2">
<div class="flex items-center gap-2 mb-2">
<div>
<UserAvatar user={$user} size="md" />
<UserAvatar user={album.owner} size="md" />
</div>
<Text class="w-full" size="small">{$user.name}</Text>
<Text class="w-full" size="small">{album.owner.name}</Text>
<Field disabled class="w-32 shrink-0">
<Select options={[{ label: $t('owner'), value: 'owner' }]} value="owner" />
</Field>

View File

@@ -1,5 +1,6 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
@@ -25,7 +26,7 @@ import {
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { mdiArrowLeft, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -39,7 +40,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
return { Create };
};
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, viewMode: AlbumPageViewMode) => {
const isOwned = get(user).id === album.ownerId;
const Share: ActionItem = {
@@ -66,7 +67,16 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
};
return { Share, AddUsers, CreateSharedLink };
const Close: ActionItem = {
title: $t('go_back'),
type: $t('command'),
icon: mdiArrowLeft,
onAction: () => goto(Route.albums()),
$if: () => viewMode === AlbumPageViewMode.VIEW,
shortcuts: { key: 'Escape' },
};
return { Share, AddUsers, CreateSharedLink, Close };
};
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {

View File

@@ -1,5 +1,5 @@
import { writable } from 'svelte/store';
import { getAlbumDateRange, timeToSeconds } from './date-time';
import { getAlbumDateRange, getShortDateRange, timeToSeconds } from './date-time';
describe('converting time to seconds', () => {
it('parses hh:mm:ss correctly', () => {
@@ -49,6 +49,43 @@ describe('converting time to seconds', () => {
});
});
describe('getShortDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
});
afterAll(() => {
vi.unstubAllEnvs();
});
it('should correctly return month if start and end date are within the same month', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
});
it('should correctly return month range if start and end date are in separate months within the same year', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
});
it('should correctly return range if start and end date are in separate months and years', () => {
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
});
it('should correctly return month if start and end date are within the same month, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
});
it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
});
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
});
});
describe('getAlbumDate', () => {
beforeAll(() => {
process.env.TZ = 'UTC';

View File

@@ -19,28 +19,30 @@ export function parseUtcDate(date: string) {
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
}
export const getShortDateRange = (startDate: string | Date, endDate: string | Date) => {
startDate = startDate instanceof Date ? startDate : new Date(startDate);
endDate = endDate instanceof Date ? endDate : new Date(endDate);
export const getShortDateRange = (startTimestamp: string, endTimestamp: string) => {
const userLocale = get(locale);
const endDateLocalized = endDate.toLocaleString(userLocale, {
let startDate = DateTime.fromISO(startTimestamp).setZone('UTC');
let endDate = DateTime.fromISO(endTimestamp).setZone('UTC');
if (userLocale) {
startDate = startDate.setLocale(userLocale);
endDate = endDate.setLocale(userLocale);
}
const endDateLocalized = endDate.toLocaleString({
month: 'short',
year: 'numeric',
// The API returns the date in UTC. If the earliest asset was taken on Jan 1st at 1am,
// we expect the album to start in January, even if the local timezone is UTC-5 for instance.
timeZone: 'UTC',
});
if (startDate.getFullYear() === endDate.getFullYear()) {
if (startDate.getMonth() === endDate.getMonth()) {
if (startDate.year === endDate.year) {
if (startDate.month === endDate.month) {
// Same year and month.
// e.g.: aug. 2024
return endDateLocalized;
} else {
// Same year but different month.
// e.g.: jul. - sept. 2024
const startMonthLocalized = startDate.toLocaleString(userLocale, {
const startMonthLocalized = startDate.toLocaleString({
month: 'short',
});
return `${startMonthLocalized} - ${endDateLocalized}`;
@@ -48,7 +50,7 @@ export const getShortDateRange = (startDate: string | Date, endDate: string | Da
} else {
// Different year.
// e.g.: feb. 2021 - sept. 2024
const startDateLocalized = startDate.toLocaleString(userLocale, {
const startDateLocalized = startDate.toLocaleString({
month: 'short',
year: 'numeric',
});

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, goto, onNavigate } from '$app/navigation';
import { goto, onNavigate } from '$app/navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ActionButton from '$lib/components/ActionButton.svelte';
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
@@ -52,13 +52,7 @@
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import {
isAlbumsRoute,
isPeopleRoute,
isSearchRoute,
navigate,
type AssetGridRouteSearchParams,
} from '$lib/utils/navigation';
import { isAlbumsRoute, navigate, type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { AlbumUserRole, AssetVisibility, getAlbumInfo, updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
import { CommandPaletteDefaultProvider, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import {
@@ -91,7 +85,6 @@
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
let backUrl: string = $state(Route.albums());
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -100,25 +93,6 @@
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
afterNavigate(({ from }) => {
let url: string | undefined = from?.url?.pathname;
const route = from?.route?.id;
if (isSearchRoute(route)) {
url = from?.url.href;
}
if (isAlbumsRoute(route) || isPeopleRoute(route)) {
url = Route.albums();
}
backUrl = url || Route.albums();
if (backUrl === Route.sharedLinks()) {
backUrl = history.state?.backUrl || Route.albums();
}
});
const handleFavorite = async () => {
try {
await activityManager.toggleLike();
@@ -158,7 +132,6 @@
cancelMultiselect(assetInteraction);
return;
}
await goto(backUrl);
return;
};
@@ -305,7 +278,7 @@
const onAlbumDelete = async ({ id }: AlbumResponseDto) => {
if (id === album.id) {
await goto(backUrl);
await goto(Route.albums());
viewMode = AlbumPageViewMode.VIEW;
}
};
@@ -332,7 +305,7 @@
};
const { Cast } = $derived(getGlobalActions($t));
const { Share } = $derived(getAlbumActions($t, album));
const { Share, Close } = $derived(getAlbumActions($t, album, viewMode));
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets));
</script>
@@ -346,7 +319,7 @@
onAlbumUserDelete={refreshAlbum}
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
/>
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload]} />
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload, Close]} />
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: Route.albums() }}>
<div class="relative w-full shrink">
@@ -512,7 +485,7 @@
</AssetSelectControlBar>
{:else}
{#if viewMode === AlbumPageViewMode.VIEW}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(backUrl)}>
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
{#snippet trailing()}
<ActionButton action={Cast} />

View File

@@ -33,7 +33,6 @@
import { Route } from '$lib/route';
import { getPersonActions } from '$lib/services/person.service';
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';
@@ -61,7 +60,6 @@
let { data }: Props = $props();
let numberOfAssets = $derived(data.statistics.assets);
let { isViewing: showAssetViewer } = assetViewingStore;
let timelineManager = $state<TimelineManager>() as TimelineManager;
const options = $derived({ visibility: AssetVisibility.Timeline, personId: data.person.id });
@@ -106,16 +104,13 @@
});
const handleEscape = async () => {
if ($showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
return;
} else {
await goto(previousRoute);
return;
}
await goto(previousRoute);
return;
};
const updateAssetCount = async () => {

View File

@@ -1,10 +1,11 @@
import { memoryStore } from '$lib/stores/memory.store.svelte';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const $t = await getFormatter();
const [$t] = await Promise.all([getFormatter(), memoryStore.ready()]);
return {
meta: {

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
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 GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
@@ -24,7 +23,6 @@
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
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 { preferences, user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
@@ -48,7 +46,6 @@
import { tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
let searchResultsElement: HTMLElement | undefined = $state();
@@ -82,18 +79,6 @@
untrack(() => handlePromiseError(onSearchQueryUpdate()));
});
const onEscape = () => {
if ($showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
assetInteraction.selectedAssets = [];
return;
}
handlePromiseError(goto(previousRoute));
};
$effect(() => {
if (scrollY) {
scrollYHistory = scrollY;
@@ -260,7 +245,6 @@
</script>
<svelte:window bind:scrollY />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
{#if terms}
<section