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"
/>
## 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.
+4 -1
View File
@@ -1021,9 +1021,11 @@
"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",
@@ -1570,7 +1572,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",
@@ -2204,6 +2206,7 @@
"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",
@@ -285,7 +285,12 @@ class BackgroundUploadService {
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;
String metadata = UploadTaskMetadata(
@@ -315,7 +315,16 @@ class ForegroundUploadService {
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 deviceId = Store.get(StoreKey.deviceId);
+1 -1
View File
@@ -4,7 +4,7 @@
/* @import '/usr/ui/dist/theme/default.css'; */
@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 {
+10 -9
View File
@@ -5,6 +5,7 @@
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 {
@@ -53,7 +54,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 class="uppercase">{title}</span>
<span>{transformToTitleCase(title)}</span>
</Link>
<IconButton
color="primary"
@@ -131,7 +132,7 @@
onClick={() => onCommand({ command: QueueCommand.Start, force: false })}
>
<Icon icon={mdiAlertCircle} size="36" />
<span class="uppercase">{$t('disabled')}</span>
<span>{$t('disabled')}</span>
</QueueCardButton>
{/if}
@@ -139,7 +140,7 @@
{#if waitingCount > 0}
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
<Icon icon={mdiClose} size="24" />
<span class="uppercase">{$t('clear')}</span>
<span>{$t('clear')}</span>
</QueueCardButton>
{/if}
{#if queue.isPaused}
@@ -147,12 +148,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 class="uppercase">{$t('resume')}</span>
<span>{$t('resume')}</span>
</QueueCardButton>
{:else}
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
<Icon icon={mdiPause} size="24" />
<span class="uppercase">{$t('pause')}</span>
<span>{$t('pause')}</span>
</QueueCardButton>
{/if}
{/if}
@@ -161,25 +162,25 @@
{#if allText}
<QueueCardButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
<Icon icon={mdiAllInclusive} size="24" />
<span class="uppercase">{allText}</span>
<span>{allText}</span>
</QueueCardButton>
{/if}
{#if refreshText}
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
<Icon icon={mdiImageRefreshOutline} size="24" />
<span class="uppercase">{refreshText}</span>
<span>{refreshText}</span>
</QueueCardButton>
{/if}
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
<Icon icon={mdiSelectionSearch} size="24" />
<span class="uppercase">{missingText}</span>
<span>{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 class="uppercase">{missingText}</span>
<span>{missingText}</span>
</QueueCardButton>
{/if}
</div>
@@ -12,7 +12,7 @@
import { handleSystemConfigSave } from '$lib/services/system-config.service';
import { user } from '$lib/stores/user.store';
import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { Heading, LoadingSpinner, Text } from '@immich/ui';
import handlebar from 'handlebars';
import * as luxon from 'luxon';
import { onDestroy } from 'svelte';
@@ -158,7 +158,9 @@
{#if configToEdit.storageTemplate.enabled}
<hr />
<h3 class="text-base font-medium text-primary">{$t('variables')}</h3>
<Heading size="tiny" color="primary">
{$t('variables')}
</Heading>
<section class="support-date">
{#await getSupportDateTimeFormat()}
@@ -174,11 +176,14 @@
<SupportedVariablesPanel />
</section>
<div class="flex flex-col mt-4">
<h3 class="text-base font-medium text-primary">{$t('template')}</h3>
<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="my-2 text-sm">
<h4 class="uppercase">{$t('preview')}</h4>
<div class="my-2">
<Text size="small">{$t('preview')}</Text>
</div>
<p class="text-sm">
@@ -249,7 +254,9 @@
{#if !minified}
<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">
<p>
<FormatMessage
@@ -1,6 +1,7 @@
<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';
@@ -15,78 +16,80 @@
};
</script>
<div class="mt-2 text-sm">
<h4 class="uppercase">{$t('date_and_time')}</h4>
</div>
<Text size="small">{$t('date_and_time')}</Text>
<!-- 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">
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
</div>
</div>
</CardBody>
</Card>
@@ -1,56 +1,59 @@
<script lang="ts">
import { Card, CardBody, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
</script>
<div class="mt-4 text-sm">
<h4 class="uppercase">{$t('other_variables')}</h4>
</div>
<Text size="small">{$t('other_variables')}</Text>
<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>
<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>
<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>
<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>
<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>
</CardBody>
</Card>
@@ -252,7 +252,7 @@
await handleStopSlideshow();
}
}
});
}, $t('error_while_navigating'));
};
const showEditor = () => {
@@ -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 } from '@immich/ui';
import { Icon, modalManager, Text } 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">
<h2 class="uppercase">{$t('tags')}</h2>
<Text color="muted">{$t('tags')}</Text>
</div>
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)}
@@ -20,7 +20,7 @@
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
import { Icon, IconButton, LoadingSpinner, modalManager, Text } 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">
<h2 class="uppercase">{$t('people')}</h2>
<Text size="small" color="muted">{$t('people')}</Text>
<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">
<h2 class="uppercase">{$t('details')}</h2>
<Text size="small" color="muted">{$t('details')}</Text>
</div>
{: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 dateTime}
@@ -487,7 +487,7 @@
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<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>
<UserAvatar user={asset.owner} size="md" />
@@ -504,7 +504,9 @@
{#if albums.length > 0}
<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)}
<a href={Route.viewAlbum(album)}>
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
@@ -147,7 +147,7 @@
{/if}
</div>
<div class="px-4 py-4 text-sm">
<h2 class="mb-8 mt-4 uppercase">{$t('all_people')}</h2>
<h2 class="mb-8 mt-4">{$t('all_people')}</h2>
{#if isShowLoadingPeople}
<div class="flex w-full justify-center">
<LoadingSpinner />
@@ -39,7 +39,7 @@
<Icon {icon} size="30" class="text-primary" />
{/if}
{#if title}
<p class="uppercase text-xl text-primary">
<p class="text-xl text-primary font-medium">
{title}
</p>
{/if}
@@ -19,7 +19,7 @@
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary"
>
<Icon icon={sunPath} viewBox={sunViewBox} size="96" />
<p class="uppercase font-semibold text-4xl">{$t('light')}</p>
<p class="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="uppercase font-semibold text-4xl">{$t('dark')}</p>
<p class="font-semibold text-4xl">{$t('dark')}</p>
</div>
</button>
</div>
@@ -24,7 +24,7 @@
import { shortcuts } from '$lib/actions/shortcut';
import { generateId } from '$lib/utils/generate-id';
import { Icon, IconButton, Label } from '@immich/ui';
import { mdiClose, mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js';
import { mdiChevronDown, mdiClose, mdiMagnify } 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' : ''}" for={inputId}>{label}</Label>
<Label class="block mb-1 {hideLabel ? 'sr-only' : ''} text-xs text-neutral-500 font-light" 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={mdiUnfoldMoreHorizontal} aria-hidden />
<Icon icon={mdiChevronDown} 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 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}`}
onclick={() => handleSelect(option)}
role="option"
@@ -10,6 +10,7 @@
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 {
@@ -81,8 +82,7 @@
</script>
<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="w-full">
<Combobox
@@ -1,13 +1,13 @@
<script lang="ts" module>
export interface SearchDateFilter {
takenBefore?: string;
takenAfter?: string;
takenBefore?: DateTime;
takenAfter?: DateTime;
}
</script>
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import { Text } from '@immich/ui';
import { DatePicker, Text } from '@immich/ui';
import type { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
@@ -17,23 +17,19 @@
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">
<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('start_date')}</Text>
<DatePicker bind:value={filters.takenAfter} />
</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>
<Text class="mb-2" fontWeight="medium">{$t('end_date')}</Text>
<DatePicker bind:value={filters.takenBefore} />
</div>
</div>
{#if invalid}
<Text color="danger">{$t('start_date_before_end_date')}</Text>
@@ -7,7 +7,7 @@
</script>
<script lang="ts">
import { Checkbox, Label } from '@immich/ui';
import { Checkbox, Label, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
@@ -20,19 +20,20 @@
<div id="display-options-selection">
<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 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" />
<Label label={$t('not_in_any_album')} for="not-in-album-checkbox" class="text-sm font-normal" />
</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" />
<Label label={$t('archive')} for="archive-checkbox" class="text-sm font-normal" />
</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" />
<Label label={$t('favorites')} for="favorites-checkbox" class="text-sm font-normal" />
</div>
</div>
</fieldset>
@@ -1,6 +1,6 @@
<script lang="ts">
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 { 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">
<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}
<button
id={getId(0)}
@@ -10,6 +10,7 @@
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';
@@ -74,7 +75,7 @@
</script>
<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="w-full">
@@ -1,6 +1,7 @@
<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 {
@@ -12,7 +13,8 @@
<div id="media-type-selection">
<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">
<RadioButton name="media-type" id="type-all" bind:group={filteredMedia} label={$t('all')} value={MediaType.All} />
<RadioButton
@@ -5,7 +5,7 @@
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, LoadingSpinner } from '@immich/ui';
import { Button, LoadingSpinner, Text } 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">
<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} />
</div>
<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}
>
{#each peopleList as person (person.id)}
@@ -1,4 +1,5 @@
<script lang="ts">
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
import Combobox from '../combobox.svelte';
@@ -18,16 +19,14 @@
];
</script>
<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 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>
@@ -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 } from '@immich/ui';
import { Checkbox, Icon, Label, Text } 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="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 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>
<div class="flex items-center gap-2">
<Checkbox
@@ -65,7 +65,7 @@
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>
</form>
@@ -1,6 +1,7 @@
<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 {
@@ -11,73 +12,48 @@
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
</script>
<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>
<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>
{#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}
{#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>
@@ -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 uppercase" for={label}>{label}</label>
<label class="font-medium text-primary text-sm min-h-6" for={label}>{label}</label>
{#if required}
<div class="text-red-400">*</div>
{/if}
@@ -11,10 +11,12 @@
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;
@@ -38,28 +40,27 @@
person,
}: Props = $props();
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
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;
}
const getAsset = (id: string) => {
return handleErrorAsync(
() => assetCacheManager.getAsset({ ...authManager.params, id }),
$t('error_retrieving_asset_information'),
);
};
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
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;
const getNextAsset = async (currentAsset: AssetResponseDto) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (!earlierTimelineAsset) {
return;
}
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>({
@@ -87,10 +88,12 @@
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return { id: randomAsset.id };
if (!randomAsset) {
return;
}
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return { id: randomAsset.id };
};
const handleClose = async (asset: { id: string }) => {
@@ -180,12 +183,14 @@
};
const handleUndoDelete = async (assets: TimelineAsset[]) => {
timelineManager.upsertAssets(assets);
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 });
if (assets.length === 0) {
return;
}
const restoredAsset = assets[0];
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
};
const handleUpdateOrUpload = (asset: AssetResponseDto) => {
@@ -1,7 +1,7 @@
<script lang="ts">
import { handleError } from '$lib/utils/handle-error';
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 DeviceCard from './device-card.svelte';
@@ -52,17 +52,17 @@
<section class="my-4">
{#if currentSession}
<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')}
</h3>
</Text>
<DeviceCard session={currentSession} />
</div>
{/if}
{#if otherSessions.length > 0}
<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')}
</h3>
</Text>
{#each otherSessions as session, index (session.id)}
<DeviceCard {session} onDelete={() => handleDelete(session)} />
{#if index !== otherSessions.length - 1}
@@ -70,9 +70,11 @@
{/if}
{/each}
</div>
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
{$t('log_out_all_devices')}
</h3>
<div class="my-3">
<hr />
</div>
<div class="flex justify-end">
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
</div>
@@ -12,7 +12,7 @@
type PartnerResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Button, Icon, IconButton, modalManager } from '@immich/ui';
import { Button, Icon, IconButton, modalManager, Text } from '@immich/ui';
import { mdiCheck, mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -157,10 +157,12 @@
<!-- I am sharing my assets with this user -->
{#if partner.sharedByMe}
<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 } })}
</p>
<p class="text-md">{$t('partner_can_access', { values: { partner: partner.user.name } })}</p>
</Text>
<Text size="tiny" fontWeight="medium"
>{$t('partner_can_access', { values: { partner: partner.user.name } })}</Text
>
<ul class="text-sm">
<li class="flex gap-2 place-items-center py-1 mt-2">
<Icon icon={mdiCheck} />
@@ -176,9 +178,10 @@
<!-- this user is sharing assets with me -->
{#if partner.sharedWithMe}
<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 } })}
</p>
</Text>
<SettingSwitch
title={$t('show_in_timeline')}
subtitle={$t('show_in_timeline_setting_description')}
@@ -2,7 +2,7 @@
import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
import { Route } from '$lib/route';
import { Icon, modalManager } from '@immich/ui';
import { Icon, modalManager, Text } 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">
<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)}
<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>
<br />
<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
type="button"
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
@@ -113,7 +113,7 @@
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-1">
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('trigger')}</span>
<Text size="tiny" fontWeight="semi-bold">{$t('trigger')}</Text>
</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" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span>
<Text size="tiny" fontWeight="semi-bold">{$t('filters')}</Text>
</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" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span>
<Text size="tiny" fontWeight="semi-bold">{$t('actions')}</Text>
</div>
<div class="space-y-1 pl-5">
{#each actions as action, index (index)}
+1 -1
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}>{label}</label>
<label for={id} class="text-sm">{label}</label>
</div>
+4 -3
View File
@@ -37,6 +37,7 @@
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';
@@ -47,8 +48,8 @@
let { searchQuery, onClose }: Props = $props();
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
const parseOptionalDate = (dateString?: DateTime) => (dateString ? parseUtcDate(dateString.toString()) : undefined);
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined;
const formId = generateId();
// 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}>
<ModalBody>
<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 -->
<SearchPeopleSection bind:selectedPeople={filter.personIds} />
+14
View File
@@ -447,3 +447,17 @@ export const withoutIcons = (actions: ActionItem[]): ActionItem[] =>
actions.map((action) => ({ ...action, icon: undefined }));
export const isEnabled = ({ $if }: IfLike) => $if?.() ?? true;
export const transformToTitleCase = (text: string) => {
if (text.length === 0) {
return text;
} else if (text.length === 1) {
return text.charAt(0).toUpperCase();
}
let result = '';
for (const word of text.toLowerCase().split(' ')) {
result += word.charAt(0).toUpperCase() + word.slice(1) + ' ';
}
return result.trim();
};
+19 -5
View File
@@ -19,12 +19,17 @@ export function getServerErrorMessage(error: unknown) {
return data?.message || error.message;
}
export function handleError(error: unknown, message: string) {
if ((error as Error)?.name === 'AbortError') {
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') {
return;
}
console.error(`[handleError]: ${message}`, error, (error as Error)?.stack);
console.error(`[handleError]: ${standardizedError}`, error, standardizedError.stack);
try {
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)`;
}
const errorMessage = serverMessage || message;
const errorMessage = serverMessage || localizedMessage;
toastManager.danger(errorMessage);
return errorMessage;
} catch (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.
* This class helps manage concurrent operations by tracking which invocations are active
@@ -51,10 +53,12 @@ export class InvocationTracker {
return this.invocationsStarted !== this.invocationsEnded;
}
async invoke<T>(invocable: () => Promise<T>) {
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
const invocation = this.startInvocation();
try {
return await invocable();
} catch (error: unknown) {
handleError(error, localizedMessage);
} finally {
invocation.endInvocation();
}
+6 -8
View File
@@ -24,11 +24,11 @@ export interface boundingBox {
export const getBoundingBox = (
faces: Faces[],
zoom: ZoomImageWheelState,
photoViewer: HTMLImageElement | null,
photoViewer: HTMLImageElement | undefined,
): boundingBox[] => {
const boxes: boundingBox[] = [];
if (photoViewer === null) {
if (!photoViewer) {
return boxes;
}
const clientHeight = photoViewer.clientHeight;
@@ -93,7 +93,7 @@ export const zoomImageToBase64 = async (
image = img;
}
if (image === null) {
if (!image) {
return null;
}
const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face;
@@ -121,11 +121,9 @@ export const zoomImageToBase64 = async (
canvas.height = faceHeight;
const context = canvas.getContext('2d');
if (context) {
context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
return canvas.toDataURL();
} else {
if (!context) {
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 { toTimelineAsset } from '$lib/utils/timeline-util';
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 { 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>
<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">
<TreeItems
icons={{ default: mdiFolderOutline, active: mdiFolder }}
@@ -2,7 +2,6 @@
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';
@@ -308,16 +307,6 @@
bind:clientWidth={viewport.width}
bind:this={searchResultsElement}
>
{#if searchResultAlbums.length > 0}
<section>
<div class="uppercase ms-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums')}</div>
<AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
<div class="uppercase m-6 text-4xl font-medium text-black/70 dark:text-white/80">
{$t('photos_and_videos')}
</div>
</section>
{/if}
<section id="search-content">
{#if searchResultAssets.length > 0}
<GalleryViewer
@@ -30,6 +30,7 @@
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';
@@ -82,7 +83,7 @@
<Sidebar>
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" />
<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">
<TreeItems icons={{ default: mdiTag, active: mdiTag }} {tree} active={tag.path} {getLink} />
</div>
@@ -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 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">
<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')}
</Text>
<Text
@@ -208,9 +208,7 @@
<!-- Trigger Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
>{$t('trigger')}</Text
>
<Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
</div>
{@render chipItem(getTriggerLabel(workflow.triggerType))}
</div>
@@ -218,9 +216,7 @@
<!-- Filters Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
>{$t('filters')}</Text
>
<Text size="tiny" color="muted" fontWeight="medium">{$t('filters')}</Text>
</div>
<div class="flex flex-wrap gap-2">
{#if workflow.filters.length === 0}
@@ -238,9 +234,7 @@
<!-- Actions Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
>{$t('actions')}</Text
>
<Text size="tiny" color="muted" fontWeight="medium">{$t('actions')}</Text>
</div>
<div>
@@ -338,7 +338,7 @@
<div class="w-full border-t-2 border-dashed border-light-200"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<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>
{/snippet}