Compare commits

..

4 Commits

Author SHA1 Message Date
shenlong-tanwen
dfd71f6379 auto layout on smaller tiles 2026-01-21 21:43:36 +05:30
shenlong-tanwen
ed9b4e795a auto dynamic mode on smaller column count 2026-01-21 21:39:48 +05:30
shenlong-tanwen
93c19d1b2e simplify _buildAssetRow 2026-01-21 21:39:48 +05:30
shenlong-tanwen
074cc1db73 feat(mobile): dynamic layout in new timeline 2026-01-21 21:39:48 +05:30
62 changed files with 626 additions and 669 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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;
}
}
}
}

View File

@@ -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,
),
);
}
}

View File

@@ -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),

View File

@@ -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(

View File

@@ -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);

View File

@@ -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}"}),

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -16,7 +16,7 @@ describe('AssetViewerNavBar component', () => {
preAction: () => {},
onZoomImage: () => {},
onAction: () => {},
onEdit: () => {},
onRunJob: () => {},
onPlaySlideshow: () => {},
onClose: () => {},
playOriginalVideo: false,

View File

@@ -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}

View File

@@ -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}

View File

@@ -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)}

View File

@@ -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">

View File

@@ -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 />

View File

@@ -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"

View File

@@ -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}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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">

View File

@@ -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

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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) => {

View File

@@ -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}

View File

@@ -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>

View File

@@ -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')}

View File

@@ -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, {})}

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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 }];

View File

@@ -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} />

View File

@@ -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'));
}
};

View File

@@ -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 };
};

View File

@@ -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) => {

View File

@@ -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();
});

View File

@@ -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);
}

View File

@@ -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));

View File

@@ -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();
};

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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();
};

View File

@@ -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">

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}