Merge branch 'main' into feat/integrity-checks-izzy

This commit is contained in:
Paul Makles
2026-01-21 17:03:08 +00:00
committed by GitHub
44 changed files with 453 additions and 369 deletions
+50
View File
@@ -68,6 +68,56 @@ Now make sure that the local album is selected in the backup screen (steps 1-2 a
title="Upload button after photos selection" 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 ## 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. 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.
+4 -1
View File
@@ -1021,9 +1021,11 @@
"error_getting_places": "Error getting places", "error_getting_places": "Error getting places",
"error_loading_image": "Error loading image", "error_loading_image": "Error loading image",
"error_loading_partners": "Error loading partners: {error}", "error_loading_partners": "Error loading partners: {error}",
"error_retrieving_asset_information": "Error retrieving asset information",
"error_saving_image": "Error: {error}", "error_saving_image": "Error: {error}",
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates", "error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
"error_title": "Error - Something went wrong", "error_title": "Error - Something went wrong",
"error_while_navigating": "Error while navigating to asset",
"errors": { "errors": {
"cannot_navigate_next_asset": "Cannot navigate to the next asset", "cannot_navigate_next_asset": "Cannot navigate to the next asset",
"cannot_navigate_previous_asset": "Cannot navigate to previous asset", "cannot_navigate_previous_asset": "Cannot navigate to previous asset",
@@ -1570,7 +1572,7 @@
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.", "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_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_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_assets_to_show": "No assets to show",
"no_cast_devices_found": "No cast devices found", "no_cast_devices_found": "No cast devices found",
"no_checksum_local": "No checksum available - cannot fetch local assets", "no_checksum_local": "No checksum available - cannot fetch local assets",
@@ -2204,6 +2206,7 @@
"theme_setting_theme_subtitle": "Choose the app's theme setting", "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_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", "theme_setting_three_stage_loading_title": "Enable three-stage loading",
"then": "Then",
"they_will_be_merged_together": "They will be merged together", "they_will_be_merged_together": "They will be merged together",
"third_party_resources": "Third-Party Resources", "third_party_resources": "Third-Party Resources",
"time": "Time", "time": "Time",
@@ -285,7 +285,12 @@ class BackgroundUploadService {
return null; return null;
} }
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; 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 originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
String metadata = UploadTaskMetadata( String metadata = UploadTaskMetadata(
@@ -315,7 +315,16 @@ class ForegroundUploadService {
return; return;
} }
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; 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 originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId); final deviceId = Store.get(StoreKey.deviceId);
+1 -1
View File
@@ -4,7 +4,7 @@
/* @import '/usr/ui/dist/theme/default.css'; */ /* @import '/usr/ui/dist/theme/default.css'; */
@utility immich-form-input { @utility immich-form-input {
@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; @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;
} }
@utility immich-form-label { @utility immich-form-label {
+10 -9
View File
@@ -5,6 +5,7 @@
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { asQueueItem } from '$lib/services/queue.service'; import { asQueueItem } from '$lib/services/queue.service';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { transformToTitleCase } from '$lib/utils';
import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk'; import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk';
import { Icon, IconButton, Link } from '@immich/ui'; import { Icon, IconButton, Link } from '@immich/ui';
import { import {
@@ -53,7 +54,7 @@
<div class="flex items-center gap-2 text-xl font-semibold text-primary"> <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}> <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" /> <Icon {icon} size="1.25em" class="hidden shrink-0 sm:block" />
<span class="uppercase">{title}</span> <span>{transformToTitleCase(title)}</span>
</Link> </Link>
<IconButton <IconButton
color="primary" color="primary"
@@ -131,7 +132,7 @@
onClick={() => onCommand({ command: QueueCommand.Start, force: false })} onClick={() => onCommand({ command: QueueCommand.Start, force: false })}
> >
<Icon icon={mdiAlertCircle} size="36" /> <Icon icon={mdiAlertCircle} size="36" />
<span class="uppercase">{$t('disabled')}</span> <span>{$t('disabled')}</span>
</QueueCardButton> </QueueCardButton>
{/if} {/if}
@@ -139,7 +140,7 @@
{#if waitingCount > 0} {#if waitingCount > 0}
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}> <QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
<Icon icon={mdiClose} size="24" /> <Icon icon={mdiClose} size="24" />
<span class="uppercase">{$t('clear')}</span> <span>{$t('clear')}</span>
</QueueCardButton> </QueueCardButton>
{/if} {/if}
{#if queue.isPaused} {#if queue.isPaused}
@@ -147,12 +148,12 @@
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Resume, force: false })}> <QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Resume, force: false })}>
<!-- size property is not reactive, so have to use width and height --> <!-- size property is not reactive, so have to use width and height -->
<Icon icon={mdiFastForward} {size} /> <Icon icon={mdiFastForward} {size} />
<span class="uppercase">{$t('resume')}</span> <span>{$t('resume')}</span>
</QueueCardButton> </QueueCardButton>
{:else} {:else}
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}> <QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
<Icon icon={mdiPause} size="24" /> <Icon icon={mdiPause} size="24" />
<span class="uppercase">{$t('pause')}</span> <span>{$t('pause')}</span>
</QueueCardButton> </QueueCardButton>
{/if} {/if}
{/if} {/if}
@@ -161,25 +162,25 @@
{#if allText} {#if allText}
<QueueCardButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}> <QueueCardButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
<Icon icon={mdiAllInclusive} size="24" /> <Icon icon={mdiAllInclusive} size="24" />
<span class="uppercase">{allText}</span> <span>{allText}</span>
</QueueCardButton> </QueueCardButton>
{/if} {/if}
{#if refreshText} {#if refreshText}
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}> <QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
<Icon icon={mdiImageRefreshOutline} size="24" /> <Icon icon={mdiImageRefreshOutline} size="24" />
<span class="uppercase">{refreshText}</span> <span>{refreshText}</span>
</QueueCardButton> </QueueCardButton>
{/if} {/if}
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}> <QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
<Icon icon={mdiSelectionSearch} size="24" /> <Icon icon={mdiSelectionSearch} size="24" />
<span class="uppercase">{missingText}</span> <span>{missingText}</span>
</QueueCardButton> </QueueCardButton>
{/if} {/if}
{#if !disabled && !multipleButtons && isIdle} {#if !disabled && !multipleButtons && isIdle}
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}> <QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
<Icon icon={mdiPlay} size="48" /> <Icon icon={mdiPlay} size="48" />
<span class="uppercase">{missingText}</span> <span>{missingText}</span>
</QueueCardButton> </QueueCardButton>
{/if} {/if}
</div> </div>
@@ -12,7 +12,7 @@
import { handleSystemConfigSave } from '$lib/services/system-config.service'; import { handleSystemConfigSave } from '$lib/services/system-config.service';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui'; import { Heading, LoadingSpinner, Text } from '@immich/ui';
import handlebar from 'handlebars'; import handlebar from 'handlebars';
import * as luxon from 'luxon'; import * as luxon from 'luxon';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
@@ -158,7 +158,9 @@
{#if configToEdit.storageTemplate.enabled} {#if configToEdit.storageTemplate.enabled}
<hr /> <hr />
<h3 class="text-base font-medium text-primary">{$t('variables')}</h3> <Heading size="tiny" color="primary">
{$t('variables')}
</Heading>
<section class="support-date"> <section class="support-date">
{#await getSupportDateTimeFormat()} {#await getSupportDateTimeFormat()}
@@ -174,11 +176,14 @@
<SupportedVariablesPanel /> <SupportedVariablesPanel />
</section> </section>
<div class="flex flex-col mt-4"> <div class="flex flex-col mt-2">
<h3 class="text-base font-medium text-primary">{$t('template')}</h3> <!-- <h3 class="text-base font-medium text-primary">{$t('template')}</h3> -->
<Heading size="tiny" color="primary">
{$t('template')}
</Heading>
<div class="my-2 text-sm"> <div class="my-2">
<h4 class="uppercase">{$t('preview')}</h4> <Text size="small">{$t('preview')}</Text>
</div> </div>
<p class="text-sm"> <p class="text-sm">
@@ -249,7 +254,9 @@
{#if !minified} {#if !minified}
<div id="migration-info" class="mt-2 text-sm"> <div id="migration-info" class="mt-2 text-sm">
<h3 class="text-base font-medium text-primary">{$t('notes')}</h3> <Heading size="tiny" color="primary">
{$t('notes')}
</Heading>
<section class="flex flex-col gap-2"> <section class="flex flex-col gap-2">
<p> <p>
<FormatMessage <FormatMessage
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
import { Card, CardBody, CardHeader, Text } from '@immich/ui';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -15,78 +16,80 @@
}; };
</script> </script>
<div class="mt-2 text-sm"> <Text size="small">{$t('date_and_time')}</Text>
<h4 class="uppercase">{$t('date_and_time')}</h4>
</div>
<!-- eslint-disable svelte/no-useless-mustaches --> <!-- eslint-disable svelte/no-useless-mustaches -->
<div class="mt-2 rounded-lg bg-gray-200 p-4 text-xs dark:bg-gray-700 dark:text-immich-dark-fg"> <Card class="mt-2 text-sm bg-light-50 shadow-none">
<div class="mb-2 text-gray-600 dark:text-immich-dark-fg"> <CardHeader>
<p>{$t('admin.storage_template_date_time_description')}</p> <Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
<p>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}</p> <Text color="primary"
</div> >{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}</Text
<div class="flex gap-10"> >
<div> </CardHeader>
<p class="uppercase font-medium text-primary">{$t('year')}</p> <CardBody>
<ul> <div class="grid grid-cols-1 gap-6 sm:grid-cols-3 md:grid-cols-4">
{#each options.yearOptions as yearFormat, index (index)} <div>
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li> <Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('year')}</Text>
{/each} <ul>
</ul> {#each options.yearOptions as yearFormat, index (index)}
</div> <li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
{/each}
</ul>
</div>
<div> <div>
<p class="uppercase font-medium text-primary">{$t('month')}</p> <Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('month')}</Text>
<ul> <ul>
{#each options.monthOptions as monthFormat, index (index)} {#each options.monthOptions as monthFormat, index (index)}
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li> <li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
{/each} {/each}
</ul> </ul>
</div> </div>
<div> <div>
<p class="uppercase font-medium text-primary">{$t('week')}</p> <Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('week')}</Text>
<ul> <ul>
{#each options.weekOptions as weekFormat, index (index)} {#each options.weekOptions as weekFormat, index (index)}
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li> <li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
{/each} {/each}
</ul> </ul>
</div> </div>
<div> <div>
<p class="uppercase font-medium text-primary">{$t('day')}</p> <Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('day')}</Text>
<ul> <ul>
{#each options.dayOptions as dayFormat, index (index)} {#each options.dayOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li> <li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each} {/each}
</ul> </ul>
</div> </div>
<div> <div>
<p class="uppercase font-medium text-primary">{$t('hour')}</p> <Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('hour')}</Text>
<ul> <ul>
{#each options.hourOptions as dayFormat, index (index)} {#each options.hourOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li> <li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each} {/each}
</ul> </ul>
</div> </div>
<div> <div>
<p class="uppercase font-medium text-primary">{$t('minute')}</p> <Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('minute')}</Text>
<ul> <ul>
{#each options.minuteOptions as dayFormat, index (index)} {#each options.minuteOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li> <li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each} {/each}
</ul> </ul>
</div> </div>
<div> <div>
<p class="uppercase font-medium text-primary">{$t('second')}</p> <Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('second')}</Text>
<ul> <ul>
{#each options.secondOptions as dayFormat, index (index)} {#each options.secondOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li> <li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each} {/each}
</ul> </ul>
</div>
</div> </div>
</div> </CardBody>
</div> </Card>
@@ -1,56 +1,59 @@
<script lang="ts"> <script lang="ts">
import { Card, CardBody, Text } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
</script> </script>
<div class="mt-4 text-sm"> <Text size="small">{$t('other_variables')}</Text>
<h4 class="uppercase">{$t('other_variables')}</h4>
</div>
<div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg"> <Card class="mt-2 text-sm bg-light-50 shadow-none">
<div class="flex gap-12"> <CardBody>
<div> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<p class="uppercase font-medium text-primary">{$t('filename')}</p> <div>
<ul> <Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('filename')}</Text>
<li>{`{{filename}}`} - IMG_123</li> <ul>
<li>{`{{ext}}`} - jpg</li> <li>{`{{filename}}`} - IMG_123</li>
</ul> <li>{`{{ext}}`} - jpg</li>
</div> </ul>
</div>
<div> <div>
<p class="uppercase font-medium text-primary">{$t('filetype')}</p> <Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('filetype')}</Text>
<ul> <ul>
<li>{`{{filetype}}`} - VID or IMG</li> <li>{`{{filetype}}`} - VID or IMG</li>
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li> <li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
</ul> </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> </div>
<div> </CardBody>
<p class="uppercase font-medium text-primary">{$t('album')}</p> </Card>
<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>
@@ -252,7 +252,7 @@
await handleStopSlideshow(); await handleStopSlideshow();
} }
} }
}); }, $t('error_while_navigating'));
}; };
const showEditor = () => { const showEditor = () => {
@@ -5,7 +5,7 @@
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { removeTag } from '$lib/utils/asset-utils'; import { removeTag } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Icon, modalManager } from '@immich/ui'; import { Icon, modalManager, Text } from '@immich/ui';
import { mdiClose, mdiPlus } from '@mdi/js'; import { mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -38,7 +38,7 @@
{#if isOwner && !authManager.isSharedLink} {#if isOwner && !authManager.isSharedLink}
<section class="px-4 mt-4"> <section class="px-4 mt-4">
<div class="flex h-10 w-full items-center justify-between text-sm"> <div class="flex h-10 w-full items-center justify-between text-sm">
<h2 class="uppercase">{$t('tags')}</h2> <Text color="muted">{$t('tags')}</Text>
</div> </div>
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags"> <section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)} {#each tags as tag (tag.id)}
@@ -20,7 +20,7 @@
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils'; import { getParentPath } from '$lib/utils/tree-utils';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui'; import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
import { import {
mdiCalendar, mdiCalendar,
mdiCamera, mdiCamera,
@@ -160,7 +160,7 @@
{#if !authManager.isSharedLink && isOwner} {#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm"> <section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between"> <div class="flex h-10 w-full items-center justify-between">
<h2 class="uppercase">{$t('people')}</h2> <Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)} {#if people.some((person) => person.isHidden)}
<IconButton <IconButton
@@ -259,10 +259,10 @@
<div class="px-4 py-4"> <div class="px-4 py-4">
{#if asset.exifInfo} {#if asset.exifInfo}
<div class="flex h-10 w-full items-center justify-between text-sm"> <div class="flex h-10 w-full items-center justify-between text-sm">
<h2 class="uppercase">{$t('details')}</h2> <Text size="small" color="muted">{$t('details')}</Text>
</div> </div>
{:else} {:else}
<p class="uppercase text-sm">{$t('no_exif_info_available')}</p> <Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
{/if} {/if}
{#if dateTime} {#if dateTime}
@@ -487,7 +487,7 @@
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner} {#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<section class="px-6 dark:text-immich-dark-fg mt-4"> <section class="px-6 dark:text-immich-dark-fg mt-4">
<p class="uppercase text-sm">{$t('shared_by')}</p> <Text size="small" color="muted">{$t('shared_by')}</Text>
<div class="flex gap-4 pt-4"> <div class="flex gap-4 pt-4">
<div> <div>
<UserAvatar user={asset.owner} size="md" /> <UserAvatar user={asset.owner} size="md" />
@@ -504,7 +504,9 @@
{#if albums.length > 0} {#if albums.length > 0}
<section class="px-6 py-6 dark:text-immich-dark-fg"> <section class="px-6 py-6 dark:text-immich-dark-fg">
<p class="uppercase pb-4 text-sm">{$t('appears_in')}</p> <div class="pb-4">
<Text size="small" color="muted">{$t('appears_in')}</Text>
</div>
{#each albums as album (album.id)} {#each albums as album (album.id)}
<a href={Route.viewAlbum(album)}> <a href={Route.viewAlbum(album)}>
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center"> <div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
@@ -147,7 +147,7 @@
{/if} {/if}
</div> </div>
<div class="px-4 py-4 text-sm"> <div class="px-4 py-4 text-sm">
<h2 class="mb-8 mt-4 uppercase">{$t('all_people')}</h2> <h2 class="mb-8 mt-4">{$t('all_people')}</h2>
{#if isShowLoadingPeople} {#if isShowLoadingPeople}
<div class="flex w-full justify-center"> <div class="flex w-full justify-center">
<LoadingSpinner /> <LoadingSpinner />
@@ -39,7 +39,7 @@
<Icon {icon} size="30" class="text-primary" /> <Icon {icon} size="30" class="text-primary" />
{/if} {/if}
{#if title} {#if title}
<p class="uppercase text-xl text-primary"> <p class="text-xl text-primary font-medium">
{title} {title}
</p> </p>
{/if} {/if}
@@ -19,7 +19,7 @@
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary" 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" /> <Icon icon={sunPath} viewBox={sunViewBox} size="96" />
<p class="uppercase font-semibold text-4xl">{$t('light')}</p> <p class="font-semibold text-4xl">{$t('light')}</p>
</div> </div>
</button> </button>
<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" 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" /> <Icon icon={moonPath} viewBox={moonViewBox} size="96" />
<p class="uppercase font-semibold text-4xl">{$t('dark')}</p> <p class="font-semibold text-4xl">{$t('dark')}</p>
</div> </div>
</button> </button>
</div> </div>
@@ -24,7 +24,7 @@
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import { generateId } from '$lib/utils/generate-id'; import { generateId } from '$lib/utils/generate-id';
import { Icon, IconButton, Label } from '@immich/ui'; import { Icon, IconButton, Label } from '@immich/ui';
import { mdiClose, mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js'; import { mdiChevronDown, mdiClose, mdiMagnify } from '@mdi/js';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { FormEventHandler } from 'svelte/elements'; import type { FormEventHandler } from 'svelte/elements';
@@ -251,7 +251,7 @@
</script> </script>
<svelte:window onresize={onPositionChange} /> <svelte:window onresize={onPositionChange} />
<Label class="block mb-1 {hideLabel ? 'sr-only' : ''}" for={inputId}>{label}</Label> <Label class="block mb-1 {hideLabel ? 'sr-only' : ''} text-xs text-neutral-500 font-light" for={inputId}>{label}</Label>
<div <div
class="relative w-full dark:text-gray-300 text-gray-700 text-base" class="relative w-full dark:text-gray-300 text-gray-700 text-base"
use:focusOutside={{ onFocusOut: deactivate }} use:focusOutside={{ onFocusOut: deactivate }}
@@ -351,7 +351,7 @@
size="small" size="small"
/> />
{:else if !isOpen} {:else if !isOpen}
<Icon icon={mdiUnfoldMoreHorizontal} aria-hidden /> <Icon icon={mdiChevronDown} aria-hidden />
{/if} {/if}
</div> </div>
</div> </div>
@@ -391,7 +391,7 @@
<li <li
aria-selected={index === selectedIndex} aria-selected={index === selectedIndex}
bind:this={optionRefs[index]} 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 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 wrap-break-words"
id={`${listboxId}-${index}`} id={`${listboxId}-${index}`}
onclick={() => handleSelect(option)} onclick={() => handleSelect(option)}
role="option" role="option"
@@ -10,6 +10,7 @@
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte'; import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk'; import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@@ -81,8 +82,7 @@
</script> </script>
<div id="camera-selection"> <div id="camera-selection">
<p class="uppercase immich-form-label">{$t('camera')}</p> <Text fontWeight="medium">{$t('camera')}</Text>
<div class="grid grid-auto-fit-40 gap-5 mt-1"> <div class="grid grid-auto-fit-40 gap-5 mt-1">
<div class="w-full"> <div class="w-full">
<Combobox <Combobox
@@ -1,13 +1,13 @@
<script lang="ts" module> <script lang="ts" module>
export interface SearchDateFilter { export interface SearchDateFilter {
takenBefore?: string; takenBefore?: DateTime;
takenAfter?: string; takenAfter?: DateTime;
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte'; import { DatePicker, Text } from '@immich/ui';
import { Text } from '@immich/ui'; import type { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@@ -17,23 +17,19 @@
let { filters = $bindable() }: Props = $props(); let { filters = $bindable() }: Props = $props();
let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore); 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> </script>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5"> <div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">
<label class="immich-form-label" for="start-date"> <div>
<span class="uppercase">{$t('start_date')}</span> <Text class="mb-2" fontWeight="medium">{$t('start_date')}</Text>
<DateInput class={inputClasses} type="date" id="start-date" name="start-date" bind:value={filters.takenAfter} /> <DatePicker bind:value={filters.takenAfter} />
</label> </div>
<label class="immich-form-label" for="end-date"> <div>
<span class="uppercase">{$t('end_date')}</span> <Text class="mb-2" fontWeight="medium">{$t('end_date')}</Text>
<DateInput class={inputClasses} type="date" id="end-date" name="end-date" bind:value={filters.takenBefore} /> <DatePicker bind:value={filters.takenBefore} />
</label> </div>
</div> </div>
{#if invalid} {#if invalid}
<Text color="danger">{$t('start_date_before_end_date')}</Text> <Text color="danger">{$t('start_date_before_end_date')}</Text>
@@ -7,7 +7,7 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { Checkbox, Label } from '@immich/ui'; import { Checkbox, Label, Text } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -20,19 +20,20 @@
<div id="display-options-selection"> <div id="display-options-selection">
<fieldset> <fieldset>
<legend class="uppercase immich-form-label">{$t('display_options')}</legend> <Text class="mb-2" fontWeight="medium">{$t('display_options')}</Text>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1"> <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} /> <Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} />
<Label label={$t('not_in_any_album')} for="not-in-album-checkbox" /> <Label label={$t('not_in_any_album')} for="not-in-album-checkbox" class="text-sm font-normal" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox id="archive-checkbox" size="tiny" bind:checked={filters.isArchive} /> <Checkbox id="archive-checkbox" size="tiny" bind:checked={filters.isArchive} />
<Label label={$t('archive')} for="archive-checkbox" /> <Label label={$t('archive')} for="archive-checkbox" class="text-sm font-normal" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox id="favorites-checkbox" size="tiny" bind:checked={filters.isFavorite} /> <Checkbox id="favorites-checkbox" size="tiny" bind:checked={filters.isFavorite} />
<Label label={$t('favorites')} for="favorites-checkbox" /> <Label label={$t('favorites')} for="favorites-checkbox" class="text-sm font-normal" />
</div> </div>
</div> </div>
</fieldset> </fieldset>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
import { Icon, IconButton } from '@immich/ui'; import { Icon, IconButton, Text } from '@immich/ui';
import { mdiClose, mdiMagnify } from '@mdi/js'; import { mdiClose, mdiMagnify } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; 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" 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"> <div class="flex items-center justify-between px-5 pt-5 text-xs">
<p class="uppercase py-2" aria-hidden={true}>{$t('recent_searches')}</p> <Text class="py-2" color="muted" aria-hidden={true}>{$t('recent_searches')}</Text>
{#if showClearAll} {#if showClearAll}
<button <button
id={getId(0)} id={getId(0)}
@@ -10,6 +10,7 @@
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte'; import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk'; import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { Text } from '@immich/ui';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -74,7 +75,7 @@
</script> </script>
<div id="location-selection"> <div id="location-selection">
<p class="uppercase immich-form-label">{$t('place')}</p> <Text fontWeight="medium">{$t('place')}</Text>
<div class="grid grid-auto-fit-40 gap-5 mt-1"> <div class="grid grid-auto-fit-40 gap-5 mt-1">
<div class="w-full"> <div class="w-full">
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { MediaType } from '$lib/constants'; import { MediaType } from '$lib/constants';
import RadioButton from '$lib/elements/RadioButton.svelte'; import RadioButton from '$lib/elements/RadioButton.svelte';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@@ -12,7 +13,8 @@
<div id="media-type-selection"> <div id="media-type-selection">
<fieldset> <fieldset>
<legend class="uppercase immich-form-label">{$t('media_type')}</legend> <Text class="mb-2" fontWeight="medium">{$t('media_type')}</Text>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1"> <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 name="media-type" id="type-all" bind:group={filteredMedia} label={$t('all')} value={MediaType.All} />
<RadioButton <RadioButton
@@ -5,7 +5,7 @@
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, LoadingSpinner } from '@immich/ui'; import { Button, LoadingSpinner, Text } from '@immich/ui';
import { mdiArrowRight, mdiClose } from '@mdi/js'; import { mdiArrowRight, mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { SvelteSet } from 'svelte/reactivity'; 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 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"> <div class="flex items-center w-full justify-between gap-6">
<p class="uppercase immich-form-label py-3">{$t('people')}</p> <Text class="py-3" fontWeight="medium">{$t('people')}</Text>
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} /> <SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
</div> </div>
<SingleGridRow <SingleGridRow
class="grid grid-auto-fill-20 gap-1 mt-2 overflow-y-auto immich-scrollbar" class="grid grid-auto-fill-20 gap-1 mt-2 overflow-y-auto immich-scrollbar space-between"
bind:itemCount={numberOfPeople} bind:itemCount={numberOfPeople}
> >
{#each peopleList as person (person.id)} {#each peopleList as person (person.id)}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import Combobox from '../combobox.svelte'; import Combobox from '../combobox.svelte';
@@ -18,16 +19,14 @@
]; ];
</script> </script>
<div class="grid grid-auto-fit-40 gap-5"> <div class="flex flex-col">
<label class="immich-form-label" for="start-date"> <Text class="mb-2" fontWeight="medium">{$t('rating')}</Text>
<div class="[&_label]:uppercase"> <Combobox
<Combobox label={$t('rating')}
label={$t('rating')} placeholder={$t('search_rating')}
placeholder={$t('search_rating')} hideLabel
{options} {options}
selectedOption={rating === undefined ? undefined : options[rating]} selectedOption={rating === undefined ? undefined : options[rating]}
onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))} onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))}
/> />
</div>
</label>
</div> </div>
@@ -2,7 +2,7 @@
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { getAllTags, type TagResponseDto } from '@immich/sdk'; import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { Checkbox, Icon, Label } from '@immich/ui'; import { Checkbox, Icon, Label, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -43,18 +43,18 @@
{#if $preferences?.tags?.enabled} {#if $preferences?.tags?.enabled}
<div id="location-selection"> <div id="location-selection">
<form autocomplete="off" id="create-tag-form"> <form autocomplete="off" id="create-tag-form">
<div class="my-4 flex flex-col gap-2"> <div class="mb-4 flex flex-col">
<div class="[&_label]:uppercase"> <Text class="py-3" fontWeight="medium">{$t('tags')}</Text>
<Combobox <Combobox
disabled={selectedTags === null} disabled={selectedTags === null}
onSelect={handleSelect} hideLabel
label={$t('tags')} onSelect={handleSelect}
defaultFirstOption label={$t('tags')}
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))} defaultFirstOption
bind:selectedOption options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
placeholder={$t('search_tags')} bind:selectedOption
/> placeholder={$t('search_tags')}
</div> />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox <Checkbox
@@ -65,7 +65,7 @@
selectedTags = checked ? null : new SvelteSet(); selectedTags = checked ? null : new SvelteSet();
}} }}
/> />
<Label label={$t('untagged')} for="untagged-checkbox" /> <Label label={$t('untagged')} for="untagged-checkbox" class="text-sm font-normal" />
</div> </div>
</form> </form>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import RadioButton from '$lib/elements/RadioButton.svelte'; import RadioButton from '$lib/elements/RadioButton.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Field, Input, Text } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@@ -11,73 +12,48 @@
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props(); let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
</script> </script>
<fieldset> <section>
<legend class="immich-form-label">{$t('search_type')}</legend> <fieldset>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1 mb-2"> <Text class="mb-2" fontWeight="medium">{$t('search_type')}</Text>
{#if featureFlagsManager.value.smartSearch} <div class="flex flex-wrap gap-x-5 gap-y-2 my-2">
<RadioButton name="query-type" id="context-radio" label={$t('context')} bind:group={queryType} value="smart" /> {#if featureFlagsManager.value.smartSearch}
{/if} <RadioButton name="query-type" id="context-radio" label={$t('context')} bind:group={queryType} value="smart" />
<RadioButton {/if}
name="query-type" <RadioButton
id="file-name-radio" name="query-type"
label={$t('file_name_or_extension')} id="file-name-radio"
bind:group={queryType} label={$t('file_name_or_extension')}
value="metadata" bind:group={queryType}
/> value="metadata"
<RadioButton />
name="query-type" <RadioButton
id="description-radio" name="query-type"
label={$t('description')} id="description-radio"
bind:group={queryType} label={$t('description')}
value="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 featureFlagsManager.value.ocr}
{/if} <RadioButton name="query-type" id="ocr-radio" label={$t('ocr')} bind:group={queryType} value="ocr" />
</div> {/if}
</fieldset> </div>
</fieldset>
{#if queryType === 'smart'} {#if queryType === 'smart'}
<label for="context-input" class="immich-form-label">{$t('search_by_context')}</label> <Field label={$t('search_by_context')}>
<input <Input type="text" placeholder={$t('sunrise_on_the_beach')} bind:value={query} />
class="immich-form-input hover:cursor-text w-full mt-1!" </Field>
type="text" {:else if queryType === 'metadata'}
id="context-input" <Field label={$t('search_by_filename')}>
name="context" <Input type="text" placeholder={$t('search_by_filename_example')} bind:value={query} />
placeholder={$t('sunrise_on_the_beach')} </Field>
bind:value={query} {:else if queryType === 'description'}
/> <Field label={$t('search_by_description')}>
{:else if queryType === 'metadata'} <Input type="text" placeholder={$t('search_by_description_example')} bind:value={query} />
<label for="file-name-input" class="immich-form-label">{$t('search_by_filename')}</label> </Field>
<input {:else if queryType === 'ocr'}
class="immich-form-input hover:cursor-text w-full mt-1!" <Field label={$t('search_by_ocr')}>
type="text" <Input type="text" placeholder={$t('search_by_ocr_example')} bind:value={query} />
id="file-name-input" </Field>
name="file-name" {/if}
placeholder={$t('search_by_filename_example')} </section>
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="mb-4 w-full">
<div class="flex place-items-center gap-1"> <div class="flex place-items-center gap-1">
<label class="font-medium text-primary text-sm min-h-6 uppercase" for={label}>{label}</label> <label class="font-medium text-primary text-sm min-h-6" for={label}>{label}</label>
{#if required} {#if required}
<div class="text-red-400">*</div> <div class="text-red-400">*</div>
{/if} {/if}
@@ -11,10 +11,12 @@
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils'; import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleErrorAsync } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk'; import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore; let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
@@ -38,28 +40,27 @@
person, person,
}: Props = $props(); }: Props = $props();
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => { const getAsset = (id: string) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset); return handleErrorAsync(
if (earlierTimelineAsset) { () => assetCacheManager.getAsset({ ...authManager.params, id }),
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id }); $t('error_retrieving_asset_information'),
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;
}
}; };
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => { const getNextAsset = async (currentAsset: AssetResponseDto) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset); const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (laterTimelineAsset) { if (!earlierTimelineAsset) {
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id }); return;
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(earlierTimelineAsset.id);
};
const getPreviousAsset = async (currentAsset: AssetResponseDto) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
if (!laterTimelineAsset) {
return;
}
return getAsset(laterTimelineAsset.id);
}; };
let assetCursor = $state<AssetCursor>({ let assetCursor = $state<AssetCursor>({
@@ -87,10 +88,12 @@
const handleRandom = async () => { const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset(); const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) { if (!randomAsset) {
await navigate({ targetRoute: 'current', assetId: randomAsset.id }); return;
return { id: randomAsset.id };
} }
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return { id: randomAsset.id };
}; };
const handleClose = async (asset: { id: string }) => { const handleClose = async (asset: { id: string }) => {
@@ -180,12 +183,14 @@
}; };
const handleUndoDelete = async (assets: TimelineAsset[]) => { const handleUndoDelete = async (assets: TimelineAsset[]) => {
timelineManager.upsertAssets(assets); timelineManager.upsertAssets(assets);
if (assets.length > 0) { if (assets.length === 0) {
const restoredAsset = assets[0]; return;
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) => { const handleUpdateOrUpload = (asset: AssetResponseDto) => {
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk'; import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
import { Button, modalManager, toastManager } from '@immich/ui'; import { Button, modalManager, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import DeviceCard from './device-card.svelte'; import DeviceCard from './device-card.svelte';
@@ -52,17 +52,17 @@
<section class="my-4"> <section class="my-4">
{#if currentSession} {#if currentSession}
<div class="mb-6"> <div class="mb-6">
<h3 class="uppercase mb-2 text-xs font-medium text-primary"> <Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('current_device')} {$t('current_device')}
</h3> </Text>
<DeviceCard session={currentSession} /> <DeviceCard session={currentSession} />
</div> </div>
{/if} {/if}
{#if otherSessions.length > 0} {#if otherSessions.length > 0}
<div class="mb-6"> <div class="mb-6">
<h3 class="uppercase mb-2 text-xs font-medium text-primary"> <Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('other_devices')} {$t('other_devices')}
</h3> </Text>
{#each otherSessions as session, index (session.id)} {#each otherSessions as session, index (session.id)}
<DeviceCard {session} onDelete={() => handleDelete(session)} /> <DeviceCard {session} onDelete={() => handleDelete(session)} />
{#if index !== otherSessions.length - 1} {#if index !== otherSessions.length - 1}
@@ -70,9 +70,11 @@
{/if} {/if}
{/each} {/each}
</div> </div>
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
{$t('log_out_all_devices')} <div class="my-3">
</h3> <hr />
</div>
<div class="flex justify-end"> <div class="flex justify-end">
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button> <Button shape="round" color="danger" size="small" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
</div> </div>
@@ -12,7 +12,7 @@
type PartnerResponseDto, type PartnerResponseDto,
type UserResponseDto, type UserResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager } from '@immich/ui'; import { Button, Icon, IconButton, modalManager, Text } from '@immich/ui';
import { mdiCheck, mdiClose } from '@mdi/js'; import { mdiCheck, mdiClose } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -157,10 +157,12 @@
<!-- I am sharing my assets with this user --> <!-- I am sharing my assets with this user -->
{#if partner.sharedByMe} {#if partner.sharedByMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" /> <hr class="my-4 border border-gray-200 dark:border-gray-700" />
<p class="uppercase text-xs font-medium my-4"> <Text class="my-4" size="small" fontWeight="medium">
{$t('shared_with_partner', { values: { partner: partner.user.name } })} {$t('shared_with_partner', { values: { partner: partner.user.name } })}
</p> </Text>
<p class="text-md">{$t('partner_can_access', { values: { partner: partner.user.name } })}</p> <Text size="tiny" fontWeight="medium"
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
>
<ul class="text-sm"> <ul class="text-sm">
<li class="flex gap-2 place-items-center py-1 mt-2"> <li class="flex gap-2 place-items-center py-1 mt-2">
<Icon icon={mdiCheck} /> <Icon icon={mdiCheck} />
@@ -176,9 +178,10 @@
<!-- this user is sharing assets with me --> <!-- this user is sharing assets with me -->
{#if partner.sharedWithMe} {#if partner.sharedWithMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" /> <hr class="my-4 border border-gray-200 dark:border-gray-700" />
<p class="uppercase text-xs font-medium my-4"> <Text class="my-4" size="small" fontWeight="medium">
{$t('shared_from_partner', { values: { partner: partner.user.name } })} {$t('shared_from_partner', { values: { partner: partner.user.name } })}
</p> </Text>
<SettingSwitch <SettingSwitch
title={$t('show_in_timeline')} title={$t('show_in_timeline')}
subtitle={$t('show_in_timeline_setting_description')} subtitle={$t('show_in_timeline_setting_description')}
@@ -2,7 +2,7 @@
import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte'; import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte'; import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { Icon, modalManager } from '@immich/ui'; import { Icon, modalManager, Text } from '@immich/ui';
import { import {
mdiCellphoneArrowDownVariant, mdiCellphoneArrowDownVariant,
mdiContentDuplicate, mdiContentDuplicate,
@@ -22,7 +22,7 @@
</script> </script>
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white"> <div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="uppercase text-xs font-medium p-4">{$t('organize_your_library')}</p> <Text size="tiny" color="muted" fontWeight="medium" class="p-4">{$t('organize_your_library')}</Text>
{#each links as link (link.href)} {#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"> <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,7 +33,8 @@
</div> </div>
<br /> <br />
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white"> <div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="uppercase text-xs font-medium p-4">{$t('download')}</p> <Text size="tiny" color="muted" fontWeight="medium" class="p-4">{$t('download')}</Text>
<button <button
type="button" type="button"
onclick={() => modalManager.show(ObtainiumConfigModal, {})} onclick={() => modalManager.show(ObtainiumConfigModal, {})}
@@ -113,7 +113,7 @@
<div class="rounded-lg bg-light-100 border p-3"> <div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<Icon icon={mdiFlashOutline} size="18" class="text-primary" /> <Icon icon={mdiFlashOutline} size="18" class="text-primary" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('trigger')}</span> <Text size="tiny" fontWeight="semi-bold">{$t('trigger')}</Text>
</div> </div>
<p class="text-sm truncate pl-5">{getTriggerName(trigger.type)}</p> <p class="text-sm truncate pl-5">{getTriggerName(trigger.type)}</p>
</div> </div>
@@ -128,7 +128,7 @@
<div class="rounded-lg bg-light-100 border p-3"> <div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<Icon icon={mdiFilterOutline} size="18" class="text-warning" /> <Icon icon={mdiFilterOutline} size="18" class="text-warning" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span> <Text size="tiny" fontWeight="semi-bold">{$t('filters')}</Text>
</div> </div>
<div class="space-y-1 pl-5"> <div class="space-y-1 pl-5">
{#each filters as filter, index (index)} {#each filters as filter, index (index)}
@@ -154,7 +154,7 @@
<div class="rounded-lg bg-light-100 border p-3"> <div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" /> <Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span> <Text size="tiny" fontWeight="semi-bold">{$t('actions')}</Text>
</div> </div>
<div class="space-y-1 pl-5"> <div class="space-y-1 pl-5">
{#each actions as action, index (index)} {#each actions as action, index (index)}
+1 -1
View File
@@ -12,5 +12,5 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="radio" {name} {id} {value} class="focus-visible:ring" bind:group /> <input type="radio" {name} {id} {value} class="focus-visible:ring" bind:group />
<label for={id}>{label}</label> <label for={id} class="text-sm">{label}</label>
</div> </div>
+4 -3
View File
@@ -37,6 +37,7 @@
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk'; import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui'; import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiTune } from '@mdi/js'; import { mdiTune } from '@mdi/js';
import type { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
@@ -47,8 +48,8 @@
let { searchQuery, onClose }: Props = $props(); let { searchQuery, onClose }: Props = $props();
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined); const parseOptionalDate = (dateString?: DateTime) => (dateString ? parseUtcDate(dateString.toString()) : undefined);
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined; const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined;
const formId = generateId(); const formId = generateId();
// combobox and all the search components have terrible support for value | null so we use empty string instead. // combobox and all the search components have terrible support for value | null so we use empty string instead.
@@ -187,7 +188,7 @@
<Modal icon={mdiTune} size="giant" title={$t('search_options')} {onClose}> <Modal icon={mdiTune} size="giant" title={$t('search_options')} {onClose}>
<ModalBody> <ModalBody>
<form id={formId} autocomplete="off" {onsubmit} {onreset}> <form id={formId} autocomplete="off" {onsubmit} {onreset}>
<div class="flex flex-col gap-4 pb-10" tabindex="-1"> <div class="flex flex-col gap-5 pb-10" tabindex="-1">
<!-- PEOPLE --> <!-- PEOPLE -->
<SearchPeopleSection bind:selectedPeople={filter.personIds} /> <SearchPeopleSection bind:selectedPeople={filter.personIds} />
+14
View File
@@ -447,3 +447,17 @@ export const withoutIcons = (actions: ActionItem[]): ActionItem[] =>
actions.map((action) => ({ ...action, icon: undefined })); actions.map((action) => ({ ...action, icon: undefined }));
export const isEnabled = ({ $if }: IfLike) => $if?.() ?? true; 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 -5
View File
@@ -19,12 +19,17 @@ export function getServerErrorMessage(error: unknown) {
return data?.message || error.message; return data?.message || error.message;
} }
export function handleError(error: unknown, message: string) { export function standardizeError(error: unknown) {
if ((error as Error)?.name === 'AbortError') { return error instanceof Error ? error : new Error(String(error));
}
export function handleError(error: unknown, localizedMessage: string) {
const standardizedError = standardizeError(error);
if (standardizedError.name === 'AbortError') {
return; return;
} }
console.error(`[handleError]: ${message}`, error, (error as Error)?.stack); console.error(`[handleError]: ${standardizedError}`, error, standardizedError.stack);
try { try {
let serverMessage = getServerErrorMessage(error); let serverMessage = getServerErrorMessage(error);
@@ -32,13 +37,22 @@ export function handleError(error: unknown, message: string) {
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`; serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
} }
const errorMessage = serverMessage || message; const errorMessage = serverMessage || localizedMessage;
toastManager.danger(errorMessage); toastManager.danger(errorMessage);
return errorMessage; return errorMessage;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return message; 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;
} }
} }
+5 -1
View File
@@ -1,3 +1,5 @@
import { handleError } from '$lib/utils/handle-error';
/** /**
* Tracks the state of asynchronous invocations to handle race conditions and stale operations. * 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 * This class helps manage concurrent operations by tracking which invocations are active
@@ -51,10 +53,12 @@ export class InvocationTracker {
return this.invocationsStarted !== this.invocationsEnded; return this.invocationsStarted !== this.invocationsEnded;
} }
async invoke<T>(invocable: () => Promise<T>) { async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
const invocation = this.startInvocation(); const invocation = this.startInvocation();
try { try {
return await invocable(); return await invocable();
} catch (error: unknown) {
handleError(error, localizedMessage);
} finally { } finally {
invocation.endInvocation(); invocation.endInvocation();
} }
+6 -8
View File
@@ -24,11 +24,11 @@ export interface boundingBox {
export const getBoundingBox = ( export const getBoundingBox = (
faces: Faces[], faces: Faces[],
zoom: ZoomImageWheelState, zoom: ZoomImageWheelState,
photoViewer: HTMLImageElement | null, photoViewer: HTMLImageElement | undefined,
): boundingBox[] => { ): boundingBox[] => {
const boxes: boundingBox[] = []; const boxes: boundingBox[] = [];
if (photoViewer === null) { if (!photoViewer) {
return boxes; return boxes;
} }
const clientHeight = photoViewer.clientHeight; const clientHeight = photoViewer.clientHeight;
@@ -93,7 +93,7 @@ export const zoomImageToBase64 = async (
image = img; image = img;
} }
if (image === null) { if (!image) {
return null; return null;
} }
const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face; const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face;
@@ -121,11 +121,9 @@ export const zoomImageToBase64 = async (
canvas.height = faceHeight; canvas.height = faceHeight;
const context = canvas.getContext('2d'); 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; return null;
} }
context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
return canvas.toDataURL();
}; };
@@ -28,7 +28,7 @@
import { cancelMultiselect } from '$lib/utils/asset-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { joinPaths } from '$lib/utils/tree-utils'; import { joinPaths } from '$lib/utils/tree-utils';
import { IconButton } from '@immich/ui'; import { IconButton, Text } from '@immich/ui';
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -77,7 +77,7 @@
<Sidebar> <Sidebar>
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} breakpoint="md" /> <SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} breakpoint="md" />
<section> <section>
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div> <Text class="ps-4 mb-4" size="small">{$t('explorer')}</Text>
<div class="h-full"> <div class="h-full">
<TreeItems <TreeItems
icons={{ default: mdiFolderOutline, active: mdiFolder }} icons={{ default: mdiFolderOutline, active: mdiFolder }}
@@ -2,7 +2,6 @@
import { afterNavigate, goto } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut'; 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 ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
@@ -308,16 +307,6 @@
bind:clientWidth={viewport.width} bind:clientWidth={viewport.width}
bind:this={searchResultsElement} 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"> <section id="search-content">
{#if searchResultAssets.length > 0} {#if searchResultAssets.length > 0}
<GalleryViewer <GalleryViewer
@@ -30,6 +30,7 @@
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import { joinPaths, TreeNode } from '$lib/utils/tree-utils'; import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
import { getAllTags, type TagResponseDto } from '@immich/sdk'; import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { Text } from '@immich/ui';
import { mdiDotsVertical, mdiPlus, mdiTag, mdiTagMultiple } from '@mdi/js'; import { mdiDotsVertical, mdiPlus, mdiTag, mdiTagMultiple } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -82,7 +83,7 @@
<Sidebar> <Sidebar>
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" /> <SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" />
<section> <section>
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div> <Text class="ps-4 mb-4" size="small">{$t('explorer')}</Text>
<div class="h-full"> <div class="h-full">
<TreeItems icons={{ default: mdiTag, active: mdiTag }} {tree} active={tag.path} {getLink} /> <TreeItems icons={{ default: mdiTag, active: mdiTag }} {tree} active={tag.path} {getLink} />
</div> </div>
@@ -139,9 +139,9 @@
<UserPageLayout title={data.meta.title} scrollbar={true}> <UserPageLayout title={data.meta.title} scrollbar={true}>
{#snippet buttons()} {#snippet buttons()}
<div class="flex gap-2 justify-end place-items-center"> <div class="flex gap-2 justify-end place-items-center">
<Text class="hidden md:block text-xs mr-4 text-dark/50">{$t('geolocation_instruction_location')}</Text> <Text class="hidden md:block mr-4" size="tiny" color="muted">{$t('geolocation_instruction_location')}</Text>
<div class="border flex place-items-center place-content-center px-2 py-1 bg-primary/10 rounded-2xl"> <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 text-xs text-gray-500 font-mono mr-5 ml-2 uppercase"> <Text class="hidden md:inline-block font-mono mr-5 ml-2" color="muted" size="tiny">
{$t('selected_gps_coordinates')} {$t('selected_gps_coordinates')}
</Text> </Text>
<Text <Text
@@ -208,9 +208,7 @@
<!-- Trigger Section --> <!-- Trigger Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200"> <div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3"> <div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold" <Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
>{$t('trigger')}</Text
>
</div> </div>
{@render chipItem(getTriggerLabel(workflow.triggerType))} {@render chipItem(getTriggerLabel(workflow.triggerType))}
</div> </div>
@@ -218,9 +216,7 @@
<!-- Filters Section --> <!-- Filters Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200"> <div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3"> <div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold" <Text size="tiny" color="muted" fontWeight="medium">{$t('filters')}</Text>
>{$t('filters')}</Text
>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#if workflow.filters.length === 0} {#if workflow.filters.length === 0}
@@ -238,9 +234,7 @@
<!-- Actions Section --> <!-- Actions Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200"> <div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3"> <div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold" <Text size="tiny" color="muted" fontWeight="medium">{$t('actions')}</Text>
>{$t('actions')}</Text
>
</div> </div>
<div> <div>
@@ -338,7 +338,7 @@
<div class="w-full border-t-2 border-dashed border-light-200"></div> <div class="w-full border-t-2 border-dashed border-light-200"></div>
</div> </div>
<div class="relative flex justify-center text-xs uppercase"> <div class="relative flex justify-center text-xs uppercase">
<span class="bg-white dark:bg-black px-2 font-semibold text-light-500">THEN</span> <Text class="bg-white dark:bg-black px-2" fontWeight="semi-bold" size="tiny" color="muted">{$t('then')}</Text>
</div> </div>
</div> </div>
{/snippet} {/snippet}