mirror of
https://github.com/immich-app/immich.git
synced 2026-01-22 09:28:56 -08:00
Compare commits
4 Commits
push-xxxwr
...
feat/dynam
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd71f6379 | ||
|
|
ed9b4e795a | ||
|
|
93c19d1b2e | ||
|
|
074cc1db73 |
@@ -68,56 +68,6 @@ Now make sure that the local album is selected in the backup screen (steps 1-2 a
|
||||
title="Upload button after photos selection"
|
||||
/>
|
||||
|
||||
## Free Up Space
|
||||
|
||||
The **Free Up Space** tool allows you to remove local media files from your device that have already been successfully backed up to your Immich server (and are not in the Immich trash). This helps reclaim storage on your mobile device without losing your memories.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Configuration:**
|
||||
- **Cutoff Date:** You can select a cutoff date. The tool will only look for photos and videos **on or before** this date.
|
||||
- **Filter Options:** You can choose to remove **All** assets, or restrict removal to **Photos only** or **Videos only**.
|
||||
- **Keep Favorites:** By default, local assets marked as favorites are preserved on your device, even if they match the cutoff date.
|
||||
2. **Scan & Review:** Before any files are removed, you are presented with a review screen to verify which items will be deleted.
|
||||
3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. They will be permanently removed by the OS based on your system settings (usually after 30 days).
|
||||
|
||||
:::info Android Permissions
|
||||
For the smoothest experience on Android, you should grant Immich special delete privileges. Without this, you may be prompted to confirm deletion for every single image.
|
||||
|
||||
Go to **Immich Settings > Advanced** and enable **"Media Management Access"**.
|
||||
:::
|
||||
|
||||
### iCloud Photos (iOS Users)
|
||||
|
||||
If you use **iCloud Photos** alongside Immich, it is vital to understand how deletion affects your data. iCloud utilizes a two-way sync; this means deleting a photo from your iPhone to free up space will **also delete it from iCloud**.
|
||||
|
||||
:::warning iCloud & Backups
|
||||
If you rely on iCloud as a secondary backup (part of a 3-2-1 backup strategy), using the Free Up Space feature in Immich will remove the file from both your phone and iCloud.
|
||||
|
||||
Once deleted, the photo will exist **only** on your Immich server (and your phone's "Recently Deleted" folder for 30 days).
|
||||
|
||||
When you use iCloud Photos and delete a photo or video on one device, it's also deleted on all other devices where you're signed in with the same Apple Account.
|
||||
|
||||
More information on the [Apple Support](https://support.apple.com/en-us/108922#iCloud_photo_library) website
|
||||
|
||||
**Shared Albums**
|
||||
Assets that are part of an **iCloud Shared Album** are automatically excluded from the cleanup scan to ensure they remain viewable to others in the shared album.
|
||||
:::
|
||||
|
||||
### External App Dependencies (WhatsApp, etc.)
|
||||
|
||||
:::danger WhatsApp & Local Files
|
||||
Android applications like **WhatsApp** rely on local files to display media in chat history.
|
||||
|
||||
If Immich backs up your WhatsApp folder and you run **Free Up Space**, the local copies of these images will be deleted. Consequently, **media in your WhatsApp chats will appear blurry or missing.** You will only be able to view these photos inside the Immich app; they will no longer be visible within the WhatsApp interface.
|
||||
|
||||
**Recommendation:** If keeping chat history intact is important, please ensure you review the deletion list carefully or consider excluding WhatsApp folders from the backup if you intend to use this feature frequently.
|
||||
:::
|
||||
|
||||
:::info reclaim storage
|
||||
You must empty the system/gallery trash manually to reclaim storage.
|
||||
:::
|
||||
|
||||
## Album Sync
|
||||
|
||||
You can sync or mirror an album from your phone to the Immich server on your account. For example, if you select Recents, Camera and Videos album for backup, the corresponding album with the same name will be created on the server. Once the assets from those albums are uploaded, they will be put into the target albums automatically.
|
||||
|
||||
@@ -1009,11 +1009,9 @@
|
||||
"error_getting_places": "Error getting places",
|
||||
"error_loading_image": "Error loading image",
|
||||
"error_loading_partners": "Error loading partners: {error}",
|
||||
"error_retrieving_asset_information": "Error retrieving asset information",
|
||||
"error_saving_image": "Error: {error}",
|
||||
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
|
||||
"error_title": "Error - Something went wrong",
|
||||
"error_while_navigating": "Error while navigating to asset",
|
||||
"errors": {
|
||||
"cannot_navigate_next_asset": "Cannot navigate to the next asset",
|
||||
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
|
||||
@@ -1557,7 +1555,7 @@
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
"no_albums_yet": "It looks like you do not have any albums yet.",
|
||||
"no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
|
||||
"no_assets_message": "Click to upload your first photo",
|
||||
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
|
||||
"no_assets_to_show": "No assets to show",
|
||||
"no_cast_devices_found": "No cast devices found",
|
||||
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
||||
@@ -2191,7 +2189,6 @@
|
||||
"theme_setting_theme_subtitle": "Choose the app's theme setting",
|
||||
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
||||
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
||||
"then": "Then",
|
||||
"they_will_be_merged_together": "They will be merged together",
|
||||
"third_party_resources": "Third-Party Resources",
|
||||
"time": "Time",
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
||||
final double dimension;
|
||||
class TimelineRow extends MultiChildRenderObjectWidget {
|
||||
final double height;
|
||||
final List<double> widths;
|
||||
final double spacing;
|
||||
final TextDirection textDirection;
|
||||
|
||||
const FixedTimelineRow({
|
||||
const TimelineRow({
|
||||
super.key,
|
||||
required this.dimension,
|
||||
required this.height,
|
||||
required this.widths,
|
||||
required this.spacing,
|
||||
required this.textDirection,
|
||||
required super.children,
|
||||
});
|
||||
|
||||
factory TimelineRow.fixed({
|
||||
required double dimension,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
required List<Widget> children,
|
||||
}) => TimelineRow(
|
||||
height: dimension,
|
||||
widths: List.filled(children.length, dimension),
|
||||
spacing: spacing,
|
||||
textDirection: textDirection,
|
||||
children: children,
|
||||
);
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection);
|
||||
return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
|
||||
renderObject.dimension = dimension;
|
||||
renderObject.height = height;
|
||||
renderObject.widths = widths;
|
||||
renderObject.spacing = spacing;
|
||||
renderObject.textDirection = textDirection;
|
||||
}
|
||||
@@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('height', height));
|
||||
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
@@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
|
||||
RenderFixedRow({
|
||||
List<RenderBox>? children,
|
||||
required double dimension,
|
||||
required double height,
|
||||
required List<double> widths,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
}) : _dimension = dimension,
|
||||
}) : _height = height,
|
||||
_widths = widths,
|
||||
_spacing = spacing,
|
||||
_textDirection = textDirection {
|
||||
addAll(children);
|
||||
}
|
||||
|
||||
double get dimension => _dimension;
|
||||
double _dimension;
|
||||
double get height => _height;
|
||||
double _height;
|
||||
|
||||
set dimension(double value) {
|
||||
if (_dimension == value) return;
|
||||
_dimension = value;
|
||||
set height(double value) {
|
||||
if (_height == value) return;
|
||||
_height = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
List<double> get widths => _widths;
|
||||
List<double> _widths;
|
||||
|
||||
set widths(List<double> value) {
|
||||
if (listEquals(_widths, value)) return;
|
||||
_widths = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@@ -86,7 +116,7 @@ class RenderFixedRow extends RenderBox
|
||||
}
|
||||
}
|
||||
|
||||
double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1);
|
||||
double get intrinsicWidth => widths.sum + (spacing * (childCount - 1));
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
|
||||
@@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox
|
||||
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) => dimension;
|
||||
double computeMinIntrinsicHeight(double width) => height;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) => dimension;
|
||||
double computeMaxIntrinsicHeight(double width) => height;
|
||||
|
||||
@override
|
||||
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
||||
@@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('height', height));
|
||||
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
@@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox
|
||||
return;
|
||||
}
|
||||
// Use the entire width of the parent for the row.
|
||||
size = Size(constraints.maxWidth, dimension);
|
||||
// Each tile is forced to be dimension x dimension.
|
||||
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
|
||||
size = Size(constraints.maxWidth, height);
|
||||
|
||||
final flipMainAxis = textDirection == TextDirection.rtl;
|
||||
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
|
||||
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
|
||||
int childIndex = 0;
|
||||
double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0;
|
||||
// Layout each child horizontally.
|
||||
while (child != null) {
|
||||
while (child != null && childIndex < widths.length) {
|
||||
final width = widths[childIndex];
|
||||
final childConstraints = BoxConstraints.tight(Size(width, height));
|
||||
child.layout(childConstraints, parentUsesSize: false);
|
||||
final childParentData = child.parentData! as _RowParentData;
|
||||
childParentData.offset = offset;
|
||||
offset += Offset(dx, 0);
|
||||
childParentData.offset = Offset(currentX, 0);
|
||||
child = childParentData.nextSibling;
|
||||
childIndex++;
|
||||
|
||||
if (child != null && childIndex < widths.length) {
|
||||
final nextWidth = widths[childIndex];
|
||||
currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||
@@ -78,6 +80,7 @@ class FixedSegment extends Segment {
|
||||
assetCount: numberOfAssets,
|
||||
tileHeight: tileHeight,
|
||||
spacing: spacing,
|
||||
columnCount: columnCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,24 +90,32 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
final int assetCount;
|
||||
final double tileHeight;
|
||||
final double spacing;
|
||||
final int columnCount;
|
||||
|
||||
const _FixedSegmentRow({
|
||||
required this.assetIndex,
|
||||
required this.assetCount,
|
||||
required this.tileHeight,
|
||||
required this.spacing,
|
||||
required this.columnCount,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
|
||||
|
||||
if (isScrubbing) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
|
||||
return _buildAssetRow(
|
||||
context,
|
||||
timelineService.getAssets(assetIndex, assetCount),
|
||||
timelineService,
|
||||
isDynamicLayout,
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder<List<BaseAsset>>(
|
||||
@@ -113,7 +124,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService);
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -122,23 +133,58 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
|
||||
return FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
Widget _buildAssetRow(
|
||||
BuildContext context,
|
||||
List<BaseAsset> assets,
|
||||
TimelineService timelineService,
|
||||
bool isDynamicLayout,
|
||||
) {
|
||||
final children = [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
assetIndex: assetIndex + i,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
final widths = List.filled(assets.length, tileHeight);
|
||||
|
||||
if (isDynamicLayout) {
|
||||
final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
|
||||
final meanAspectRatio = aspectRatios.sum / assets.length;
|
||||
|
||||
// 1: mean width
|
||||
// 0.5: width < mean - threshold
|
||||
// 1.5: width > mean + threshold
|
||||
final arConfiguration = aspectRatios.map((e) {
|
||||
if (e - meanAspectRatio > 0.3) return 1.5;
|
||||
if (e - meanAspectRatio < -0.3) return 0.5;
|
||||
return 1.0;
|
||||
});
|
||||
|
||||
// Normalize to get width distribution
|
||||
final sum = arConfiguration.sum;
|
||||
|
||||
int index = 0;
|
||||
for (final ratio in arConfiguration) {
|
||||
// Distribute the available width proportionally based on aspect ratio configuration
|
||||
widths[index++] = ((ratio * assets.length) / sum) * tileHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return TimelineDragRegion(
|
||||
child: TimelineRow(
|
||||
height: tileHeight,
|
||||
widths: widths,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ abstract class SegmentBuilder {
|
||||
Size size = kTimelineFixedTileExtent,
|
||||
double spacing = kTimelineSpacing,
|
||||
}) => RepaintBoundary(
|
||||
child: FixedTimelineRow(
|
||||
child: TimelineRow.fixed(
|
||||
dimension: size.height,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
|
||||
@@ -285,12 +285,7 @@ class BackgroundUploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
final hasExtension = p.extension(fileName).isNotEmpty;
|
||||
if (!hasExtension) {
|
||||
fileName = p.setExtension(fileName, p.extension(asset.name));
|
||||
}
|
||||
|
||||
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
||||
|
||||
String metadata = UploadTaskMetadata(
|
||||
|
||||
@@ -315,16 +315,7 @@ class ForegroundUploadService {
|
||||
return;
|
||||
}
|
||||
|
||||
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
|
||||
/// Handle special file name from DJI or Fusion app
|
||||
/// If the file name has no extension, likely due to special renaming template by specific apps
|
||||
/// we append the original extension from the asset name
|
||||
final hasExtension = p.extension(fileName).isNotEmpty;
|
||||
if (!hasExtension) {
|
||||
fileName = p.setExtension(fileName, p.extension(asset.name));
|
||||
}
|
||||
|
||||
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
@@ -24,11 +25,12 @@ class LayoutSettings extends HookConsumerWidget {
|
||||
title: "asset_list_layout_sub_title".t(context: context),
|
||||
icon: Icons.view_module_outlined,
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useDynamicLayout,
|
||||
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
if (!Store.isBetaTimelineEnabled)
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useDynamicLayout,
|
||||
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
SettingsSliderListTile(
|
||||
valueNotifier: tilesPerRow,
|
||||
text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/* @import '/usr/ui/dist/theme/default.css'; */
|
||||
|
||||
@utility immich-form-input {
|
||||
@apply bg-gray-100 ring-1 ring-gray-200 transition outline-none focus-within:ring-1 disabled:cursor-not-allowed dark:bg-gray-800 dark:ring-neutral-900 flex w-full items-center rounded-lg disabled:bg-gray-300 disabled:text-dark dark:disabled:bg-gray-900 dark:disabled:text-gray-200 flex-1 py-2.5 text-base pl-4 pr-4;
|
||||
@apply rounded-xl bg-slate-200 px-3 py-3 text-sm focus:border-immich-primary disabled:cursor-not-allowed disabled:bg-gray-400 disabled:text-gray-100 dark:bg-gray-600 dark:text-immich-dark-fg dark:disabled:bg-gray-800 dark:disabled:text-gray-200;
|
||||
}
|
||||
|
||||
@utility immich-form-label {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { asQueueItem } from '$lib/services/queue.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { transformToTitleCase } from '$lib/utils';
|
||||
import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, Link } from '@immich/ui';
|
||||
import {
|
||||
@@ -54,7 +53,7 @@
|
||||
<div class="flex items-center gap-2 text-xl font-semibold text-primary">
|
||||
<Link class="flex items-center gap-2 hover:underline" href={Route.viewQueue(queue)} underline={false}>
|
||||
<Icon {icon} size="1.25em" class="hidden shrink-0 sm:block" />
|
||||
<span>{transformToTitleCase(title)}</span>
|
||||
<span class="uppercase">{title}</span>
|
||||
</Link>
|
||||
<IconButton
|
||||
color="primary"
|
||||
@@ -132,7 +131,7 @@
|
||||
onClick={() => onCommand({ command: QueueCommand.Start, force: false })}
|
||||
>
|
||||
<Icon icon={mdiAlertCircle} size="36" />
|
||||
<span>{$t('disabled')}</span>
|
||||
<span class="uppercase">{$t('disabled')}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
|
||||
@@ -140,7 +139,7 @@
|
||||
{#if waitingCount > 0}
|
||||
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
|
||||
<Icon icon={mdiClose} size="24" />
|
||||
<span>{$t('clear')}</span>
|
||||
<span class="uppercase">{$t('clear')}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{#if queue.isPaused}
|
||||
@@ -148,12 +147,12 @@
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Resume, force: false })}>
|
||||
<!-- size property is not reactive, so have to use width and height -->
|
||||
<Icon icon={mdiFastForward} {size} />
|
||||
<span>{$t('resume')}</span>
|
||||
<span class="uppercase">{$t('resume')}</span>
|
||||
</QueueCardButton>
|
||||
{:else}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
|
||||
<Icon icon={mdiPause} size="24" />
|
||||
<span>{$t('pause')}</span>
|
||||
<span class="uppercase">{$t('pause')}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -162,25 +161,25 @@
|
||||
{#if allText}
|
||||
<QueueCardButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
|
||||
<Icon icon={mdiAllInclusive} size="24" />
|
||||
<span>{allText}</span>
|
||||
<span class="uppercase">{allText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{#if refreshText}
|
||||
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
|
||||
<Icon icon={mdiImageRefreshOutline} size="24" />
|
||||
<span>{refreshText}</span>
|
||||
<span class="uppercase">{refreshText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<Icon icon={mdiSelectionSearch} size="24" />
|
||||
<span>{missingText}</span>
|
||||
<span class="uppercase">{missingText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
|
||||
{#if !disabled && !multipleButtons && isIdle}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<Icon icon={mdiPlay} size="48" />
|
||||
<span>{missingText}</span>
|
||||
<span class="uppercase">{missingText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { handleSystemConfigSave } from '$lib/services/system-config.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
|
||||
import { Heading, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import handlebar from 'handlebars';
|
||||
import * as luxon from 'luxon';
|
||||
import { onDestroy } from 'svelte';
|
||||
@@ -158,9 +158,7 @@
|
||||
{#if configToEdit.storageTemplate.enabled}
|
||||
<hr />
|
||||
|
||||
<Heading size="tiny" color="primary">
|
||||
{$t('variables')}
|
||||
</Heading>
|
||||
<h3 class="text-base font-medium text-primary">{$t('variables')}</h3>
|
||||
|
||||
<section class="support-date">
|
||||
{#await getSupportDateTimeFormat()}
|
||||
@@ -176,14 +174,11 @@
|
||||
<SupportedVariablesPanel />
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col mt-2">
|
||||
<!-- <h3 class="text-base font-medium text-primary">{$t('template')}</h3> -->
|
||||
<Heading size="tiny" color="primary">
|
||||
{$t('template')}
|
||||
</Heading>
|
||||
<div class="flex flex-col mt-4">
|
||||
<h3 class="text-base font-medium text-primary">{$t('template')}</h3>
|
||||
|
||||
<div class="my-2">
|
||||
<Text size="small">{$t('preview')}</Text>
|
||||
<div class="my-2 text-sm">
|
||||
<h4 class="uppercase">{$t('preview')}</h4>
|
||||
</div>
|
||||
|
||||
<p class="text-sm">
|
||||
@@ -254,9 +249,7 @@
|
||||
|
||||
{#if !minified}
|
||||
<div id="migration-info" class="mt-2 text-sm">
|
||||
<Heading size="tiny" color="primary">
|
||||
{$t('notes')}
|
||||
</Heading>
|
||||
<h3 class="text-base font-medium text-primary">{$t('notes')}</h3>
|
||||
<section class="flex flex-col gap-2">
|
||||
<p>
|
||||
<FormatMessage
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
|
||||
import { Card, CardBody, CardHeader, Text } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -16,80 +15,78 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<Text size="small">{$t('date_and_time')}</Text>
|
||||
<div class="mt-2 text-sm">
|
||||
<h4 class="uppercase">{$t('date_and_time')}</h4>
|
||||
</div>
|
||||
|
||||
<!-- eslint-disable svelte/no-useless-mustaches -->
|
||||
<Card class="mt-2 text-sm bg-light-50 shadow-none">
|
||||
<CardHeader>
|
||||
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
|
||||
<Text color="primary"
|
||||
>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}</Text
|
||||
>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3 md:grid-cols-4">
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('year')}</Text>
|
||||
<ul>
|
||||
{#each options.yearOptions as yearFormat, index (index)}
|
||||
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('month')}</Text>
|
||||
<ul>
|
||||
{#each options.monthOptions as monthFormat, index (index)}
|
||||
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('week')}</Text>
|
||||
<ul>
|
||||
{#each options.weekOptions as weekFormat, index (index)}
|
||||
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('day')}</Text>
|
||||
<ul>
|
||||
{#each options.dayOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('hour')}</Text>
|
||||
<ul>
|
||||
{#each options.hourOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('minute')}</Text>
|
||||
<ul>
|
||||
{#each options.minuteOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('second')}</Text>
|
||||
<ul>
|
||||
{#each options.secondOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-2 rounded-lg bg-gray-200 p-4 text-xs dark:bg-gray-700 dark:text-immich-dark-fg">
|
||||
<div class="mb-2 text-gray-600 dark:text-immich-dark-fg">
|
||||
<p>{$t('admin.storage_template_date_time_description')}</p>
|
||||
<p>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}</p>
|
||||
</div>
|
||||
<div class="flex gap-10">
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('year')}</p>
|
||||
<ul>
|
||||
{#each options.yearOptions as yearFormat, index (index)}
|
||||
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('month')}</p>
|
||||
<ul>
|
||||
{#each options.monthOptions as monthFormat, index (index)}
|
||||
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('week')}</p>
|
||||
<ul>
|
||||
{#each options.weekOptions as weekFormat, index (index)}
|
||||
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('day')}</p>
|
||||
<ul>
|
||||
{#each options.dayOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('hour')}</p>
|
||||
<ul>
|
||||
{#each options.hourOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('minute')}</p>
|
||||
<ul>
|
||||
{#each options.minuteOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('second')}</p>
|
||||
<ul>
|
||||
{#each options.secondOptions as dayFormat, index (index)}
|
||||
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,59 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { Card, CardBody, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<Text size="small">{$t('other_variables')}</Text>
|
||||
<div class="mt-4 text-sm">
|
||||
<h4 class="uppercase">{$t('other_variables')}</h4>
|
||||
</div>
|
||||
|
||||
<Card class="mt-2 text-sm bg-light-50 shadow-none">
|
||||
<CardBody>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('filename')}</Text>
|
||||
<ul>
|
||||
<li>{`{{filename}}`} - IMG_123</li>
|
||||
<li>{`{{ext}}`} - jpg</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('filetype')}</Text>
|
||||
<ul>
|
||||
<li>{`{{filetype}}`} - VID or IMG</li>
|
||||
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('camera')}</Text>
|
||||
<ul>
|
||||
<li>{`{{make}}`} - FUJIFILM</li>
|
||||
<li>{`{{model}}`} - X-T50</li>
|
||||
<li>{`{{lensModel}}`} - XF27mm F2.8 R WR</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('album')}</Text>
|
||||
<ul>
|
||||
<li>{`{{album}}`} - Album Name</li>
|
||||
<li>
|
||||
{`{{album-startDate-x}}`} - Album Start Date and Time (e.g. album-startDate-yy).
|
||||
{$t('admin.storage_template_date_time_sample', { values: { date: '2021-12-31T05:32:41.750' } })}
|
||||
</li>
|
||||
<li>
|
||||
{`{{album-endDate-x}}`} - Album End Date and Time (e.g. album-endDate-MM).
|
||||
{$t('admin.storage_template_date_time_sample', { values: { date: '2023-05-06T09:15:17.100' } })}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('other')}</Text>
|
||||
<ul>
|
||||
<li>{`{{assetId}}`} - Asset ID</li>
|
||||
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
|
||||
<div class="flex gap-12">
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('filename')}</p>
|
||||
<ul>
|
||||
<li>{`{{filename}}`} - IMG_123</li>
|
||||
<li>{`{{ext}}`} - jpg</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('filetype')}</p>
|
||||
<ul>
|
||||
<li>{`{{filetype}}`} - VID or IMG</li>
|
||||
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('album')}</p>
|
||||
<ul>
|
||||
<li>{`{{album}}`} - Album Name</li>
|
||||
<li>
|
||||
{`{{album-startDate-x}}`} - Album Start Date and Time (e.g. album-startDate-yy).
|
||||
{$t('admin.storage_template_date_time_sample', { values: { date: '2021-12-31T05:32:41.750' } })}
|
||||
</li>
|
||||
<li>
|
||||
{`{{album-endDate-x}}`} - Album End Date and Time (e.g. album-endDate-MM).
|
||||
{$t('admin.storage_template_date_time_sample', { values: { date: '2023-05-06T09:15:17.100' } })}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('camera')}</p>
|
||||
<ul>
|
||||
<li>{`{{make}}`} - FUJIFILM</li>
|
||||
<li>{`{{model}}`} - X-T50</li>
|
||||
<li>{`{{lensModel}}`} - XF27mm F2.8 R WR</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('other')}</p>
|
||||
<ul>
|
||||
<li>{`{{assetId}}`} - Asset ID</li>
|
||||
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('AssetViewerNavBar component', () => {
|
||||
preAction: () => {},
|
||||
onZoomImage: () => {},
|
||||
onAction: () => {},
|
||||
onEdit: () => {},
|
||||
onRunJob: () => {},
|
||||
onPlaySlideshow: () => {},
|
||||
onClose: () => {},
|
||||
playOriginalVideo: false,
|
||||
|
||||
@@ -28,11 +28,12 @@
|
||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||
import { getAssetJobName, getSharedLink, withoutIcons } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
type AlbumResponseDto,
|
||||
@@ -43,9 +44,13 @@
|
||||
import { CommandPaletteDefaultProvider, IconButton, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiCogRefreshOutline,
|
||||
mdiCompare,
|
||||
mdiContentCopy,
|
||||
mdiDatabaseRefreshOutline,
|
||||
mdiDotsVertical,
|
||||
mdiHeadSyncOutline,
|
||||
mdiImageRefreshOutline,
|
||||
mdiImageSearch,
|
||||
mdiMagnifyMinusOutline,
|
||||
mdiMagnifyPlusOutline,
|
||||
@@ -66,6 +71,7 @@
|
||||
preAction: PreAction;
|
||||
onAction: OnAction;
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
onRunJob: (name: AssetJobName) => void;
|
||||
onPlaySlideshow: () => void;
|
||||
onEdit: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -84,6 +90,7 @@
|
||||
preAction,
|
||||
onAction,
|
||||
onUndoDelete = undefined,
|
||||
onRunJob,
|
||||
onPlaySlideshow,
|
||||
onClose,
|
||||
onEdit,
|
||||
@@ -117,10 +124,6 @@
|
||||
PlayMotionPhoto,
|
||||
StopMotionPhoto,
|
||||
Info,
|
||||
RefreshFacesJob,
|
||||
RefreshMetadataJob,
|
||||
RegenerateThumbnailJob,
|
||||
TranscodeVideoJob,
|
||||
} = $derived(getAssetActions($t, asset));
|
||||
const sharedLink = getSharedLink();
|
||||
|
||||
@@ -137,24 +140,7 @@
|
||||
|
||||
<CommandPaletteDefaultProvider
|
||||
name={$t('assets')}
|
||||
actions={withoutIcons([
|
||||
Close,
|
||||
Cast,
|
||||
Share,
|
||||
Download,
|
||||
DownloadOriginal,
|
||||
SharedLinkDownload,
|
||||
Offline,
|
||||
Favorite,
|
||||
Unfavorite,
|
||||
PlayMotionPhoto,
|
||||
StopMotionPhoto,
|
||||
Info,
|
||||
RefreshFacesJob,
|
||||
RefreshMetadataJob,
|
||||
RegenerateThumbnailJob,
|
||||
TranscodeVideoJob,
|
||||
])}
|
||||
actions={withoutIcons([Close, Share, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info])}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -289,10 +275,28 @@
|
||||
{/if}
|
||||
{#if isOwner}
|
||||
<hr />
|
||||
<ActionMenuItem action={RefreshFacesJob} />
|
||||
<ActionMenuItem action={RefreshMetadataJob} />
|
||||
<ActionMenuItem action={RegenerateThumbnailJob} />
|
||||
<ActionMenuItem action={TranscodeVideoJob} />
|
||||
<MenuOption
|
||||
icon={mdiHeadSyncOutline}
|
||||
onClick={() => onRunJob(AssetJobName.RefreshFaces)}
|
||||
text={$getAssetJobName(AssetJobName.RefreshFaces)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiDatabaseRefreshOutline}
|
||||
onClick={() => onRunJob(AssetJobName.RefreshMetadata)}
|
||||
text={$getAssetJobName(AssetJobName.RefreshMetadata)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiImageRefreshOutline}
|
||||
onClick={() => onRunJob(AssetJobName.RegenerateThumbnail)}
|
||||
text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
|
||||
/>
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<MenuOption
|
||||
icon={mdiCogRefreshOutline}
|
||||
onClick={() => onRunJob(AssetJobName.TranscodeVideo)}
|
||||
text={$getAssetJobName(AssetJobName.TranscodeVideo)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</ButtonContextMenu>
|
||||
{/if}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -28,15 +28,18 @@
|
||||
import { preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetTypeEnum,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
getStack,
|
||||
runAssetJobs,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
type PersonResponseDto,
|
||||
type StackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -249,7 +252,7 @@
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
}
|
||||
}, $t('error_while_navigating'));
|
||||
});
|
||||
};
|
||||
|
||||
const showEditor = () => {
|
||||
@@ -259,6 +262,15 @@
|
||||
isShowEditor = !isShowEditor;
|
||||
};
|
||||
|
||||
const handleRunJob = async (name: AssetJobName) => {
|
||||
try {
|
||||
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
|
||||
toastManager.success($getAssetJobMessage(name));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_submit_job'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Slide show mode
|
||||
*/
|
||||
@@ -461,6 +473,7 @@
|
||||
onAction={handleAction}
|
||||
{onUndoDelete}
|
||||
onEdit={showEditor}
|
||||
onRunJob={handleRunJob}
|
||||
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
||||
onClose={onClose ? () => onClose(asset) : undefined}
|
||||
{playOriginalVideo}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { removeTag } from '$lib/utils/asset-utils';
|
||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, modalManager, Text } from '@immich/ui';
|
||||
import { Icon, modalManager } from '@immich/ui';
|
||||
import { mdiClose, mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
{#if isOwner && !authManager.isSharedLink}
|
||||
<section class="px-4 mt-4">
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm">
|
||||
<Text color="muted">{$t('tags')}</Text>
|
||||
<h2 class="uppercase">{$t('tags')}</h2>
|
||||
</div>
|
||||
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
|
||||
{#each tags as tag (tag.id)}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
mdiCamera,
|
||||
@@ -160,7 +160,7 @@
|
||||
{#if !authManager.isSharedLink && isOwner}
|
||||
<section class="px-4 pt-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<Text size="small" color="muted">{$t('people')}</Text>
|
||||
<h2 class="uppercase">{$t('people')}</h2>
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<IconButton
|
||||
@@ -259,10 +259,10 @@
|
||||
<div class="px-4 py-4">
|
||||
{#if asset.exifInfo}
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm">
|
||||
<Text size="small" color="muted">{$t('details')}</Text>
|
||||
<h2 class="uppercase">{$t('details')}</h2>
|
||||
</div>
|
||||
{:else}
|
||||
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
|
||||
<p class="uppercase text-sm">{$t('no_exif_info_available')}</p>
|
||||
{/if}
|
||||
|
||||
{#if dateTime}
|
||||
@@ -487,7 +487,7 @@
|
||||
|
||||
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
|
||||
<section class="px-6 dark:text-immich-dark-fg mt-4">
|
||||
<Text size="small" color="muted">{$t('shared_by')}</Text>
|
||||
<p class="uppercase text-sm">{$t('shared_by')}</p>
|
||||
<div class="flex gap-4 pt-4">
|
||||
<div>
|
||||
<UserAvatar user={asset.owner} size="md" />
|
||||
@@ -504,9 +504,7 @@
|
||||
|
||||
{#if albums.length > 0}
|
||||
<section class="px-6 py-6 dark:text-immich-dark-fg">
|
||||
<div class="pb-4">
|
||||
<Text size="small" color="muted">{$t('appears_in')}</Text>
|
||||
</div>
|
||||
<p class="uppercase pb-4 text-sm">{$t('appears_in')}</p>
|
||||
{#each albums as album (album.id)}
|
||||
<a href={Route.viewAlbum(album)}>
|
||||
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="px-4 py-4 text-sm">
|
||||
<h2 class="mb-8 mt-4">{$t('all_people')}</h2>
|
||||
<h2 class="mb-8 mt-4 uppercase">{$t('all_people')}</h2>
|
||||
{#if isShowLoadingPeople}
|
||||
<div class="flex w-full justify-center">
|
||||
<LoadingSpinner />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
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';
|
||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
||||
@@ -70,8 +70,8 @@
|
||||
isShowLoadingPeople = false;
|
||||
}
|
||||
|
||||
const onPersonThumbnailReady = ({ id }: { id: string }) => {
|
||||
assetFaceGenerated.push(id);
|
||||
const onPersonThumbnail = (personId: string) => {
|
||||
assetFaceGenerated.push(personId);
|
||||
if (
|
||||
isEqual(assetFaceGenerated, peopleToCreate) &&
|
||||
loaderLoadingDoneTimeout &&
|
||||
@@ -86,6 +86,7 @@
|
||||
|
||||
onMount(() => {
|
||||
handlePromiseError(loadPeople());
|
||||
return websocketEvents.on('on_person_thumbnail', onPersonThumbnail);
|
||||
});
|
||||
|
||||
const isEqual = (a: string[], b: string[]): boolean => {
|
||||
@@ -183,8 +184,6 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onPersonThumbnailReady} />
|
||||
|
||||
<section
|
||||
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
||||
class="absolute top-0 h-full w-90 overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<Icon {icon} size="30" class="text-primary" />
|
||||
{/if}
|
||||
{#if title}
|
||||
<p class="text-xl text-primary font-medium">
|
||||
<p class="uppercase text-xl text-primary">
|
||||
{title}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary"
|
||||
>
|
||||
<Icon icon={sunPath} viewBox={sunViewBox} size="96" />
|
||||
<p class="font-semibold text-4xl">{$t('light')}</p>
|
||||
<p class="uppercase font-semibold text-4xl">{$t('light')}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -31,7 +31,7 @@
|
||||
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary"
|
||||
>
|
||||
<Icon icon={moonPath} viewBox={moonViewBox} size="96" />
|
||||
<p class="font-semibold text-4xl">{$t('dark')}</p>
|
||||
<p class="uppercase font-semibold text-4xl">{$t('dark')}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { Icon, IconButton, Label } from '@immich/ui';
|
||||
import { mdiChevronDown, mdiClose, mdiMagnify } from '@mdi/js';
|
||||
import { mdiClose, mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { FormEventHandler } from 'svelte/elements';
|
||||
@@ -251,7 +251,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={onPositionChange} />
|
||||
<Label class="block mb-1 {hideLabel ? 'sr-only' : ''} text-xs text-neutral-500 font-light" for={inputId}>{label}</Label>
|
||||
<Label class="block mb-1 {hideLabel ? 'sr-only' : ''}" for={inputId}>{label}</Label>
|
||||
<div
|
||||
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
||||
use:focusOutside={{ onFocusOut: deactivate }}
|
||||
@@ -351,7 +351,7 @@
|
||||
size="small"
|
||||
/>
|
||||
{:else if !isOpen}
|
||||
<Icon icon={mdiChevronDown} aria-hidden />
|
||||
<Icon icon={mdiUnfoldMoreHorizontal} aria-hidden />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,7 +391,7 @@
|
||||
<li
|
||||
aria-selected={index === selectedIndex}
|
||||
bind:this={optionRefs[index]}
|
||||
class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 wrap-break-words"
|
||||
class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
|
||||
id={`${listboxId}-${index}`}
|
||||
onclick={() => handleSelect(option)}
|
||||
role="option"
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -82,7 +81,8 @@
|
||||
</script>
|
||||
|
||||
<div id="camera-selection">
|
||||
<Text fontWeight="medium">{$t('camera')}</Text>
|
||||
<p class="uppercase immich-form-label">{$t('camera')}</p>
|
||||
|
||||
<div class="grid grid-auto-fit-40 gap-5 mt-1">
|
||||
<div class="w-full">
|
||||
<Combobox
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts" module>
|
||||
export interface SearchDateFilter {
|
||||
takenBefore?: DateTime;
|
||||
takenAfter?: DateTime;
|
||||
takenBefore?: string;
|
||||
takenAfter?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { DatePicker, Text } from '@immich/ui';
|
||||
import type { DateTime } from 'luxon';
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -17,19 +17,23 @@
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
|
||||
let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore);
|
||||
|
||||
const inputClasses = $derived(
|
||||
`immich-form-input w-full mt-1 hover:cursor-pointer ${invalid ? 'border border-danger' : ''}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">
|
||||
<div>
|
||||
<Text class="mb-2" fontWeight="medium">{$t('start_date')}</Text>
|
||||
<DatePicker bind:value={filters.takenAfter} />
|
||||
</div>
|
||||
<label class="immich-form-label" for="start-date">
|
||||
<span class="uppercase">{$t('start_date')}</span>
|
||||
<DateInput class={inputClasses} type="date" id="start-date" name="start-date" bind:value={filters.takenAfter} />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Text class="mb-2" fontWeight="medium">{$t('end_date')}</Text>
|
||||
<DatePicker bind:value={filters.takenBefore} />
|
||||
</div>
|
||||
<label class="immich-form-label" for="end-date">
|
||||
<span class="uppercase">{$t('end_date')}</span>
|
||||
<DateInput class={inputClasses} type="date" id="end-date" name="end-date" bind:value={filters.takenBefore} />
|
||||
</label>
|
||||
</div>
|
||||
{#if invalid}
|
||||
<Text color="danger">{$t('start_date_before_end_date')}</Text>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Checkbox, Label, Text } from '@immich/ui';
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -20,20 +20,19 @@
|
||||
|
||||
<div id="display-options-selection">
|
||||
<fieldset>
|
||||
<Text class="mb-2" fontWeight="medium">{$t('display_options')}</Text>
|
||||
|
||||
<legend class="uppercase immich-form-label">{$t('display_options')}</legend>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} />
|
||||
<Label label={$t('not_in_any_album')} for="not-in-album-checkbox" class="text-sm font-normal" />
|
||||
<Label label={$t('not_in_any_album')} for="not-in-album-checkbox" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="archive-checkbox" size="tiny" bind:checked={filters.isArchive} />
|
||||
<Label label={$t('archive')} for="archive-checkbox" class="text-sm font-normal" />
|
||||
<Label label={$t('archive')} for="archive-checkbox" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="favorites-checkbox" size="tiny" bind:checked={filters.isFavorite} />
|
||||
<Label label={$t('favorites')} for="favorites-checkbox" class="text-sm font-normal" />
|
||||
<Label label={$t('favorites')} for="favorites-checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { Icon, IconButton, Text } from '@immich/ui';
|
||||
import { Icon, IconButton } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -97,7 +97,7 @@
|
||||
class="absolute w-full rounded-b-3xl border-2 border-t-0 border-gray-200 bg-white pb-5 shadow-2xl transition-all dark:border-gray-700 dark:bg-immich-dark-gray dark:text-gray-300 z-1"
|
||||
>
|
||||
<div class="flex items-center justify-between px-5 pt-5 text-xs">
|
||||
<Text class="py-2" color="muted" aria-hidden={true}>{$t('recent_searches')}</Text>
|
||||
<p class="uppercase py-2" aria-hidden={true}>{$t('recent_searches')}</p>
|
||||
{#if showClearAll}
|
||||
<button
|
||||
id={getId(0)}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -75,7 +74,7 @@
|
||||
</script>
|
||||
|
||||
<div id="location-selection">
|
||||
<Text fontWeight="medium">{$t('place')}</Text>
|
||||
<p class="uppercase immich-form-label">{$t('place')}</p>
|
||||
|
||||
<div class="grid grid-auto-fit-40 gap-5 mt-1">
|
||||
<div class="w-full">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { MediaType } from '$lib/constants';
|
||||
import RadioButton from '$lib/elements/RadioButton.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -13,8 +12,7 @@
|
||||
|
||||
<div id="media-type-selection">
|
||||
<fieldset>
|
||||
<Text class="mb-2" fontWeight="medium">{$t('media_type')}</Text>
|
||||
|
||||
<legend class="uppercase immich-form-label">{$t('media_type')}</legend>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||
<RadioButton name="media-type" id="type-all" bind:group={filteredMedia} label={$t('all')} value={MediaType.All} />
|
||||
<RadioButton
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { Button, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiArrowRight, mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
@@ -63,12 +63,12 @@
|
||||
|
||||
<div id="people-selection" class="max-h-60 -mb-4 overflow-y-auto immich-scrollbar">
|
||||
<div class="flex items-center w-full justify-between gap-6">
|
||||
<Text class="py-3" fontWeight="medium">{$t('people')}</Text>
|
||||
<p class="uppercase immich-form-label py-3">{$t('people')}</p>
|
||||
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
<SingleGridRow
|
||||
class="grid grid-auto-fill-20 gap-1 mt-2 overflow-y-auto immich-scrollbar space-between"
|
||||
class="grid grid-auto-fill-20 gap-1 mt-2 overflow-y-auto immich-scrollbar"
|
||||
bind:itemCount={numberOfPeople}
|
||||
>
|
||||
{#each peopleList as person (person.id)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Combobox from '../combobox.svelte';
|
||||
|
||||
@@ -19,14 +18,16 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<Text class="mb-2" fontWeight="medium">{$t('rating')}</Text>
|
||||
<Combobox
|
||||
label={$t('rating')}
|
||||
placeholder={$t('search_rating')}
|
||||
hideLabel
|
||||
{options}
|
||||
selectedOption={rating === undefined ? undefined : options[rating]}
|
||||
onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))}
|
||||
/>
|
||||
<div class="grid grid-auto-fit-40 gap-5">
|
||||
<label class="immich-form-label" for="start-date">
|
||||
<div class="[&_label]:uppercase">
|
||||
<Combobox
|
||||
label={$t('rating')}
|
||||
placeholder={$t('search_rating')}
|
||||
{options}
|
||||
selectedOption={rating === undefined ? undefined : options[rating]}
|
||||
onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { getAllTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { Checkbox, Icon, Label, Text } from '@immich/ui';
|
||||
import { Checkbox, Icon, Label } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -43,18 +43,18 @@
|
||||
{#if $preferences?.tags?.enabled}
|
||||
<div id="location-selection">
|
||||
<form autocomplete="off" id="create-tag-form">
|
||||
<div class="mb-4 flex flex-col">
|
||||
<Text class="py-3" fontWeight="medium">{$t('tags')}</Text>
|
||||
<Combobox
|
||||
disabled={selectedTags === null}
|
||||
hideLabel
|
||||
onSelect={handleSelect}
|
||||
label={$t('tags')}
|
||||
defaultFirstOption
|
||||
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
|
||||
bind:selectedOption
|
||||
placeholder={$t('search_tags')}
|
||||
/>
|
||||
<div class="my-4 flex flex-col gap-2">
|
||||
<div class="[&_label]:uppercase">
|
||||
<Combobox
|
||||
disabled={selectedTags === null}
|
||||
onSelect={handleSelect}
|
||||
label={$t('tags')}
|
||||
defaultFirstOption
|
||||
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
|
||||
bind:selectedOption
|
||||
placeholder={$t('search_tags')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
@@ -65,7 +65,7 @@
|
||||
selectedTags = checked ? null : new SvelteSet();
|
||||
}}
|
||||
/>
|
||||
<Label label={$t('untagged')} for="untagged-checkbox" class="text-sm font-normal" />
|
||||
<Label label={$t('untagged')} for="untagged-checkbox" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import RadioButton from '$lib/elements/RadioButton.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Field, Input, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -12,48 +11,73 @@
|
||||
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<fieldset>
|
||||
<Text class="mb-2" fontWeight="medium">{$t('search_type')}</Text>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 my-2">
|
||||
{#if featureFlagsManager.value.smartSearch}
|
||||
<RadioButton name="query-type" id="context-radio" label={$t('context')} bind:group={queryType} value="smart" />
|
||||
{/if}
|
||||
<RadioButton
|
||||
name="query-type"
|
||||
id="file-name-radio"
|
||||
label={$t('file_name_or_extension')}
|
||||
bind:group={queryType}
|
||||
value="metadata"
|
||||
/>
|
||||
<RadioButton
|
||||
name="query-type"
|
||||
id="description-radio"
|
||||
label={$t('description')}
|
||||
bind:group={queryType}
|
||||
value="description"
|
||||
/>
|
||||
{#if featureFlagsManager.value.ocr}
|
||||
<RadioButton name="query-type" id="ocr-radio" label={$t('ocr')} bind:group={queryType} value="ocr" />
|
||||
{/if}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend class="immich-form-label">{$t('search_type')}</legend>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1 mb-2">
|
||||
{#if featureFlagsManager.value.smartSearch}
|
||||
<RadioButton name="query-type" id="context-radio" label={$t('context')} bind:group={queryType} value="smart" />
|
||||
{/if}
|
||||
<RadioButton
|
||||
name="query-type"
|
||||
id="file-name-radio"
|
||||
label={$t('file_name_or_extension')}
|
||||
bind:group={queryType}
|
||||
value="metadata"
|
||||
/>
|
||||
<RadioButton
|
||||
name="query-type"
|
||||
id="description-radio"
|
||||
label={$t('description')}
|
||||
bind:group={queryType}
|
||||
value="description"
|
||||
/>
|
||||
{#if featureFlagsManager.value.ocr}
|
||||
<RadioButton name="query-type" id="ocr-radio" label={$t('ocr')} bind:group={queryType} value="ocr" />
|
||||
{/if}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if queryType === 'smart'}
|
||||
<Field label={$t('search_by_context')}>
|
||||
<Input type="text" placeholder={$t('sunrise_on_the_beach')} bind:value={query} />
|
||||
</Field>
|
||||
{:else if queryType === 'metadata'}
|
||||
<Field label={$t('search_by_filename')}>
|
||||
<Input type="text" placeholder={$t('search_by_filename_example')} bind:value={query} />
|
||||
</Field>
|
||||
{:else if queryType === 'description'}
|
||||
<Field label={$t('search_by_description')}>
|
||||
<Input type="text" placeholder={$t('search_by_description_example')} bind:value={query} />
|
||||
</Field>
|
||||
{:else if queryType === 'ocr'}
|
||||
<Field label={$t('search_by_ocr')}>
|
||||
<Input type="text" placeholder={$t('search_by_ocr_example')} bind:value={query} />
|
||||
</Field>
|
||||
{/if}
|
||||
</section>
|
||||
{#if queryType === 'smart'}
|
||||
<label for="context-input" class="immich-form-label">{$t('search_by_context')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="context-input"
|
||||
name="context"
|
||||
placeholder={$t('sunrise_on_the_beach')}
|
||||
bind:value={query}
|
||||
/>
|
||||
{:else if queryType === 'metadata'}
|
||||
<label for="file-name-input" class="immich-form-label">{$t('search_by_filename')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="file-name-input"
|
||||
name="file-name"
|
||||
placeholder={$t('search_by_filename_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="file-name-label"
|
||||
/>
|
||||
{:else if queryType === 'description'}
|
||||
<label for="description-input" class="immich-form-label">{$t('search_by_description')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="description-input"
|
||||
name="description"
|
||||
placeholder={$t('search_by_description_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="description-label"
|
||||
/>
|
||||
{:else if queryType === 'ocr'}
|
||||
<label for="ocr-input" class="immich-form-label">{$t('search_by_ocr')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="ocr-input"
|
||||
name="ocr"
|
||||
placeholder={$t('search_by_ocr_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="ocr-label"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class="flex place-items-center gap-1">
|
||||
<label class="font-medium text-primary text-sm min-h-6" for={label}>{label}</label>
|
||||
<label class="font-medium text-primary text-sm min-h-6 uppercase" for={label}>{label}</label>
|
||||
{#if required}
|
||||
<div class="text-red-400">*</div>
|
||||
{/if}
|
||||
|
||||
@@ -11,12 +11,10 @@
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||
import { handleErrorAsync } from '$lib/utils/handle-error';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||
|
||||
@@ -40,27 +38,28 @@
|
||||
person,
|
||||
}: Props = $props();
|
||||
|
||||
const getAsset = (id: string) => {
|
||||
return handleErrorAsync(
|
||||
() => assetCacheManager.getAsset({ ...authManager.params, id }),
|
||||
$t('error_retrieving_asset_information'),
|
||||
);
|
||||
};
|
||||
|
||||
const getNextAsset = async (currentAsset: AssetResponseDto) => {
|
||||
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
|
||||
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
|
||||
if (!earlierTimelineAsset) {
|
||||
return;
|
||||
if (earlierTimelineAsset) {
|
||||
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id });
|
||||
if (preload) {
|
||||
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
|
||||
void getNextAsset(asset, false);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
return getAsset(earlierTimelineAsset.id);
|
||||
};
|
||||
|
||||
const getPreviousAsset = async (currentAsset: AssetResponseDto) => {
|
||||
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
|
||||
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
|
||||
if (!laterTimelineAsset) {
|
||||
return;
|
||||
if (laterTimelineAsset) {
|
||||
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id });
|
||||
if (preload) {
|
||||
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
|
||||
void getPreviousAsset(asset, false);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
return getAsset(laterTimelineAsset.id);
|
||||
};
|
||||
|
||||
let assetCursor = $state<AssetCursor>({
|
||||
@@ -88,12 +87,10 @@
|
||||
|
||||
const handleRandom = async () => {
|
||||
const randomAsset = await timelineManager.getRandomAsset();
|
||||
if (!randomAsset) {
|
||||
return;
|
||||
if (randomAsset) {
|
||||
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
||||
return { id: randomAsset.id };
|
||||
}
|
||||
|
||||
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
||||
return { id: randomAsset.id };
|
||||
};
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
@@ -183,14 +180,12 @@
|
||||
};
|
||||
const handleUndoDelete = async (assets: TimelineAsset[]) => {
|
||||
timelineManager.upsertAssets(assets);
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
if (assets.length > 0) {
|
||||
const restoredAsset = assets[0];
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
|
||||
assetViewingStore.setAsset(asset);
|
||||
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
|
||||
}
|
||||
|
||||
const restoredAsset = assets[0];
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
|
||||
assetViewingStore.setAsset(asset);
|
||||
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
|
||||
};
|
||||
|
||||
const handleUpdateOrUpload = (asset: AssetResponseDto) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { getAssetJobIcon, getAssetJobName } from '$lib/utils';
|
||||
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetJobName, runAssetJobs } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
@@ -22,7 +22,7 @@
|
||||
try {
|
||||
const ids = [...getOwnedAssets()].map(({ id }) => id);
|
||||
await runAssetJobs({ assetJobsDto: { assetIds: ids, name } });
|
||||
toastManager.success(getAssetJobName($t, name));
|
||||
toastManager.success($getAssetJobMessage(name));
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_submit_job'));
|
||||
@@ -32,6 +32,6 @@
|
||||
|
||||
{#each jobs as job (job)}
|
||||
{#if isAllVideos || job !== AssetJobName.TranscodeVideo}
|
||||
<MenuOption text={getAssetJobName($t, job)} icon={getAssetJobIcon(job)} onClick={() => handleRunJob(job)} />
|
||||
<MenuOption text={$getAssetJobName(job)} icon={getAssetJobIcon(job)} onClick={() => handleRunJob(job)} />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
|
||||
import { Button, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { Button, modalManager, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import DeviceCard from './device-card.svelte';
|
||||
|
||||
@@ -52,17 +52,17 @@
|
||||
<section class="my-4">
|
||||
{#if currentSession}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
|
||||
{$t('current_device')}
|
||||
</Text>
|
||||
</h3>
|
||||
<DeviceCard session={currentSession} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if otherSessions.length > 0}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
|
||||
{$t('other_devices')}
|
||||
</Text>
|
||||
</h3>
|
||||
{#each otherSessions as session, index (session.id)}
|
||||
<DeviceCard {session} onDelete={() => handleDelete(session)} />
|
||||
{#if index !== otherSessions.length - 1}
|
||||
@@ -70,11 +70,9 @@
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
|
||||
{$t('log_out_all_devices')}
|
||||
</h3>
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
type PartnerResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Icon, IconButton, modalManager, Text } from '@immich/ui';
|
||||
import { Button, Icon, IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiCheck, mdiClose } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -157,12 +157,10 @@
|
||||
<!-- I am sharing my assets with this user -->
|
||||
{#if partner.sharedByMe}
|
||||
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
||||
<Text class="my-4" size="small" fontWeight="medium">
|
||||
<p class="uppercase text-xs font-medium my-4">
|
||||
{$t('shared_with_partner', { values: { partner: partner.user.name } })}
|
||||
</Text>
|
||||
<Text size="tiny" fontWeight="medium"
|
||||
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
|
||||
>
|
||||
</p>
|
||||
<p class="text-md">{$t('partner_can_access', { values: { partner: partner.user.name } })}</p>
|
||||
<ul class="text-sm">
|
||||
<li class="flex gap-2 place-items-center py-1 mt-2">
|
||||
<Icon icon={mdiCheck} />
|
||||
@@ -178,10 +176,9 @@
|
||||
<!-- this user is sharing assets with me -->
|
||||
{#if partner.sharedWithMe}
|
||||
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
|
||||
<Text class="my-4" size="small" fontWeight="medium">
|
||||
<p class="uppercase text-xs font-medium my-4">
|
||||
{$t('shared_from_partner', { values: { partner: partner.user.name } })}
|
||||
</Text>
|
||||
|
||||
</p>
|
||||
<SettingSwitch
|
||||
title={$t('show_in_timeline')}
|
||||
subtitle={$t('show_in_timeline_setting_description')}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
|
||||
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { Icon, modalManager, Text } from '@immich/ui';
|
||||
import { Icon, modalManager } from '@immich/ui';
|
||||
import {
|
||||
mdiCellphoneArrowDownVariant,
|
||||
mdiContentDuplicate,
|
||||
@@ -22,7 +22,7 @@
|
||||
</script>
|
||||
|
||||
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
|
||||
<Text size="tiny" color="muted" fontWeight="medium" class="p-4">{$t('organize_your_library')}</Text>
|
||||
<p class="uppercase text-xs font-medium p-4">{$t('organize_your_library')}</p>
|
||||
|
||||
{#each links as link (link.href)}
|
||||
<a href={link.href} class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4">
|
||||
@@ -33,8 +33,7 @@
|
||||
</div>
|
||||
<br />
|
||||
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
|
||||
<Text size="tiny" color="muted" fontWeight="medium" class="p-4">{$t('download')}</Text>
|
||||
|
||||
<p class="uppercase text-xs font-medium p-4">{$t('download')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<div class="rounded-lg bg-light-100 border p-3">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
|
||||
<Text size="tiny" fontWeight="semi-bold">{$t('trigger')}</Text>
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('trigger')}</span>
|
||||
</div>
|
||||
<p class="text-sm truncate pl-5">{getTriggerName(trigger.type)}</p>
|
||||
</div>
|
||||
@@ -128,7 +128,7 @@
|
||||
<div class="rounded-lg bg-light-100 border p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Icon icon={mdiFilterOutline} size="18" class="text-warning" />
|
||||
<Text size="tiny" fontWeight="semi-bold">{$t('filters')}</Text>
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span>
|
||||
</div>
|
||||
<div class="space-y-1 pl-5">
|
||||
{#each filters as filter, index (index)}
|
||||
@@ -154,7 +154,7 @@
|
||||
<div class="rounded-lg bg-light-100 border p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
|
||||
<Text size="tiny" fontWeight="semi-bold">{$t('actions')}</Text>
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span>
|
||||
</div>
|
||||
<div class="space-y-1 pl-5">
|
||||
{#each actions as action, index (index)}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="radio" {name} {id} {value} class="focus-visible:ring" bind:group />
|
||||
<label for={id} class="text-sm">{label}</label>
|
||||
<label for={id}>{label}</label>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,6 @@ export type Events = {
|
||||
AlbumUserDelete: [{ albumId: string; userId: string }];
|
||||
|
||||
PersonUpdate: [PersonResponseDto];
|
||||
PersonThumbnailReady: [{ id: string }];
|
||||
|
||||
BackupDeleteStatus: [{ filename: string; isDeleting: boolean }];
|
||||
BackupDeleted: [{ filename: string }];
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiTune } from '@mdi/js';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
@@ -48,8 +47,8 @@
|
||||
|
||||
let { searchQuery, onClose }: Props = $props();
|
||||
|
||||
const parseOptionalDate = (dateString?: DateTime) => (dateString ? parseUtcDate(dateString.toString()) : undefined);
|
||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined;
|
||||
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
|
||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
|
||||
const formId = generateId();
|
||||
|
||||
// combobox and all the search components have terrible support for value | null so we use empty string instead.
|
||||
@@ -188,7 +187,7 @@
|
||||
<Modal icon={mdiTune} size="giant" title={$t('search_options')} {onClose}>
|
||||
<ModalBody>
|
||||
<form id={formId} autocomplete="off" {onsubmit} {onreset}>
|
||||
<div class="flex flex-col gap-5 pb-10" tabindex="-1">
|
||||
<div class="flex flex-col gap-4 pb-10" tabindex="-1">
|
||||
<!-- PEOPLE -->
|
||||
<SearchPeopleSection bind:selectedPeople={filter.personIds} />
|
||||
|
||||
|
||||
@@ -3,36 +3,28 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { user as authUser, preferences } from '$lib/stores/user.store';
|
||||
import { getAssetJobName, getSharedLink, sleep } from '$lib/utils';
|
||||
import { getSharedLink, sleep } from '$lib/utils';
|
||||
import { downloadUrl } from '$lib/utils/asset-utils';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { asQueryString } from '$lib/utils/shared-links';
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
copyAsset,
|
||||
deleteAssets,
|
||||
getAssetInfo,
|
||||
getBaseUrl,
|
||||
runAssetJobs,
|
||||
updateAsset,
|
||||
type AssetJobsDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiAlertOutline,
|
||||
mdiCogRefreshOutline,
|
||||
mdiDatabaseRefreshOutline,
|
||||
mdiDownload,
|
||||
mdiDownloadBox,
|
||||
mdiHeadSyncOutline,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
mdiImageRefreshOutline,
|
||||
mdiInformationOutline,
|
||||
mdiMotionPauseOutline,
|
||||
mdiMotionPlayOutline,
|
||||
@@ -132,31 +124,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
shortcuts: [{ key: 'i' }],
|
||||
};
|
||||
|
||||
const RefreshFacesJob: ActionItem = {
|
||||
title: getAssetJobName($t, AssetJobName.RefreshFaces),
|
||||
icon: mdiHeadSyncOutline,
|
||||
onAction: () => handleRunAssetJob({ name: AssetJobName.RefreshFaces, assetIds: [asset.id] }),
|
||||
};
|
||||
|
||||
const RefreshMetadataJob: ActionItem = {
|
||||
title: getAssetJobName($t, AssetJobName.RefreshMetadata),
|
||||
icon: mdiDatabaseRefreshOutline,
|
||||
onAction: () => handleRunAssetJob({ name: AssetJobName.RefreshMetadata, assetIds: [asset.id] }),
|
||||
};
|
||||
|
||||
const RegenerateThumbnailJob: ActionItem = {
|
||||
title: getAssetJobName($t, AssetJobName.RegenerateThumbnail),
|
||||
icon: mdiImageRefreshOutline,
|
||||
onAction: () => handleRunAssetJob({ name: AssetJobName.RegenerateThumbnail, assetIds: [asset.id] }),
|
||||
};
|
||||
|
||||
const TranscodeVideoJob: ActionItem = {
|
||||
title: getAssetJobName($t, AssetJobName.TranscodeVideo),
|
||||
icon: mdiCogRefreshOutline,
|
||||
onAction: () => handleRunAssetJob({ name: AssetJobName.TranscodeVideo, assetIds: [asset.id] }),
|
||||
$if: () => asset.type === AssetTypeEnum.Video,
|
||||
};
|
||||
|
||||
return {
|
||||
Share,
|
||||
Download,
|
||||
@@ -168,10 +135,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
Unfavorite,
|
||||
PlayMotionPhoto,
|
||||
StopMotionPhoto,
|
||||
RefreshFacesJob,
|
||||
RefreshMetadataJob,
|
||||
RegenerateThumbnailJob,
|
||||
TranscodeVideoJob,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -254,14 +217,3 @@ export const handleReplaceAsset = async (oldAssetId: string) => {
|
||||
|
||||
eventManager.emit('AssetReplace', { oldAssetId, newAssetId });
|
||||
};
|
||||
|
||||
const handleRunAssetJob = async (dto: AssetJobsDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await runAssetJobs({ assetJobsDto: dto });
|
||||
toastManager.success(getAssetJobName($t, dto.name));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_submit_job'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import { modalManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiKeyboard } from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getKeyboardActions = ($t: MessageFormatter) => {
|
||||
const KeyboardShortcuts: ActionItem = {
|
||||
title: $t('show_keyboard_shortcuts'),
|
||||
icon: mdiKeyboard,
|
||||
onAction: () => modalManager.show(ShortcutsModal, {}),
|
||||
};
|
||||
|
||||
return { KeyboardShortcuts };
|
||||
};
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type UpdateLibraryDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiInformationOutline, mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResponseDto[]) => {
|
||||
@@ -45,13 +45,6 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
};
|
||||
|
||||
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
||||
const Detail: ActionItem = {
|
||||
icon: mdiInformationOutline,
|
||||
type: $t('command'),
|
||||
title: $t('details'),
|
||||
onAction: () => goto(Route.viewLibrary(library)),
|
||||
};
|
||||
|
||||
const Edit: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
@@ -91,7 +84,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
};
|
||||
|
||||
return { Detail, Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
};
|
||||
|
||||
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import { getAssetOcr } from '@immich/sdk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -31,7 +30,6 @@ describe('OcrManager', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the singleton state before each test
|
||||
ocrManager.clear();
|
||||
assetCacheManager.clearOcrCache();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { getAssetOcr } from '@immich/sdk';
|
||||
|
||||
export type OcrBoundingBox = {
|
||||
id: string;
|
||||
@@ -38,7 +38,7 @@ class OcrManager {
|
||||
this.#cleared = false;
|
||||
}
|
||||
await this.#ocrLoader.execute(async () => {
|
||||
this.#data = await assetCacheManager.getAssetOcr(id);
|
||||
this.#data = await getAssetOcr({ id });
|
||||
}, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,6 @@ websocket
|
||||
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
|
||||
.on('on_session_delete', () => authManager.logout())
|
||||
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
|
||||
.on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id }))
|
||||
.on('on_notification', () => notificationManager.refresh())
|
||||
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from '@immich/sdk';
|
||||
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
|
||||
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, mdiImageRefreshOutline } from '@mdi/js';
|
||||
import { init, register, t, type MessageFormatter } from 'svelte-i18n';
|
||||
import { init, register, t } from 'svelte-i18n';
|
||||
import { derived, get } from 'svelte/store';
|
||||
|
||||
interface DownloadRequestOptions<T = unknown> {
|
||||
@@ -259,16 +259,31 @@ export const getProfileImageUrl = (user: UserResponseDto) =>
|
||||
export const getPeopleThumbnailUrl = (person: PersonResponseDto, updatedAt?: string) =>
|
||||
createUrl(getPeopleThumbnailPath(person.id), { updatedAt: updatedAt ?? person.updatedAt });
|
||||
|
||||
export const getAssetJobName = ($t: MessageFormatter, job: AssetJobName) => {
|
||||
const messages: Record<AssetJobName, string> = {
|
||||
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
|
||||
[AssetJobName.RefreshMetadata]: $t('refreshing_metadata'),
|
||||
[AssetJobName.RegenerateThumbnail]: $t('regenerating_thumbnails'),
|
||||
[AssetJobName.TranscodeVideo]: $t('refreshing_encoded_video'),
|
||||
};
|
||||
export const getAssetJobName = derived(t, ($t) => {
|
||||
return (job: AssetJobName) => {
|
||||
const names: Record<AssetJobName, string> = {
|
||||
[AssetJobName.RefreshFaces]: $t('refresh_faces'),
|
||||
[AssetJobName.RefreshMetadata]: $t('refresh_metadata'),
|
||||
[AssetJobName.RegenerateThumbnail]: $t('refresh_thumbnails'),
|
||||
[AssetJobName.TranscodeVideo]: $t('refresh_encoded_videos'),
|
||||
};
|
||||
|
||||
return messages[job];
|
||||
};
|
||||
return names[job];
|
||||
};
|
||||
});
|
||||
|
||||
export const getAssetJobMessage = derived(t, ($t) => {
|
||||
return (job: AssetJobName) => {
|
||||
const messages: Record<AssetJobName, string> = {
|
||||
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
|
||||
[AssetJobName.RefreshMetadata]: $t('refreshing_metadata'),
|
||||
[AssetJobName.RegenerateThumbnail]: $t('regenerating_thumbnails'),
|
||||
[AssetJobName.TranscodeVideo]: $t('refreshing_encoded_video'),
|
||||
};
|
||||
|
||||
return messages[job];
|
||||
};
|
||||
});
|
||||
|
||||
export const getAssetJobIcon = (job: AssetJobName) => {
|
||||
const names: Record<AssetJobName, string> = {
|
||||
@@ -431,17 +446,3 @@ export const withoutIcons = (actions: ActionItem[]): ActionItem[] =>
|
||||
actions.map((action) => ({ ...action, icon: undefined }));
|
||||
|
||||
export const isEnabled = ({ $if }: IfLike) => $if?.() ?? true;
|
||||
|
||||
export const transformToTitleCase = (text: string) => {
|
||||
if (text.length === 0) {
|
||||
return text;
|
||||
} else if (text.length === 1) {
|
||||
return text.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
let result = '';
|
||||
for (const word of text.toLowerCase().split(' ')) {
|
||||
result += word.charAt(0).toUpperCase() + word.slice(1) + ' ';
|
||||
}
|
||||
return result.trim();
|
||||
};
|
||||
|
||||
@@ -19,17 +19,12 @@ export function getServerErrorMessage(error: unknown) {
|
||||
return data?.message || error.message;
|
||||
}
|
||||
|
||||
export function standardizeError(error: unknown) {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
export function handleError(error: unknown, localizedMessage: string) {
|
||||
const standardizedError = standardizeError(error);
|
||||
if (standardizedError.name === 'AbortError') {
|
||||
export function handleError(error: unknown, message: string) {
|
||||
if ((error as Error)?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[handleError]: ${standardizedError}`, error, standardizedError.stack);
|
||||
console.error(`[handleError]: ${message}`, error, (error as Error)?.stack);
|
||||
|
||||
try {
|
||||
let serverMessage = getServerErrorMessage(error);
|
||||
@@ -37,22 +32,13 @@ export function handleError(error: unknown, localizedMessage: string) {
|
||||
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
|
||||
}
|
||||
|
||||
const errorMessage = serverMessage || localizedMessage;
|
||||
const errorMessage = serverMessage || message;
|
||||
|
||||
toastManager.danger(errorMessage);
|
||||
|
||||
return errorMessage;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return localizedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleErrorAsync<T>(fn: () => Promise<T>, localizedMessage: string): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, localizedMessage);
|
||||
return;
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
/**
|
||||
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
|
||||
* This class helps manage concurrent operations by tracking which invocations are active
|
||||
@@ -53,12 +51,10 @@ export class InvocationTracker {
|
||||
return this.invocationsStarted !== this.invocationsEnded;
|
||||
}
|
||||
|
||||
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
|
||||
async invoke<T>(invocable: () => Promise<T>) {
|
||||
const invocation = this.startInvocation();
|
||||
try {
|
||||
return await invocable();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, localizedMessage);
|
||||
} finally {
|
||||
invocation.endInvocation();
|
||||
}
|
||||
|
||||
@@ -24,11 +24,11 @@ export interface boundingBox {
|
||||
export const getBoundingBox = (
|
||||
faces: Faces[],
|
||||
zoom: ZoomImageWheelState,
|
||||
photoViewer: HTMLImageElement | undefined,
|
||||
photoViewer: HTMLImageElement | null,
|
||||
): boundingBox[] => {
|
||||
const boxes: boundingBox[] = [];
|
||||
|
||||
if (!photoViewer) {
|
||||
if (photoViewer === null) {
|
||||
return boxes;
|
||||
}
|
||||
const clientHeight = photoViewer.clientHeight;
|
||||
@@ -93,7 +93,7 @@ export const zoomImageToBase64 = async (
|
||||
|
||||
image = img;
|
||||
}
|
||||
if (!image) {
|
||||
if (image === null) {
|
||||
return null;
|
||||
}
|
||||
const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face;
|
||||
@@ -121,9 +121,11 @@ export const zoomImageToBase64 = async (
|
||||
canvas.height = faceHeight;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
if (context) {
|
||||
context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
|
||||
|
||||
return canvas.toDataURL();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
|
||||
return canvas.toDataURL();
|
||||
};
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
@@ -28,17 +29,17 @@
|
||||
|
||||
let hasPeople = $derived(data.response.total > 0);
|
||||
|
||||
const onPersonThumbnailReady = ({ id }: { id: string }) => {
|
||||
for (const person of people) {
|
||||
if (person.id === id) {
|
||||
person.updatedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
};
|
||||
onMount(() => {
|
||||
return websocketEvents.on('on_person_thumbnail', (personId: string) => {
|
||||
people.map((person) => {
|
||||
if (person.id === personId) {
|
||||
person.updatedAt = Date.now().toString();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<OnEvents {onPersonThumbnailReady} />
|
||||
|
||||
<UserPageLayout title={data.meta.title}>
|
||||
{#if hasPeople}
|
||||
<div class="mb-6 mt-2">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { joinPaths } from '$lib/utils/tree-utils';
|
||||
import { IconButton, Text } from '@immich/ui';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -77,7 +77,7 @@
|
||||
<Sidebar>
|
||||
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} breakpoint="md" />
|
||||
<section>
|
||||
<Text class="ps-4 mb-4" size="small">{$t('explorer')}</Text>
|
||||
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div>
|
||||
<div class="h-full">
|
||||
<TreeItems
|
||||
icons={{ default: mdiFolderOutline, active: mdiFolder }}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||
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';
|
||||
@@ -307,6 +308,16 @@
|
||||
bind:clientWidth={viewport.width}
|
||||
bind:this={searchResultsElement}
|
||||
>
|
||||
{#if searchResultAlbums.length > 0}
|
||||
<section>
|
||||
<div class="uppercase ms-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums')}</div>
|
||||
<AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
|
||||
|
||||
<div class="uppercase m-6 text-4xl font-medium text-black/70 dark:text-white/80">
|
||||
{$t('photos_and_videos')}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<section id="search-content">
|
||||
{#if searchResultAssets.length > 0}
|
||||
<GalleryViewer
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
|
||||
import { getAllTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { mdiDotsVertical, mdiPlus, mdiTag, mdiTagMultiple } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -83,7 +82,7 @@
|
||||
<Sidebar>
|
||||
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" />
|
||||
<section>
|
||||
<Text class="ps-4 mb-4" size="small">{$t('explorer')}</Text>
|
||||
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div>
|
||||
<div class="h-full">
|
||||
<TreeItems icons={{ default: mdiTag, active: mdiTag }} {tree} active={tag.path} {getLink} />
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
<script lang="ts">
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
|
||||
import { getKeyboardActions } from '$lib/services/keyboard.service';
|
||||
import { Container } from '@immich/ui';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import { Container, IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiKeyboard } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
data: PageData;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const { KeyboardShortcuts } = $derived(getKeyboardActions($t));
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title} actions={[KeyboardShortcuts]}>
|
||||
<UserPageLayout title={data.meta.title}>
|
||||
{#snippet buttons()}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon={mdiKeyboard}
|
||||
aria-label={$t('show_keyboard_shortcuts')}
|
||||
onclick={() => modalManager.show(ShortcutsModal, {})}
|
||||
/>
|
||||
{/snippet}
|
||||
<Container size="medium" center>
|
||||
<UserSettingsList keys={data.keys} sessions={data.sessions} />
|
||||
</Container>
|
||||
|
||||
@@ -139,9 +139,9 @@
|
||||
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
||||
{#snippet buttons()}
|
||||
<div class="flex gap-2 justify-end place-items-center">
|
||||
<Text class="hidden md:block mr-4" size="tiny" color="muted">{$t('geolocation_instruction_location')}</Text>
|
||||
<Text class="hidden md:block text-xs mr-4 text-dark/50">{$t('geolocation_instruction_location')}</Text>
|
||||
<div class="border flex place-items-center place-content-center px-2 py-1 bg-primary/10 rounded-2xl">
|
||||
<Text class="hidden md:inline-block font-mono mr-5 ml-2" color="muted" size="tiny">
|
||||
<Text class="hidden md:inline-block text-xs text-gray-500 font-mono mr-5 ml-2 uppercase">
|
||||
{$t('selected_gps_coordinates')}
|
||||
</Text>
|
||||
<Text
|
||||
|
||||
@@ -208,7 +208,9 @@
|
||||
<!-- Trigger Section -->
|
||||
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
|
||||
<div class="mb-3">
|
||||
<Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
|
||||
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
|
||||
>{$t('trigger')}</Text
|
||||
>
|
||||
</div>
|
||||
{@render chipItem(getTriggerLabel(workflow.triggerType))}
|
||||
</div>
|
||||
@@ -216,7 +218,9 @@
|
||||
<!-- Filters Section -->
|
||||
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
|
||||
<div class="mb-3">
|
||||
<Text size="tiny" color="muted" fontWeight="medium">{$t('filters')}</Text>
|
||||
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
|
||||
>{$t('filters')}</Text
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if workflow.filters.length === 0}
|
||||
@@ -234,7 +238,9 @@
|
||||
<!-- Actions Section -->
|
||||
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
|
||||
<div class="mb-3">
|
||||
<Text size="tiny" color="muted" fontWeight="medium">{$t('actions')}</Text>
|
||||
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
|
||||
>{$t('actions')}</Text
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -338,7 +338,7 @@
|
||||
<div class="w-full border-t-2 border-dashed border-light-200"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<Text class="bg-white dark:bg-black px-2" fontWeight="semi-bold" size="tiny" color="muted">{$t('then')}</Text>
|
||||
<span class="bg-white dark:bg-black px-2 font-semibold text-light-500">THEN</span>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -4,16 +4,14 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getLibrariesActions, getLibraryActions } from '$lib/services/library.service';
|
||||
import { getLibrariesActions } from '$lib/services/library.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
CommandPaletteDefaultProvider,
|
||||
Container,
|
||||
ContextMenuButton,
|
||||
Link,
|
||||
MenuItemType,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -60,18 +58,13 @@
|
||||
|
||||
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
||||
|
||||
const getActionsForLibrary = (library: LibraryResponseDto) => {
|
||||
const { Detail, Scan, Edit, Delete } = getLibraryActions($t, library);
|
||||
return [Detail, Scan, Edit, MenuItemType.Divider, Delete];
|
||||
};
|
||||
|
||||
const classes = {
|
||||
column1: 'w-4/12',
|
||||
column2: 'w-4/12',
|
||||
column3: 'w-1/12',
|
||||
column4: 'w-1/12',
|
||||
column5: 'w-1/12',
|
||||
column6: 'w-1/12 flex justify-end',
|
||||
column3: 'w-2/12',
|
||||
column4: 'w-2/12',
|
||||
column5: 'w-2/12',
|
||||
column6: 'w-2/12',
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -96,19 +89,14 @@
|
||||
{#each libraries as library (library.id + library.name)}
|
||||
{@const { photos, usage, videos } = statistics[library.id]}
|
||||
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
|
||||
{@const owner = owners[library.id]}
|
||||
<TableRow>
|
||||
<TableCell class={classes.column1}>
|
||||
<Link href={Route.viewLibrary(library)}>{library.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column2}>
|
||||
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column1}>{library.name}</TableCell>
|
||||
<TableCell class={classes.column2}>{owners[library.id].name}</TableCell>
|
||||
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</TableCell>
|
||||
<TableCell class={classes.column6}>
|
||||
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
|
||||
<Button size="small" href={Route.viewLibrary(library)}>{$t('view')}</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user