chore(web): migration svelte 5 syntax (#13883)

This commit is contained in:
Alex 2024-11-14 08:43:25 -06:00 committed by GitHub
parent 9203a61709
commit 0b3742cf13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
310 changed files with 6435 additions and 4176 deletions

View file

@ -11,17 +11,19 @@
import { sortAlbums } from '$lib/utils/album-utils';
import { albumViewSettings } from '$lib/stores/preferences.store';
export let onNewAlbum: (search: string) => void;
export let onAlbumClick: (album: AlbumResponseDto) => void;
let albums: AlbumResponseDto[] = $state([]);
let recentAlbums: AlbumResponseDto[] = $state([]);
let loading = $state(true);
let search = $state('');
let albums: AlbumResponseDto[] = [];
let recentAlbums: AlbumResponseDto[] = [];
let filteredAlbums: AlbumResponseDto[] = [];
let loading = true;
let search = '';
interface Props {
onNewAlbum: (search: string) => void;
onAlbumClick: (album: AlbumResponseDto) => void;
shared: boolean;
onClose: () => void;
}
export let shared: boolean;
export let onClose: () => void;
let { onNewAlbum, onAlbumClick, shared, onClose }: Props = $props();
onMount(async () => {
albums = await getAllAlbums({ shared: shared || undefined });
@ -29,13 +31,15 @@
loading = false;
});
$: filteredAlbums = sortAlbums(
search.length > 0 && albums.length > 0
? albums.filter((album) => {
return normalizeSearchString(album.albumName).includes(normalizeSearchString(search));
})
: albums,
{ sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder },
let filteredAlbums = $derived(
sortAlbums(
search.length > 0 && albums.length > 0
? albums.filter((album) => {
return normalizeSearchString(album.albumName).includes(normalizeSearchString(search));
})
: albums,
{ sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder },
),
);
const getTitle = () => {
@ -71,7 +75,7 @@
<div class="immich-scrollbar overflow-y-auto">
<button
type="button"
on:click={() => onNewAlbum(search)}
onclick={() => onNewAlbum(search)}
class="flex w-full items-center gap-4 px-6 py-2 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
>
<div class="flex h-12 w-12 items-center justify-center">

View file

@ -3,23 +3,23 @@
import { shortcut } from '$lib/actions/shortcut';
import { tick } from 'svelte';
export let content: string = '';
let className: string = '';
export { className as class };
export let onContentUpdate: (newContent: string) => void = () => null;
export let placeholder: string = '';
interface Props {
content?: string;
class?: string;
onContentUpdate?: (newContent: string) => void;
placeholder?: string;
}
let textarea: HTMLTextAreaElement;
$: newContent = content;
let { content = '', class: className = '', onContentUpdate = () => null, placeholder = '' }: Props = $props();
$: {
// re-visit with svelte 5. runes will make this better.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
newContent;
let textarea: HTMLTextAreaElement | undefined = $state();
let newContent = $state(content);
$effect(() => {
if (textarea && newContent.length > 0) {
void tick().then(() => autoGrowHeight(textarea));
}
}
});
const updateContent = () => {
if (content === newContent) {
@ -32,8 +32,8 @@
<textarea
bind:this={textarea}
class="resize-none {className}"
on:focusout={updateContent}
on:input={(e) => (newContent = e.currentTarget.value)}
onfocusout={updateContent}
oninput={(e) => (newContent = e.currentTarget.value)}
{placeholder}
use:shortcut={{
shortcut: { key: 'Enter', ctrl: true },

View file

@ -16,6 +16,16 @@ describe('ChangeDate component', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.stubGlobal('visualViewport', {
height: window.innerHeight,
width: window.innerWidth,
scale: 1,
offsetLeft: 0,
offsetTop: 0,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
});
afterEach(() => {

View file

@ -1,14 +1,18 @@
<script lang="ts">
import { DateTime } from 'luxon';
import ConfirmDialog from './dialog/confirm-dialog.svelte';
import Combobox from './combobox.svelte';
import Combobox, { type ComboBoxOption } from './combobox.svelte';
import DateInput from '../elements/date-input.svelte';
import { t } from 'svelte-i18n';
export let initialDate: DateTime = DateTime.now();
export let initialTimeZone: string = '';
export let onCancel: () => void;
export let onConfirm: (date: string) => void;
interface Props {
initialDate?: DateTime;
initialTimeZone?: string;
onCancel: () => void;
onConfirm: (date: string) => void;
}
let { initialDate = DateTime.now(), initialTimeZone = '', onCancel, onConfirm }: Props = $props();
type ZoneOption = {
/**
@ -49,21 +53,15 @@
const knownTimezones = Intl.supportedValuesOf('timeZone');
let timezones: ZoneOption[];
$: timezones = knownTimezones
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm"));
let timezones: ZoneOption[] = knownTimezones
.map((zone) => zoneOptionForDate(zone, selectedDate))
.filter((zone) => zone.valid)
.sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB));
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption: ZoneOption | undefined;
$: selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, selectedOption);
let selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm");
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
$: date = DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true });
let selectedOption: ZoneOption | undefined = $state(getPreferredTimeZone(initialDate, userTimeZone, timezones));
function zoneOptionForDate(zone: string, date: string) {
const dateAtZone: DateTime = DateTime.fromISO(date, { zone });
@ -125,6 +123,14 @@
onConfirm(value);
}
};
const handleOnSelect = (option?: ComboBoxOption) => {
if (option) {
selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, option as ZoneOption);
}
};
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
let date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<ConfirmDialog
@ -135,13 +141,23 @@
onConfirm={handleConfirm}
{onCancel}
>
<div class="flex flex-col text-left gap-2" slot="prompt">
<div class="flex flex-col">
<label for="datetime">{$t('date_and_time')}</label>
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
<!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component -->
<!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component -->
{#snippet promptSnippet()}
<div class="flex flex-col text-left gap-2">
<div class="flex flex-col">
<label for="datetime">{$t('date_and_time')}</label>
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
</div>
<div>
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => handleOnSelect(option)}
/>
</div>
</div>
<div>
<Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />
</div>
</div>
{/snippet}
</ConfirmDialog>

View file

@ -12,39 +12,44 @@
import { listNavigation } from '$lib/actions/list-navigation';
import { t } from 'svelte-i18n';
import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
import Map from '$lib/components/shared-components/map/map.svelte';
interface Point {
lng: number;
lat: number;
}
export let asset: AssetResponseDto | undefined = undefined;
export let onCancel: () => void;
export let onConfirm: (point: Point) => void;
interface Props {
asset?: AssetResponseDto | undefined;
onCancel: () => void;
onConfirm: (point: Point) => void;
}
let places: PlacesResponseDto[] = [];
let suggestedPlaces: PlacesResponseDto[] = [];
let searchWord: string;
let { asset = undefined, onCancel, onConfirm }: Props = $props();
let places: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $state([]);
let searchWord: string = $state('');
let latestSearchTimeout: number;
let showLoadingSpinner = false;
let suggestionContainer: HTMLDivElement;
let hideSuggestion = false;
let addClipMapMarker: (long: number, lat: number) => void;
let showLoadingSpinner = $state(false);
let suggestionContainer: HTMLDivElement | undefined = $state();
let hideSuggestion = $state(false);
let mapElement = $state<ReturnType<typeof Map>>();
$: lat = asset?.exifInfo?.latitude ?? undefined;
$: lng = asset?.exifInfo?.longitude ?? undefined;
$: zoom = lat !== undefined && lng !== undefined ? 12.5 : 1;
let lat = $derived(asset?.exifInfo?.latitude ?? undefined);
let lng = $derived(asset?.exifInfo?.longitude ?? undefined);
let zoom = $derived(lat !== undefined && lng !== undefined ? 12.5 : 1);
$: {
$effect(() => {
if (places) {
suggestedPlaces = places.slice(0, 5);
}
if (searchWord === '') {
suggestedPlaces = [];
}
}
});
let point: Point | null = null;
let point: Point | null = $state(null);
const handleConfirm = () => {
if (point) {
@ -94,88 +99,95 @@
const handleUseSuggested = (latitude: number, longitude: number) => {
hideSuggestion = true;
point = { lng: longitude, lat: latitude };
addClipMapMarker(longitude, latitude);
mapElement?.addClipMapMarker(longitude, latitude);
};
</script>
<ConfirmDialog confirmColor="primary" title={$t('change_location')} width="wide" onConfirm={handleConfirm} {onCancel}>
<div slot="prompt" class="flex flex-col w-full h-full gap-2">
<div
class="relative w-64 sm:w-96"
use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }}
use:listNavigation={suggestionContainer}
>
<button type="button" class="w-full" on:click={() => (hideSuggestion = false)}>
<SearchBar
placeholder={$t('search_places')}
bind:name={searchWord}
{showLoadingSpinner}
onReset={() => (suggestedPlaces = [])}
onSearch={handleSearchPlaces}
roundedBottom={suggestedPlaces.length === 0 || hideSuggestion}
/>
</button>
<div class="absolute z-[99] w-full" id="suggestion" bind:this={suggestionContainer}>
{#if !hideSuggestion}
{#each suggestedPlaces as place, index}
<button
type="button"
class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
suggestedPlaces.length - 1
? 'rounded-b-lg border-b'
: ''}"
on:click={() => handleUseSuggested(place.latitude, place.longitude)}
>
<p class="ml-4 text-sm text-gray-700 dark:text-gray-100 truncate">
{getLocation(place.name, place.admin1name, place.admin2name)}
</p>
{#snippet promptSnippet()}
<div class="flex flex-col w-full h-full gap-2">
<div class="relative w-64 sm:w-96">
{#if suggestionContainer}
<div
use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }}
use:listNavigation={suggestionContainer}
>
<button type="button" class="w-full" onclick={() => (hideSuggestion = false)}>
<SearchBar
placeholder={$t('search_places')}
bind:name={searchWord}
{showLoadingSpinner}
onReset={() => (suggestedPlaces = [])}
onSearch={handleSearchPlaces}
roundedBottom={suggestedPlaces.length === 0 || hideSuggestion}
/>
</button>
{/each}
</div>
{/if}
<div class="absolute z-[99] w-full" id="suggestion" bind:this={suggestionContainer}>
{#if !hideSuggestion}
{#each suggestedPlaces as place, index}
<button
type="button"
class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
suggestedPlaces.length - 1
? 'rounded-b-lg border-b'
: ''}"
onclick={() => handleUseSuggested(place.latitude, place.longitude)}
>
<p class="ml-4 text-sm text-gray-700 dark:text-gray-100 truncate">
{getLocation(place.name, place.admin1name, place.admin2name)}
</p>
</button>
{/each}
{/if}
</div>
</div>
<span>{$t('pick_a_location')}</span>
<div class="h-[500px] min-h-[300px] w-full">
{#await import('../shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
{/await}
{:then { default: Map }}
<Map
bind:this={mapElement}
mapMarkers={lat !== undefined && lng !== undefined && asset
? [
{
id: asset.id,
lat,
lon: lng,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
},
]
: []}
{zoom}
center={lat && lng ? { lat, lng } : undefined}
simplified={true}
clickable={true}
onClickPoint={(selected) => (point = selected)}
/>
{/await}
</div>
<div class="grid sm:grid-cols-2 gap-4 text-sm text-left mt-4">
<CoordinatesInput
lat={point ? point.lat : lat}
lng={point ? point.lng : lng}
onUpdate={(lat, lng) => {
point = { lat, lng };
mapElement?.addClipMapMarker(lng, lat);
}}
/>
</div>
</div>
<span>{$t('pick_a_location')}</span>
<div class="h-[500px] min-h-[300px] w-full">
{#await import('../shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
{/await}
{:then { default: Map }}
<Map
mapMarkers={lat !== undefined && lng !== undefined && asset
? [
{
id: asset.id,
lat,
lon: lng,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
},
]
: []}
{zoom}
bind:addClipMapMarker
center={lat && lng ? { lat, lng } : undefined}
simplified={true}
clickable={true}
onClickPoint={(selected) => (point = selected)}
/>
{/await}
</div>
<div class="grid sm:grid-cols-2 gap-4 text-sm text-left mt-4">
<CoordinatesInput
lat={point ? point.lat : lat}
lng={point ? point.lng : lng}
onUpdate={(lat, lng) => {
point = { lat, lng };
addClipMapMarker(lng, lat);
}}
/>
</div>
</div>
{/snippet}
</ConfirmDialog>

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export type ComboBoxOption = {
id?: string;
label: string;
@ -30,12 +30,23 @@
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
export let label: string;
export let hideLabel = false;
export let options: ComboBoxOption[] = [];
export let selectedOption: ComboBoxOption | undefined = undefined;
export let placeholder = '';
export let onSelect: (option: ComboBoxOption | undefined) => void = () => {};
interface Props {
label: string;
hideLabel?: boolean;
options?: ComboBoxOption[];
selectedOption?: ComboBoxOption | undefined;
placeholder?: string;
onSelect?: (option: ComboBoxOption | undefined) => void;
}
let {
label,
hideLabel = false,
options = [],
selectedOption = $bindable(),
placeholder = '',
onSelect = () => {},
}: Props = $props();
/**
* Unique identifier for the combobox.
@ -44,17 +55,16 @@
/**
* Indicates whether or not the dropdown autocomplete list should be visible.
*/
let isOpen = false;
let isOpen = $state(false);
/**
* Keeps track of whether the combobox is actively being used.
*/
let isActive = false;
let searchQuery = selectedOption?.label || '';
let selectedIndex: number | undefined;
let optionRefs: HTMLElement[] = [];
let input: HTMLInputElement;
let bounds: DOMRect | undefined;
let dropdownDirection: 'bottom' | 'top' = 'bottom';
let isActive = $state(false);
let searchQuery = $state(selectedOption?.label || '');
let selectedIndex: number | undefined = $state();
let optionRefs: HTMLElement[] = $state([]);
let input = $state<HTMLInputElement>();
let bounds: DOMRect | undefined = $state();
const inputId = `combobox-${id}`;
const listboxId = `listbox-${id}`;
@ -76,17 +86,12 @@
{ threshold: 0.5 },
);
$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
$: {
searchQuery = selectedOption ? selectedOption.label : '';
}
$: position = calculatePosition(bounds);
onMount(() => {
if (!input) {
return;
}
observer.observe(input);
const scrollableAncestor = input.closest('.overflow-y-auto, .overflow-y-scroll');
const scrollableAncestor = input?.closest('.overflow-y-auto, .overflow-y-scroll');
scrollableAncestor?.addEventListener('scroll', onPositionChange);
window.visualViewport?.addEventListener('resize', onPositionChange);
window.visualViewport?.addEventListener('scroll', onPositionChange);
@ -157,7 +162,6 @@
const calculatePosition = (boundary: DOMRect | undefined) => {
const visualViewport = window.visualViewport;
dropdownDirection = getComboboxDirection(boundary, visualViewport);
if (!boundary) {
return;
@ -212,9 +216,19 @@
};
const getInputPosition = () => input?.getBoundingClientRect();
$effect(() => {
// searchQuery = selectedOption ? selectedOption.label : '';
});
let filteredOptions = $derived(
options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())),
);
let position = $derived(calculatePosition(bounds));
let dropdownDirection: 'bottom' | 'top' = $derived(getComboboxDirection(bounds, visualViewport));
</script>
<svelte:window on:resize={onPositionChange} />
<svelte:window onresize={onPositionChange} />
<label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label>
<div
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
@ -252,9 +266,9 @@
class:cursor-pointer={!isActive}
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
id={inputId}
on:click={activate}
on:focus={activate}
on:input={onInput}
onclick={activate}
onfocus={activate}
oninput={onInput}
role="combobox"
type="text"
value={searchQuery}
@ -304,7 +318,7 @@
class:pointer-events-none={!selectedOption}
>
{#if selectedOption}
<CircleIconButton on:click={onClear} title={$t('clear_value')} icon={mdiClose} size="16" padding="2" />
<CircleIconButton onclick={onClear} title={$t('clear_value')} icon={mdiClose} size="16" padding="2" />
{:else if !isOpen}
<Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} />
{/if}
@ -329,26 +343,26 @@
>
{#if isOpen}
{#if filteredOptions.length === 0}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
role="option"
aria-selected={selectedIndex === 0}
aria-disabled={true}
class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700"
id={`${listboxId}-${0}`}
on:click={() => closeDropdown()}
onclick={() => closeDropdown()}
>
{$t('no_results')}
</li>
{/if}
{#each filteredOptions as option, index (option.id || option.label)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
aria-selected={index === selectedIndex}
bind:this={optionRefs[index]}
class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
id={`${listboxId}-${index}`}
on:click={() => handleSelect(option)}
onclick={() => handleSelect(option)}
role="option"
>
{option.label}

View file

@ -14,41 +14,52 @@
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import { clickOutside } from '$lib/actions/click-outside';
import { shortcuts } from '$lib/actions/shortcut';
import type { Snippet } from 'svelte';
export let icon: string;
export let title: string;
/**
* The alignment of the context menu relative to the button.
*/
export let align: Align = 'top-left';
/**
* The direction in which the context menu should open.
*/
export let direction: 'left' | 'right' = 'right';
export let color: Color = 'transparent';
export let size: string | undefined = undefined;
export let padding: Padding | undefined = undefined;
/**
* Additional classes to apply to the button.
*/
export let buttonClass: string | undefined = undefined;
export let hideContent = false;
interface Props {
icon: string;
title: string;
/**
* The alignment of the context menu relative to the button.
*/
align?: Align;
/**
* The direction in which the context menu should open.
*/
direction?: 'left' | 'right';
color?: Color;
size?: string | undefined;
padding?: Padding | undefined;
/**
* Additional classes to apply to the button.
*/
buttonClass?: string | undefined;
hideContent?: boolean;
children?: Snippet;
}
let isOpen = false;
let contextMenuPosition = { x: 0, y: 0 };
let menuContainer: HTMLUListElement;
let buttonContainer: HTMLDivElement;
let {
icon,
title,
align = 'top-left',
direction = 'right',
color = 'transparent',
size = undefined,
padding = undefined,
buttonClass = undefined,
hideContent = false,
children,
}: Props = $props();
let isOpen = $state(false);
let contextMenuPosition = $state({ x: 0, y: 0 });
let menuContainer: HTMLUListElement | undefined = $state();
let buttonContainer: HTMLDivElement | undefined = $state();
const id = generateId();
const buttonId = `context-menu-button-${id}`;
const menuId = `context-menu-${id}`;
$: {
if (isOpen) {
$optionClickCallbackStore = handleOptionClick;
}
}
const openDropdown = (event: KeyboardEvent | MouseEvent) => {
contextMenuPosition = getContextMenuPositionFromEvent(event, align);
isOpen = true;
@ -72,9 +83,10 @@
};
const onResize = () => {
if (!isOpen) {
if (!isOpen || !buttonContainer) {
return;
}
contextMenuPosition = getContextMenuPositionFromBoundingRect(buttonContainer.getBoundingClientRect(), align);
};
@ -92,12 +104,19 @@
};
const focusButton = () => {
const button: HTMLButtonElement | null = buttonContainer.querySelector(`#${buttonId}`);
const button = buttonContainer?.querySelector(`#${buttonId}`) as HTMLButtonElement | null;
button?.focus();
};
$effect(() => {
if (isOpen) {
$optionClickCallbackStore = handleOptionClick;
}
});
</script>
<svelte:window on:resize={onResize} />
<svelte:window onresize={onResize} />
<div
use:contextMenuNavigation={{
closeDropdown,
@ -109,7 +128,7 @@
selectionChanged: (id) => ($selectedIdStore = id),
}}
use:clickOutside={{ onOutclick: closeDropdown }}
on:resize={onResize}
onresize={onResize}
>
<div bind:this={buttonContainer}>
<CircleIconButton
@ -123,7 +142,7 @@
aria-haspopup={true}
class={buttonClass}
id={buttonId}
on:click={handleClick}
onclick={handleClick}
/>
</div>
{#if isOpen || !hideContent}
@ -150,7 +169,7 @@
id={menuId}
isVisible={isOpen}
>
<slot />
{@render children?.()}
</ContextMenu>
</div>
{/if}

View file

@ -2,27 +2,44 @@
import { quintOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import { clickOutside } from '$lib/actions/click-outside';
import type { Snippet } from 'svelte';
export let isVisible: boolean = false;
export let direction: 'left' | 'right' = 'right';
export let x = 0;
export let y = 0;
export let id: string | undefined = undefined;
export let ariaLabel: string | undefined = undefined;
export let ariaLabelledBy: string | undefined = undefined;
export let ariaActiveDescendant: string | undefined = undefined;
interface Props {
isVisible?: boolean;
direction?: 'left' | 'right';
x?: number;
y?: number;
id?: string | undefined;
ariaLabel?: string | undefined;
ariaLabelledBy?: string | undefined;
ariaActiveDescendant?: string | undefined;
menuElement?: HTMLUListElement | undefined;
onClose?: (() => void) | undefined;
children?: Snippet;
}
export let menuElement: HTMLUListElement | undefined = undefined;
export let onClose: (() => void) | undefined = undefined;
let {
isVisible = false,
direction = 'right',
x = 0,
y = 0,
id = undefined,
ariaLabel = undefined,
ariaLabelledBy = undefined,
ariaActiveDescendant = undefined,
menuElement = $bindable(),
onClose = undefined,
children,
}: Props = $props();
let left: number;
let top: number;
let left: number = $state(0);
let top: number = $state(0);
// We need to bind clientHeight since the bounding box may return a height
// of zero when starting the 'slide' animation.
let height: number;
let height: number = $state(0);
$: {
$effect(() => {
if (menuElement) {
const rect = menuElement.getBoundingClientRect();
const directionWidth = direction === 'left' ? rect.width : 0;
@ -31,7 +48,7 @@
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
top = Math.min(window.innerHeight - menuHeight, y);
}
}
});
</script>
<div
@ -54,6 +71,6 @@
role="menu"
tabindex="-1"
>
<slot />
{@render children?.()}
</ul>
</div>

View file

@ -3,16 +3,27 @@
import { generateId } from '$lib/utils/generate-id';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
export let text: string;
export let subtitle = '';
export let icon = '';
export let activeColor = 'bg-slate-300';
export let textColor = 'text-immich-fg dark:text-immich-dark-bg';
export let onClick: () => void;
interface Props {
text: string;
subtitle?: string;
icon?: string;
activeColor?: string;
textColor?: string;
onClick: () => void;
}
let {
text,
subtitle = '',
icon = '',
activeColor = 'bg-slate-300',
textColor = 'text-immich-fg dark:text-immich-dark-bg',
onClick,
}: Props = $props();
let id: string = generateId();
$: isActive = $selectedIdStore === id;
let isActive = $derived($selectedIdStore === id);
const handleClick = () => {
$optionClickCallbackStore?.();
@ -20,13 +31,13 @@
};
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<li
{id}
on:click={handleClick}
on:mouseover={() => ($selectedIdStore = id)}
on:mouseleave={() => ($selectedIdStore = undefined)}
onclick={handleClick}
onmouseover={() => ($selectedIdStore = id)}
onmouseleave={() => ($selectedIdStore = undefined)}
class="w-full p-4 text-left text-sm font-medium {textColor} focus:outline-none focus:ring-2 focus:ring-inset cursor-pointer border-gray-200 flex gap-2 items-center {isActive
? activeColor
: 'bg-slate-100'}"

View file

@ -1,33 +1,30 @@
<script lang="ts">
import { tick } from 'svelte';
import { tick, type Snippet } from 'svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { shortcuts } from '$lib/actions/shortcut';
import { generateId } from '$lib/utils/generate-id';
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
export let title: string;
export let direction: 'left' | 'right' = 'right';
export let x = 0;
export let y = 0;
export let isOpen = false;
export let onClose: (() => unknown) | undefined;
interface Props {
title: string;
direction?: 'left' | 'right';
x?: number;
y?: number;
isOpen?: boolean;
onClose: (() => unknown) | undefined;
children?: Snippet;
}
let uniqueKey = {};
let menuContainer: HTMLUListElement;
let triggerElement: HTMLElement | undefined = undefined;
let { title, direction = 'right', x = 0, y = 0, isOpen = false, onClose, children }: Props = $props();
let uniqueKey = $state({});
let menuContainer: HTMLUListElement | undefined = $state();
let triggerElement: HTMLElement | undefined = $state(undefined);
const id = generateId();
const menuId = `context-menu-${id}`;
$: {
if (isOpen && menuContainer) {
triggerElement = document.activeElement as HTMLElement;
menuContainer.focus();
$optionClickCallbackStore = closeContextMenu;
}
}
const reopenContextMenu = async (event: MouseEvent) => {
const contextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
@ -39,7 +36,7 @@
const elements = document.elementsFromPoint(event.x, event.y);
if (elements.includes(menuContainer)) {
if (menuContainer && elements.includes(menuContainer)) {
// User right-clicked on the context menu itself, we keep the context
// menu as is
return;
@ -58,6 +55,18 @@
triggerElement?.focus();
onClose?.();
};
$effect(() => {
if (isOpen && menuContainer) {
triggerElement = document.activeElement as HTMLElement;
menuContainer.focus();
$optionClickCallbackStore = closeContextMenu;
}
});
const oncontextmenu = async (event: MouseEvent) => {
event.preventDefault();
await reopenContextMenu(event);
};
</script>
{#key uniqueKey}
@ -81,11 +90,7 @@
},
]}
>
<section
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
on:contextmenu|preventDefault={reopenContextMenu}
role="presentation"
>
<section class="fixed left-0 top-0 z-10 flex h-screen w-screen" {oncontextmenu} role="presentation">
<ContextMenu
{direction}
{x}
@ -97,7 +102,7 @@
isVisible
onClose={closeContextMenu}
>
<slot />
{@render children?.()}
</ContextMenu>
</section>
</div>

View file

@ -1,23 +1,39 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, type Snippet } from 'svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { fly } from 'svelte/transition';
import { mdiClose } from '@mdi/js';
import { isSelectingAllAssets } from '$lib/stores/assets.store';
import { t } from 'svelte-i18n';
export let showBackButton = true;
export let backIcon = mdiClose;
export let tailwindClasses = '';
export let forceDark = false;
export let onClose: () => void = () => {};
interface Props {
showBackButton?: boolean;
backIcon?: string;
tailwindClasses?: string;
forceDark?: boolean;
onClose?: () => void;
leading?: Snippet;
children?: Snippet;
trailing?: Snippet;
}
let appBarBorder = 'bg-immich-bg border border-transparent';
let {
showBackButton = true,
backIcon = mdiClose,
tailwindClasses = '',
forceDark = false,
onClose = () => {},
leading,
children,
trailing,
}: Props = $props();
let appBarBorder = $state('bg-immich-bg border border-transparent');
const onScroll = () => {
if (window.pageYOffset > 80) {
if (window.scrollY > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
if (forceDark) {
@ -45,7 +61,7 @@
}
});
$: buttonClass = forceDark ? 'hover:text-immich-dark-gray' : undefined;
let buttonClass = $derived(forceDark ? 'hover:text-immich-dark-gray' : undefined);
</script>
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
@ -57,17 +73,17 @@
>
<div class="flex place-items-center sm:gap-6 justify-self-start dark:text-immich-dark-fg">
{#if showBackButton}
<CircleIconButton title={$t('close')} on:click={handleClose} icon={backIcon} size={'24'} class={buttonClass} />
<CircleIconButton title={$t('close')} onclick={handleClose} icon={backIcon} size={'24'} class={buttonClass} />
{/if}
<slot name="leading" />
{@render leading?.()}
</div>
<div class="w-full">
<slot />
{@render children?.()}
</div>
<div class="mr-4 flex place-items-center gap-1 justify-self-end">
<slot name="trailing" />
{@render trailing?.()}
</div>
</div>
</div>

View file

@ -3,9 +3,13 @@
import { generateId } from '$lib/utils/generate-id';
import { t } from 'svelte-i18n';
export let lat: number | null | undefined = undefined;
export let lng: number | null | undefined = undefined;
export let onUpdate: (lat: number, lng: number) => void;
interface Props {
lat?: number;
lng?: number;
onUpdate: (lat: number, lng: number) => void;
}
let { lat = $bindable(), lng = $bindable(), onUpdate }: Props = $props();
const id = generateId();

View file

@ -8,29 +8,40 @@
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
import { mdiContentCopy, mdiLink } from '@mdi/js';
import { NotificationType, notificationController } from '../notification/notification';
import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte';
import SettingInputField from '../settings/setting-input-field.svelte';
import SettingSwitch from '../settings/setting-switch.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { t } from 'svelte-i18n';
import { locale } from '$lib/stores/preferences.store';
import { DateTime, Duration } from 'luxon';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { SettingInputFieldType } from '$lib/constants';
export let onClose: () => void;
export let albumId: string | undefined = undefined;
export let assetIds: string[] = [];
export let editingLink: SharedLinkResponseDto | undefined = undefined;
export let onCreated: () => void = () => {};
interface Props {
onClose: () => void;
albumId?: string | undefined;
assetIds?: string[];
editingLink?: SharedLinkResponseDto | undefined;
onCreated?: () => void;
}
let sharedLink: string | null = null;
let description = '';
let allowDownload = true;
let allowUpload = false;
let showMetadata = true;
let expirationOption: number = 0;
let password = '';
let shouldChangeExpirationTime = false;
let enablePassword = false;
let {
onClose,
albumId = $bindable(undefined),
assetIds = $bindable([]),
editingLink = undefined,
onCreated = () => {},
}: Props = $props();
let sharedLink: string | null = $state(null);
let description = $state('');
let allowDownload = $state(true);
let allowUpload = $state(false);
let showMetadata = $state(true);
let expirationOption: number = $state(0);
let password = $state('');
let shouldChangeExpirationTime = $state(false);
let enablePassword = $state(false);
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
[30, 'minutes'],
@ -43,22 +54,23 @@
[1, 'year'],
];
$: relativeTime = new Intl.RelativeTimeFormat($locale);
$: expiredDateOptions = [
let relativeTime = $derived(new Intl.RelativeTimeFormat($locale));
let expiredDateOptions = $derived([
{ text: $t('never'), value: 0 },
...expirationOptions.map(([value, unit]) => ({
text: relativeTime.format(value, unit),
value: Duration.fromObject({ [unit]: value }).toMillis(),
})),
];
]);
// svelte-ignore reactive_declaration_non_reactive_property
$: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual;
$: {
let shareType = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
$effect(() => {
if (!showMetadata) {
allowDownload = false;
}
}
});
if (editingLink) {
if (editingLink.description) {
description = editingLink.description;
@ -223,22 +235,22 @@
</div>
</section>
<svelte:fragment slot="sticky-bottom">
{#snippet stickyBottom()}
{#if !sharedLink}
{#if editingLink}
<Button size="sm" fullwidth on:click={handleEditLink}>{$t('confirm')}</Button>
<Button size="sm" fullwidth onclick={handleEditLink}>{$t('confirm')}</Button>
{:else}
<Button size="sm" fullwidth on:click={handleCreateSharedLink}>{$t('create_link')}</Button>
<Button size="sm" fullwidth onclick={handleCreateSharedLink}>{$t('create_link')}</Button>
{/if}
{:else}
<div class="flex w-full gap-2">
<input class="immich-form-input w-full" bind:value={sharedLink} disabled />
<LinkButton on:click={() => (sharedLink ? copyToClipboard(sharedLink) : '')}>
<LinkButton onclick={() => (sharedLink ? copyToClipboard(sharedLink) : '')}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiContentCopy} ariaLabel={$t('copy_link_to_clipboard')} size="18" />
</div>
</LinkButton>
</div>
{/if}
</svelte:fragment>
{/snippet}
</FullScreenModal>

View file

@ -3,18 +3,37 @@
import Button from '../../elements/buttons/button.svelte';
import type { Color } from '$lib/components/elements/buttons/button.svelte';
import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte';
export let title = $t('confirm');
export let prompt = $t('are_you_sure_to_do_this');
export let confirmText = $t('confirm');
export let confirmColor: Color = 'red';
export let cancelText = $t('cancel');
export let cancelColor: Color = 'secondary';
export let hideCancelButton = false;
export let disabled = false;
export let width: 'wide' | 'narrow' = 'narrow';
export let onCancel: () => void;
export let onConfirm: () => void;
interface Props {
title?: string;
prompt?: string;
confirmText?: string;
confirmColor?: Color;
cancelText?: string;
cancelColor?: Color;
hideCancelButton?: boolean;
disabled?: boolean;
width?: 'wide' | 'narrow';
onCancel: () => void;
onConfirm: () => void;
promptSnippet?: Snippet;
}
let {
title = $t('confirm'),
prompt = $t('are_you_sure_to_do_this'),
confirmText = $t('confirm'),
confirmColor = 'red',
cancelText = $t('cancel'),
cancelColor = 'secondary',
hideCancelButton = false,
disabled = false,
width = 'narrow',
onCancel,
onConfirm,
promptSnippet,
}: Props = $props();
const handleConfirm = () => {
onConfirm();
@ -23,19 +42,19 @@
<FullScreenModal {title} onClose={onCancel} {width}>
<div class="text-md py-5 text-center">
<slot name="prompt">
{#if promptSnippet}{@render promptSnippet()}{:else}
<p>{prompt}</p>
</slot>
{/if}
</div>
<svelte:fragment slot="sticky-bottom">
{#snippet stickyBottom()}
{#if !hideCancelButton}
<Button color={cancelColor} fullwidth on:click={onCancel}>
<Button color={cancelColor} fullwidth onclick={onCancel}>
{cancelText}
</Button>
{/if}
<Button color={confirmColor} fullwidth on:click={handleConfirm} {disabled}>
<Button color={confirmColor} fullwidth onclick={handleConfirm} {disabled}>
{confirmText}
</Button>
</svelte:fragment>
{/snippet}
</FullScreenModal>

View file

@ -8,10 +8,10 @@
import { fade } from 'svelte/transition';
import ImmichLogo from './immich-logo.svelte';
$: albumId = isAlbumsRoute($page.route?.id) ? $page.params.albumId : undefined;
$: isShare = isSharedLinkRoute($page.route?.id);
let albumId = $derived(isAlbumsRoute($page.route?.id) ? $page.params.albumId : undefined);
let isShare = $derived(isSharedLinkRoute($page.route?.id));
let dragStartTarget: EventTarget | null = null;
let dragStartTarget: EventTarget | null = $state(null);
const onDragEnter = (e: DragEvent) => {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
@ -117,26 +117,41 @@
await fileUploadHandler(filesArray, albumId);
}
};
const ondragenter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
onDragEnter(e);
};
const ondragleave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
onDragLeave(e);
};
const ondrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
await onDrop(e);
};
const onDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
</script>
<svelte:window on:paste={onPaste} />
<svelte:window onpaste={onPaste} />
<svelte:body
on:dragenter|stopPropagation|preventDefault={onDragEnter}
on:dragleave|stopPropagation|preventDefault={onDragLeave}
on:drop|stopPropagation|preventDefault={onDrop}
/>
<svelte:body {ondragenter} {ondragleave} {ondrop} />
{#if dragStartTarget}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[1000] flex h-full w-full flex-col items-center justify-center bg-gray-100/90 text-immich-dark-gray dark:bg-immich-dark-bg/90 dark:text-immich-gray"
transition:fade={{ duration: 250 }}
on:dragover={(e) => {
// Prevent browser from opening the dropped file.
e.stopPropagation();
e.preventDefault();
}}
ondragover={onDragOver}
>
<ImmichLogo noText class="m-16 w-48 animate-bounce" />
<div class="text-2xl">{$t('drop_files_to_upload')}</div>

View file

@ -1,22 +1,26 @@
<script lang="ts">
import empty1Url from '$lib/assets/empty-1.svg';
export let onClick: undefined | (() => unknown) = undefined;
export let text: string;
export let fullWidth = false;
export let src = empty1Url;
interface Props {
onClick?: undefined | (() => unknown);
text: string;
fullWidth?: boolean;
src?: string;
}
$: width = fullWidth ? 'w-full' : 'w-1/2';
let { onClick = undefined, text, fullWidth = false, src = empty1Url }: Props = $props();
let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
const hoverClasses = onClick
? `border dark:border-immich-dark-gray hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25`
: '';
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svelte:element
this={onClick ? 'button' : 'div'}
on:click={onClick}
onclick={onClick}
class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
>
<img {src} alt="" width="500" draggable="false" />

View file

@ -4,36 +4,52 @@
import { fade } from 'svelte/transition';
import ModalHeader from '$lib/components/shared-components/modal-header.svelte';
import { generateId } from '$lib/utils/generate-id';
import type { Snippet } from 'svelte';
export let onClose: () => void;
export let title: string;
/**
* If true, the logo will be displayed next to the modal title.
*/
export let showLogo = false;
/**
* Optional icon to display next to the modal title, if `showLogo` is false.
*/
export let icon: string | undefined = undefined;
/**
* Sets the width of the modal.
*
* - `wide`: 48rem
* - `narrow`: 28rem
* - `auto`: fits the width of the modal content, up to a maximum of 32rem
*/
export let width: 'extra-wide' | 'wide' | 'narrow' | 'auto' = 'narrow';
interface Props {
onClose: () => void;
title: string;
/**
* If true, the logo will be displayed next to the modal title.
*/
showLogo?: boolean;
/**
* Optional icon to display next to the modal title, if `showLogo` is false.
*/
icon?: string | undefined;
/**
* Sets the width of the modal.
*
* - `wide`: 48rem
* - `narrow`: 28rem
* - `auto`: fits the width of the modal content, up to a maximum of 32rem
*/
width?: 'extra-wide' | 'wide' | 'narrow' | 'auto';
stickyBottom?: Snippet;
children?: Snippet;
}
let {
onClose,
title,
showLogo = false,
icon = undefined,
width = 'narrow',
stickyBottom,
children,
}: Props = $props();
/**
* Unique identifier for the modal.
*/
let id: string = generateId();
$: titleId = `${id}-title`;
$: isStickyBottom = !!$$slots['sticky-bottom'];
let titleId = $derived(`${id}-title`);
let isStickyBottom = $derived(!!stickyBottom);
let modalWidth: string;
$: {
let modalWidth = $state<string>();
$effect(() => {
switch (width) {
case 'extra-wide': {
modalWidth = 'w-[56rem]';
@ -54,7 +70,7 @@
modalWidth = 'sm:max-w-4xl';
}
}
}
});
</script>
<section
@ -62,7 +78,7 @@
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
class="fixed left-0 top-0 z-[9999] flex h-dvh w-screen place-content-center place-items-center bg-black/40"
on:keydown={(event) => {
onkeydown={(event) => {
event.stopPropagation();
}}
use:focusTrap
@ -77,14 +93,14 @@
<div class="immich-scrollbar overflow-y-auto pt-1" class:pb-4={isStickyBottom}>
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
<div class="px-5 pt-0 mb-5">
<slot />
{@render children?.()}
</div>
</div>
{#if isStickyBottom}
<div
class="flex flex-col sm:flex-row justify-end w-full gap-2 sm:gap-4 sticky pt-4 px-5 bg-immich-bg dark:bg-immich-dark-gray border-t border-gray-200 dark:border-gray-500"
>
<slot name="sticky-bottom" />
{@render stickyBottom?.()}
</div>
{/if}
</div>

View file

@ -1,8 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import ImmichLogo from './immich-logo.svelte';
export let title: string;
export let showMessage = $$slots.message;
interface Props {
title: string;
message?: Snippet;
showMessage?: boolean;
children?: Snippet;
}
let { title, message, showMessage = message != undefined, children }: Props = $props();
</script>
<section class="min-w-screen flex min-h-screen place-content-center place-items-center p-4">
@ -20,10 +27,10 @@
<div
class="w-full rounded-xl border-2 border-immich-primary bg-immich-primary/5 p-4 text-sm font-medium text-immich-primary dark:border-immich-dark-bg dark:text-immich-dark-primary"
>
<slot name="message" />
{@render message?.()}
</div>
{/if}
<slot />
{@render children?.()}
</div>
</section>

View file

@ -17,20 +17,34 @@
import Portal from '../portal/portal.svelte';
import { handlePromiseError } from '$lib/utils';
export let assets: AssetResponseDto[];
export let selectedAssets: Set<AssetResponseDto> = new Set();
export let disableAssetSelect = false;
export let showArchiveIcon = false;
export let viewport: Viewport;
export let onIntersected: (() => void) | undefined = undefined;
export let showAssetName = false;
export let onPrevious: (() => Promise<AssetResponseDto | undefined>) | undefined = undefined;
export let onNext: (() => Promise<AssetResponseDto | undefined>) | undefined = undefined;
interface Props {
assets: AssetResponseDto[];
selectedAssets?: Set<AssetResponseDto>;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
viewport: Viewport;
onIntersected?: (() => void) | undefined;
showAssetName?: boolean;
onPrevious?: (() => Promise<AssetResponseDto | undefined>) | undefined;
onNext?: (() => Promise<AssetResponseDto | undefined>) | undefined;
}
let {
assets = $bindable(),
selectedAssets = $bindable(new Set()),
disableAssetSelect = false,
showArchiveIcon = false,
viewport,
onIntersected = undefined,
showAssetName = false,
onPrevious = undefined,
onNext = undefined,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
let currentViewAssetIndex = 0;
$: isMultiSelectionMode = selectedAssets.size > 0;
let isMultiSelectionMode = $derived(selectedAssets.size > 0);
const viewAssetHandler = async (asset: AssetResponseDto) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
@ -100,23 +114,25 @@
$isViewerOpen = false;
});
$: geometry = (() => {
const justifiedLayoutResult = justifiedLayout(
assets.map((asset) => getAssetRatio(asset)),
{
boxSpacing: 2,
containerWidth: Math.floor(viewport.width),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
},
);
let geometry = $derived(
(() => {
const justifiedLayoutResult = justifiedLayout(
assets.map((asset) => getAssetRatio(asset)),
{
boxSpacing: 2,
containerWidth: Math.floor(viewport.width),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
},
);
return {
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
};
})();
return {
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
};
})(),
);
</script>
{#if assets.length > 0}

View file

@ -7,9 +7,12 @@
import { mdiBugOutline, mdiFaceAgent, mdiGit, mdiGithub, mdiInformationOutline } from '@mdi/js';
import { discordPath } from '$lib/assets/svg-paths';
export let onClose: () => void;
interface Props {
onClose: () => void;
info: ServerAboutResponseDto;
}
export let info: ServerAboutResponseDto;
let { onClose, info }: Props = $props();
</script>
<Portal>

View file

@ -1,7 +1,11 @@
<script lang="ts">
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
export let width: number;
interface Props {
width: number;
}
let { width }: Props = $props();
</script>
<a data-sveltekit-preload-data="hover" class="ml-4" href="/">

View file

@ -9,14 +9,12 @@
import type { HTMLImgAttributes } from 'svelte/elements';
import { t } from 'svelte-i18n';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface $$Props extends HTMLImgAttributes {
interface Props extends HTMLImgAttributes {
noText?: boolean;
draggable?: boolean;
}
export let noText = false;
export let draggable = false;
let { noText = false, draggable = false, ...rest }: Props = $props();
const today = DateTime.now().toLocal();
</script>
@ -28,6 +26,6 @@
src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl}
alt={$t('immich_logo')}
{draggable}
{...$$restProps}
{...rest}
/>
{/if}

View file

@ -1,5 +1,9 @@
<script lang="ts">
export let size: string = '24';
interface Props {
size?: string;
}
let { size = '24' }: Props = $props();
</script>
<div>

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
void maplibregl.setRTLTextPlugin(mapboxRtlUrl, true);
</script>
@ -6,12 +6,13 @@
import Icon from '$lib/components/elements/icon.svelte';
import { Theme } from '$lib/constants';
import { colorTheme, mapSettings } from '$lib/stores/preferences.store';
import { serverConfig } from '$lib/stores/server-config.store';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { getServerConfig, type MapMarkerResponseDto } from '@immich/sdk';
import { type MapMarkerResponseDto } from '@immich/sdk';
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
import type { GeoJSONSource, LngLatLike, StyleSpecification } from 'maplibre-gl';
import type { GeoJSONSource, LngLatLike } from 'maplibre-gl';
import maplibregl from 'maplibre-gl';
import { t } from 'svelte-i18n';
import {
@ -30,14 +31,43 @@
type Map,
} from 'svelte-maplibre';
export let mapMarkers: MapMarkerResponseDto[];
export let showSettingsModal: boolean | undefined = undefined;
export let zoom: number | undefined = undefined;
export let center: LngLatLike | undefined = undefined;
export let hash = false;
export let simplified = false;
export let clickable = false;
export let useLocationPin = false;
interface Props {
mapMarkers: MapMarkerResponseDto[];
showSettingsModal?: boolean | undefined;
zoom?: number | undefined;
center?: LngLatLike | undefined;
hash?: boolean;
simplified?: boolean;
clickable?: boolean;
useLocationPin?: boolean;
onOpenInMapView?: (() => Promise<void> | void) | undefined;
onSelect?: (assetIds: string[]) => void;
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
}
let {
mapMarkers = $bindable(),
showSettingsModal = $bindable(undefined),
zoom = undefined,
center = $bindable(undefined),
hash = false,
simplified = false,
clickable = false,
useLocationPin = false,
onOpenInMapView = undefined,
onSelect = () => {},
onClickPoint = () => {},
popup,
}: Props = $props();
let map: maplibregl.Map | undefined = $state();
let marker: maplibregl.Marker | null = null;
const theme = $derived($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT);
const styleUrl = $derived(theme === Theme.DARK ? $serverConfig.mapDarkStyleUrl : $serverConfig.mapLightStyleUrl);
const style = $derived(fetch(styleUrl).then((response) => response.json()));
export function addClipMapMarker(lng: number, lat: number) {
if (map) {
if (marker) {
@ -46,26 +76,9 @@
center = { lng, lat };
marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map);
map.setZoom(15);
}
}
export let onOpenInMapView: (() => Promise<void> | void) | undefined = undefined;
export let onSelect: (assetIds: string[]) => void = () => {};
export let onClickPoint: ({ lat, lng }: { lat: number; lng: number }) => void = () => {};
let map: maplibregl.Map;
let marker: maplibregl.Marker | null = null;
// svelte-ignore reactive_declaration_non_reactive_property
$: style = (async () => {
const config = await getServerConfig();
const theme = $mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT;
const styleUrl = theme === Theme.DARK ? config.mapDarkStyleUrl : config.mapLightStyleUrl;
const style = await fetch(styleUrl).then((response) => response.json());
return style as StyleSpecification;
})();
function handleAssetClick(assetId: string, map: Map | null) {
if (!map) {
return;
@ -93,7 +106,9 @@
marker.remove();
}
marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map);
if (map) {
marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map);
}
}
}
@ -135,92 +150,96 @@
{zoom}
attributionControl={false}
diffStyleUpdates={true}
let:map
on:load={(event) => event.detail.setMaxZoom(18)}
on:load={(event) => event.detail.on('click', handleMapClick)}
bind:map
>
<NavigationControl position="top-left" showCompass={!simplified} />
{#snippet children({ map }: { map: maplibregl.Map })}
<NavigationControl position="top-left" showCompass={!simplified} />
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{/if}
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{/if}
{#if showSettingsModal !== undefined}
<Control>
<ControlGroup>
<ControlButton on:click={() => (showSettingsModal = true)}><Icon path={mdiCog} size="100%" /></ControlButton>
</ControlGroup>
</Control>
{/if}
{#if showSettingsModal !== undefined}
<Control>
<ControlGroup>
<ControlButton on:click={() => (showSettingsModal = true)}><Icon path={mdiCog} size="100%" /></ControlButton
>
</ControlGroup>
</Control>
{/if}
{#if onOpenInMapView}
<Control position="top-right">
<ControlGroup>
<ControlButton on:click={() => onOpenInMapView()}>
<Icon title={$t('open_in_map_view')} path={mdiMap} size="100%" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
{#if onOpenInMapView}
<Control position="top-right">
<ControlGroup>
<ControlButton on:click={() => onOpenInMapView()}>
<Icon title={$t('open_in_map_view')} path={mdiMap} size="100%" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
<GeoJSON
data={{
type: 'FeatureCollection',
features: mapMarkers.map((marker) => asFeature(marker)),
}}
id="geojson"
cluster={{ radius: 500, maxZoom: 24 }}
>
<MarkerLayer
applyToClusters
asButton
let:feature
on:click={(event) => handlePromiseError(handleClusterClick(event.detail.feature.properties?.cluster_id, map))}
>
<div
class="rounded-full w-[40px] h-[40px] bg-immich-primary text-immich-gray flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
>
{feature.properties?.point_count}
</div>
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
asButton
let:feature
on:click={(event) => {
if (!$$slots.popup) {
handleAssetClick(event.detail.feature.properties?.id, map);
}
<GeoJSON
data={{
type: 'FeatureCollection',
features: mapMarkers.map((marker) => asFeature(marker)),
}}
id="geojson"
cluster={{ radius: 500, maxZoom: 24 }}
>
{#if useLocationPin}
<Icon
path={mdiMapMarker}
size="50px"
class="location-pin dark:text-immich-dark-primary text-immich-primary"
/>
{:else}
<img
src={getAssetThumbnailUrl(feature.properties?.id)}
class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
/>
{/if}
{#if $$slots.popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
<slot name="popup" marker={asMarker(feature)} />
</Popup>
{/if}
</MarkerLayer>
</GeoJSON>
<MarkerLayer
applyToClusters
asButton
on:click={(event) => handlePromiseError(handleClusterClick(event.detail.feature.properties?.cluster_id, map))}
>
{#snippet children({ feature }: { feature: maplibregl.Feature })}
<div
class="rounded-full w-[40px] h-[40px] bg-immich-primary text-immich-gray flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
>
{feature.properties?.point_count}
</div>
{/snippet}
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
asButton
on:click={(event) => {
if (!popup) {
handleAssetClick(event.detail.feature.properties?.id, map);
}
}}
>
{#snippet children({ feature }: { feature: Feature<Geometry, GeoJsonProperties> })}
{#if useLocationPin}
<Icon
path={mdiMapMarker}
size="50px"
class="location-pin dark:text-immich-dark-primary text-immich-primary"
/>
{:else}
<img
src={getAssetThumbnailUrl(feature.properties?.id)}
class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
/>
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
</MarkerLayer>
</GeoJSON>
{/snippet}
</MapLibre>
<style>
.location-pin {

View file

@ -5,20 +5,24 @@
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
/**
* Unique identifier for the header text.
*/
export let id: string;
export let title: string;
export let onClose: () => void;
/**
* If true, the logo will be displayed next to the modal title.
*/
export let showLogo = false;
/**
* Optional icon to display next to the modal title, if `showLogo` is false.
*/
export let icon: string | undefined = undefined;
interface Props {
/**
* Unique identifier for the header text.
*/
id: string;
title: string;
onClose: () => void;
/**
* If true, the logo will be displayed next to the modal title.
*/
showLogo?: boolean;
/**
* Optional icon to display next to the modal title, if `showLogo` is false.
*/
icon?: string;
}
let { id, title, onClose, showLogo = false, icon = undefined }: Props = $props();
</script>
<div class="flex place-items-center justify-between px-5 pb-3">
@ -33,5 +37,5 @@
</h1>
</div>
<CircleIconButton on:click={onClose} icon={mdiClose} size={'20'} title={$t('close')} />
<CircleIconButton onclick={onClose} icon={mdiClose} size={'20'} title={$t('close')} />
</div>

View file

@ -15,10 +15,14 @@
import UserAvatar from '../user-avatar.svelte';
import AvatarSelector from './avatar-selector.svelte';
export let onLogout: () => void;
export let onClose: () => void = () => {};
interface Props {
onLogout: () => void;
onClose?: () => void;
}
let isShowSelectAvatar = false;
let { onLogout, onClose = () => {} }: Props = $props();
let isShowSelectAvatar = $state(false);
const handleSaveProfile = async (color: UserAvatarColor) => {
try {
@ -60,7 +64,7 @@
class="border"
size="12"
padding="2"
on:click={() => (isShowSelectAvatar = true)}
onclick={() => (isShowSelectAvatar = true)}
/>
</div>
</div>
@ -72,7 +76,7 @@
</div>
<div class="flex flex-col gap-1">
<Button href={AppRoute.USER_SETTINGS} on:click={onClose} color="dark-gray" size="sm" shadow={false} border>
<Button href={AppRoute.USER_SETTINGS} onclick={onClose} color="dark-gray" size="sm" shadow={false} border>
<div class="flex place-content-center place-items-center text-center gap-2 px-2">
<Icon path={mdiCog} size="18" ariaHidden />
{$t('account_settings')}
@ -81,7 +85,7 @@
{#if $user.isAdmin}
<Button
href={AppRoute.ADMIN_USER_MANAGEMENT}
on:click={onClose}
onclick={onClose}
color="dark-gray"
size="sm"
shadow={false}
@ -101,7 +105,7 @@
<button
type="button"
class="flex w-full place-content-center place-items-center gap-2 py-3 font-medium text-gray-500 hover:bg-immich-primary/10 dark:text-gray-300"
on:click={onLogout}
onclick={onLogout}
>
<Icon path={mdiLogout} size={24} />
{$t('sign_out')}</button

View file

@ -4,9 +4,13 @@
import FullScreenModal from '../full-screen-modal.svelte';
import UserAvatar from '../user-avatar.svelte';
export let user: UserResponseDto;
export let onClose: () => void;
export let onChoose: (color: UserAvatarColor) => void;
interface Props {
user: UserResponseDto;
onClose: () => void;
onChoose: (color: UserAvatarColor) => void;
}
let { user, onClose, onChoose }: Props = $props();
const colors: UserAvatarColor[] = Object.values(UserAvatarColor);
</script>
@ -15,7 +19,7 @@
<div class="flex items-center justify-center mt-4">
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
{#each colors as color}
<button type="button" on:click={() => onChoose(color)}>
<button type="button" onclick={() => onChoose(color)}>
<UserAvatar label={color} {user} {color} size="xl" showProfileImage={false} />
</button>
{/each}

View file

@ -21,20 +21,24 @@
import HelpAndFeedbackModal from '$lib/components/shared-components/help-and-feedback-modal.svelte';
import { onMount } from 'svelte';
export let showUploadButton = true;
export let onUploadClick: () => void;
interface Props {
showUploadButton?: boolean;
onUploadClick: () => void;
}
let shouldShowAccountInfo = false;
let shouldShowAccountInfoPanel = false;
let shouldShowHelpPanel = false;
let innerWidth: number;
let { showUploadButton = true, onUploadClick }: Props = $props();
let shouldShowAccountInfo = $state(false);
let shouldShowAccountInfoPanel = $state(false);
let shouldShowHelpPanel = $state(false);
let innerWidth: number = $state(0);
const onLogout = async () => {
const { redirectUri } = await logout();
await handleLogout(redirectUri);
};
let aboutInfo: ServerAboutResponseDto;
let aboutInfo: ServerAboutResponseDto | undefined = $state();
onMount(async () => {
aboutInfo = await getAboutInfo();
@ -43,7 +47,7 @@
<svelte:window bind:innerWidth />
{#if shouldShowHelpPanel}
{#if shouldShowHelpPanel && aboutInfo}
<HelpAndFeedbackModal onClose={() => (shouldShowHelpPanel = false)} info={aboutInfo} />
{/if}
@ -71,6 +75,7 @@
title={$t('go_to_search')}
icon={mdiMagnify}
padding="2"
onclick={() => {}}
/>
{/if}
@ -85,20 +90,20 @@
id="support-feedback-button"
title={$t('support_and_feedback')}
icon={mdiHelpCircleOutline}
on:click={() => (shouldShowHelpPanel = !shouldShowHelpPanel)}
onclick={() => (shouldShowHelpPanel = !shouldShowHelpPanel)}
padding="1"
/>
</div>
{#if !$page.url.pathname.includes('/admin') && showUploadButton}
<LinkButton on:click={onUploadClick} class="hidden lg:block">
<LinkButton onclick={onUploadClick} class="hidden lg:block">
<div class="flex gap-2">
<Icon path={mdiTrayArrowUp} size="1.5em" />
<span>{$t('upload')}</span>
</div>
</LinkButton>
<CircleIconButton
on:click={onUploadClick}
onclick={onUploadClick}
title={$t('upload')}
icon={mdiTrayArrowUp}
class="lg:hidden"
@ -115,11 +120,11 @@
<button
type="button"
class="flex pl-2"
on:mouseover={() => (shouldShowAccountInfo = true)}
on:focus={() => (shouldShowAccountInfo = true)}
on:blur={() => (shouldShowAccountInfo = false)}
on:mouseleave={() => (shouldShowAccountInfo = false)}
on:click={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)}
onmouseover={() => (shouldShowAccountInfo = true)}
onfocus={() => (shouldShowAccountInfo = true)}
onblur={() => (shouldShowAccountInfo = false)}
onmouseleave={() => (shouldShowAccountInfo = false)}
onclick={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)}
>
{#key $user}
<UserAvatar user={$user} size="md" showTitle={false} interactive />

View file

@ -3,7 +3,7 @@
import { cubicOut } from 'svelte/easing';
import { tweened } from 'svelte/motion';
let showing = false;
let showing = $state(false);
// delay showing any progress for a little bit so very fast loads
// do not cause flicker

View file

@ -1,5 +1,9 @@
<script lang="ts">
export let href: string;
interface Props {
href: string;
}
let { href }: Props = $props();
</script>
Notification <b>message</b> with <a {href}>link</a>

View file

@ -13,11 +13,14 @@
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
export let notification: Notification | ComponentNotification;
interface Props {
notification: Notification | ComponentNotification;
}
// svelte-ignore reactive_declaration_non_reactive_property
$: icon = notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline;
$: hoverStyle = notification.action.type === 'discard' ? 'hover:cursor-pointer' : '';
let { notification }: Props = $props();
let icon = $derived(notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline);
let hoverStyle = $derived(notification.action.type === 'discard' ? 'hover:cursor-pointer' : '');
const backgroundColor: Record<NotificationType, string> = {
[NotificationType.Info]: '#E0E2F0',
@ -67,14 +70,14 @@
};
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
transition:fade={{ duration: 250 }}
style:background-color={backgroundColor[notification.type]}
style:border-color={borderColor[notification.type]}
class="border z-[999999] mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md {hoverStyle}"
on:click={handleClick}
on:keydown={handleClick}
onclick={handleClick}
onkeydown={handleClick}
>
<div class="flex justify-between">
<div class="flex place-items-center gap-2">
@ -91,15 +94,15 @@
class="dark:text-immich-dark-gray"
size="20"
padding="2"
on:click={discard}
aria-hidden="true"
onclick={discard}
aria-hidden={true}
tabindex={-1}
/>
</div>
<p class="whitespace-pre-wrap pl-[28px] pr-[16px] text-sm" data-testid="message">
{#if isComponentNotification(notification)}
<svelte:component this={notification.component.type} {...notification.component.props} />
<notification.component.type {...notification.component.props} />
{:else}
{notification.message}
{/if}
@ -110,7 +113,7 @@
<button
type="button"
class="{buttonStyle[notification.type]} rounded px-3 pt-1.5 pb-1 transition-all duration-200"
on:click={handleButtonClick}
onclick={handleButtonClick}
aria-hidden="true"
tabindex={-1}
>

View file

@ -2,14 +2,38 @@
import { clamp } from 'lodash-es';
import type { ClipboardEventHandler } from 'svelte/elements';
export let id: string;
export let min: number;
export let max: number;
export let step: number | string = 'any';
export let required = true;
export let value: number | null = null;
export let onInput: (value: number | null) => void;
export let onPaste: ClipboardEventHandler<HTMLInputElement> | undefined = undefined;
interface Props {
id: string;
min: number;
max: number;
step?: number | string;
required?: boolean;
value?: number;
onInput: (value: number | null) => void;
onPaste?: ClipboardEventHandler<HTMLInputElement>;
}
let {
id,
min,
max,
step = 'any',
required = true,
value = $bindable(),
onInput,
onPaste = undefined,
}: Props = $props();
const oninput = () => {
if (!value) {
return;
}
if (value !== null && (value < min || value > max)) {
value = clamp(value, min, max);
}
onInput(value);
};
</script>
<input
@ -21,11 +45,6 @@
{step}
{required}
bind:value
on:input={() => {
if (value !== null && (value < min || value > max)) {
value = clamp(value, min, max);
}
onInput(value);
}}
on:paste={onPaste}
{oninput}
onpaste={onPaste}
/>

View file

@ -4,28 +4,26 @@
import Icon from '../elements/icon.svelte';
import { t } from 'svelte-i18n';
interface $$Props extends HTMLInputAttributes {
interface Props extends HTMLInputAttributes {
password: string;
autocomplete: AutoFill;
required?: boolean;
onInput?: (value: string) => void;
}
export let password: $$Props['password'];
export let required = true;
export let onInput: $$Props['onInput'] = undefined;
let { password = $bindable(), required = true, onInput = undefined, ...rest }: Props = $props();
let showPassword = false;
let showPassword = $state(false);
</script>
<div class="relative w-full">
<input
{...$$restProps}
{...rest}
class="immich-form-input w-full !pr-12"
type={showPassword ? 'text' : 'password'}
{required}
value={password}
on:input={(e) => {
oninput={(e) => {
password = e.currentTarget.value;
onInput?.(password);
}}
@ -36,7 +34,7 @@
type="button"
tabindex="-1"
class="absolute inset-y-0 end-0 px-4 text-gray-700 dark:text-gray-200"
on:click={() => (showPassword = !showPassword)}
onclick={() => (showPassword = !showPassword)}
title={showPassword ? $t('hide_password') : $t('show_password')}
>
<Icon path={showPassword ? mdiEyeOffOutline : mdiEyeOutline} size="1.25em" />

View file

@ -1,6 +1,6 @@
<script context="module" lang="ts">
<script module lang="ts">
import { handlePromiseError } from '$lib/utils';
import { tick } from 'svelte';
import { tick, type Snippet } from 'svelte';
/**
* Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}>
@ -64,12 +64,17 @@ Used for every occurrence of an HTML tag in a message
```
-->
<script lang="ts">
/**
* DOM Element or CSS Selector
*/
export let target: HTMLElement | string = 'body';
interface Props {
/**
* DOM Element or CSS Selector
*/
target?: HTMLElement | string;
children?: Snippet;
}
let { target = 'body', children }: Props = $props();
</script>
<div use:portal={target} hidden>
<slot />
{@render children?.()}
</div>

View file

@ -10,12 +10,20 @@
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
export let onClose: () => void;
interface Props {
asset: AssetResponseDto;
onClose: () => void;
}
let imgElement: HTMLDivElement;
let { asset, onClose }: Props = $props();
let imgElement: HTMLDivElement | undefined = $state();
onMount(() => {
if (!imgElement) {
return;
}
imgElement.style.width = '100%';
});
@ -45,6 +53,10 @@
};
const handleSetProfilePicture = async () => {
if (!imgElement) {
return;
}
try {
const blob = await domtoimage.toBlob(imgElement);
if (await hasTransparentPixels(blob)) {
@ -79,7 +91,8 @@
<PhotoViewer bind:element={imgElement} {asset} />
</div>
</div>
<svelte:fragment slot="sticky-bottom">
<Button fullwidth on:click={handleSetProfilePicture}>{$t('set_as_profile_picture')}</Button>
</svelte:fragment>
{#snippet stickyBottom()}
<Button fullwidth onclick={handleSetProfilePicture}>{$t('set_as_profile_picture')}</Button>
{/snippet}
</FullScreenModal>

View file

@ -1,4 +1,4 @@
<script context="module" lang="ts">
<script module lang="ts">
export enum ProgressBarStatus {
Playing = 'playing',
Paused = 'paused',
@ -11,41 +11,49 @@
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
/**
* Autoplay on mount
* @default false
*/
export let autoplay = false;
interface Props {
/**
* Autoplay on mount
* @default false
*/
autoplay?: boolean;
/**
* Progress bar status
*/
status?: ProgressBarStatus;
hidden?: boolean;
duration?: number;
onDone: () => void;
onPlaying?: () => void;
onPaused?: () => void;
}
/**
* Progress bar status
*/
export let status: ProgressBarStatus = ProgressBarStatus.Paused;
let {
autoplay = false,
status = $bindable(),
hidden = false,
duration = 5,
onDone,
onPlaying = () => {},
onPaused = () => {},
}: Props = $props();
export let hidden = false;
export let duration = 5;
export let onDone: () => void;
export let onPlaying: () => void = () => {};
export let onPaused: () => void = () => {};
const onChange = async () => {
progress = setDuration(duration);
const onChange = async (progressDuration: number) => {
progress = setDuration(progressDuration);
await play();
};
let progress = setDuration(duration);
// svelte 5, again....
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$: duration, handlePromiseError(onChange());
$effect(() => {
handlePromiseError(onChange(duration));
});
$: {
$effect(() => {
if ($progress === 1) {
onDone();
}
}
});
onMount(async () => {
if (autoplay) {

View file

@ -7,7 +7,11 @@
import { preferences } from '$lib/stores/user.store';
import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
export let onDone: () => void;
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
</script>
<div class="m-auto w-3/4 text-center flex flex-col place-content-center place-items-center dark:text-white my-6">
@ -25,6 +29,6 @@
</div>
<div class="mt-6 w-full">
<Button fullwidth on:click={onDone}>{$t('ok')}</Button>
<Button fullwidth onclick={onDone}>{$t('ok')}</Button>
</div>
</div>

View file

@ -8,12 +8,15 @@
import { purchaseStore } from '$lib/stores/purchase.store';
import { t } from 'svelte-i18n';
export let onActivate: () => void;
interface Props {
onActivate: () => void;
showTitle?: boolean;
showMessage?: boolean;
}
export let showTitle = true;
export let showMessage = true;
let productKey = '';
let isLoading = false;
let { onActivate, showTitle = true, showMessage = true }: Props = $props();
let productKey = $state('');
let isLoading = $state(false);
const activate = async () => {
try {
@ -61,7 +64,7 @@
<div class="mt-6">
<p class="dark:text-immich-gray">{$t('purchase_input_suggestion')}</p>
<form class="mt-2 flex gap-2" on:submit={activate}>
<form class="mt-2 flex gap-2" onsubmit={activate}>
<input
class="immich-form-input w-full"
id="purchaseKey"

View file

@ -5,9 +5,13 @@
import Portal from '$lib/components/shared-components/portal/portal.svelte';
export let onClose: () => void;
interface Props {
onClose: () => void;
}
let showProductActivated = false;
let { onClose }: Props = $props();
let showProductActivated = $state(false);
</script>
<Portal>

View file

@ -7,28 +7,45 @@
import { isTimelineScrolling } from '$lib/stores/timeline.store';
import { fade, fly } from 'svelte/transition';
export let timelineTopOffset = 0;
export let timelineBottomOffset = 0;
export let height = 0;
export let assetStore: AssetStore;
export let invisible = false;
export let scrubOverallPercent: number = 0;
export let scrubBucketPercent: number = 0;
export let scrubBucket: { bucketDate: string | undefined } | undefined = undefined;
export let leadout: boolean = false;
export let onScrub: ScrubberListener | undefined = undefined;
export let startScrub: ScrubberListener | undefined = undefined;
export let stopScrub: ScrubberListener | undefined = undefined;
interface Props {
timelineTopOffset?: number;
timelineBottomOffset?: number;
height?: number;
assetStore: AssetStore;
invisible?: boolean;
scrubOverallPercent?: number;
scrubBucketPercent?: number;
scrubBucket?: { bucketDate: string | undefined } | undefined;
leadout?: boolean;
onScrub?: ScrubberListener | undefined;
startScrub?: ScrubberListener | undefined;
stopScrub?: ScrubberListener | undefined;
}
let isHover = false;
let isDragging = false;
let hoverLabel: string | undefined;
let {
timelineTopOffset = 0,
timelineBottomOffset = 0,
height = 0,
assetStore,
invisible = false,
scrubOverallPercent = 0,
scrubBucketPercent = 0,
scrubBucket = undefined,
leadout = false,
onScrub = undefined,
startScrub = undefined,
stopScrub = undefined,
}: Props = $props();
let isHover = $state(false);
let isDragging = $state(false);
let hoverLabel: string | undefined = $state();
let bucketDate: string | undefined;
let hoverY = 0;
let hoverY = $state(0);
let clientY = 0;
let windowHeight = 0;
let scrollBar: HTMLElement | undefined;
let segments: Segment[] = [];
let windowHeight = $state(0);
let scrollBar: HTMLElement | undefined = $state();
let segments: Segment[] = $state([]);
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
@ -70,10 +87,14 @@
return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
}
};
$: scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
$: timelineFullHeight = $assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset;
$: relativeTopOffset = toScrollY(timelineTopOffset / timelineFullHeight);
$: relativeBottomOffset = toScrollY(timelineBottomOffset / timelineFullHeight);
let scrollY = $state(0);
$effect(() => {
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
});
let timelineFullHeight = $derived($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
const listener: BucketListener = (event) => {
const { type } = event;
@ -204,12 +225,12 @@
<svelte:window
bind:innerHeight={windowHeight}
on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
/>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
transition:fly={{ x: 50, duration: 250 }}
@ -223,8 +244,8 @@
style:background-color={isDragging ? 'transparent' : 'transparent'}
draggable="false"
bind:this={scrollBar}
on:mouseenter={() => (isHover = true)}
on:mouseleave={() => (isHover = false)}
onmouseenter={() => (isHover = true)}
onmouseleave={() => (isHover = false)}
>
{#if hoverLabel && (isHover || isDragging)}
<div

View file

@ -15,35 +15,38 @@
import { generateId } from '$lib/utils/generate-id';
import { tick } from 'svelte';
export let value = '';
export let grayTheme: boolean;
export let searchQuery: MetadataSearchDto | SmartSearchDto = {};
interface Props {
value?: string;
grayTheme: boolean;
searchQuery?: MetadataSearchDto | SmartSearchDto;
onSearch?: () => void;
}
$: showClearIcon = value.length > 0;
let { value = $bindable(''), grayTheme, searchQuery = {}, onSearch }: Props = $props();
let input: HTMLInputElement;
let showClearIcon = $derived(value.length > 0);
let showSuggestions = false;
let showFilter = false;
let isSearchSuggestions = false;
let selectedId: string | undefined;
let moveSelection: (direction: 1 | -1) => void;
let clearSelection: () => void;
let selectActiveOption: () => void;
let input = $state<HTMLInputElement>();
let searchHistoryBox = $state<ReturnType<typeof SearchHistoryBox>>();
let showSuggestions = $state(false);
let showFilter = $state(false);
let isSearchSuggestions = $state(false);
let selectedId: string | undefined = $state();
const listboxId = generateId();
const onSearch = async (payload: SmartSearchDto | MetadataSearchDto) => {
const handleSearch = async (payload: SmartSearchDto | MetadataSearchDto) => {
const params = getMetadataSearchQuery(payload);
closeDropdown();
showFilter = false;
$isSearchEnabled = false;
await goto(`${AppRoute.SEARCH}?${params}`);
onSearch?.();
};
const clearSearchTerm = (searchTerm: string) => {
input.focus();
input?.focus();
$savedSearchTerms = $savedSearchTerms.filter((item) => item !== searchTerm);
};
@ -57,7 +60,7 @@
};
const clearAllSearchTerms = () => {
input.focus();
input?.focus();
$savedSearchTerms = [];
};
@ -82,7 +85,7 @@
const onHistoryTermClick = async (searchTerm: string) => {
value = searchTerm;
const searchPayload = { query: searchTerm };
await onSearch(searchPayload);
await handleSearch(searchPayload);
};
const onFilterClick = () => {
@ -95,13 +98,13 @@
};
const onSubmit = () => {
handlePromiseError(onSearch({ query: value }));
handlePromiseError(handleSearch({ query: value }));
saveSearchTerm(value);
};
const onClear = () => {
value = '';
input.focus();
input?.focus();
};
const onEscape = () => {
@ -112,19 +115,19 @@
const onArrow = async (direction: 1 | -1) => {
openDropdown();
await tick();
moveSelection(direction);
searchHistoryBox?.moveSelection(direction);
};
const onEnter = (event: KeyboardEvent) => {
if (selectedId) {
event.preventDefault();
selectActiveOption();
searchHistoryBox?.selectActiveOption();
}
};
const onInput = () => {
openDropdown();
clearSelection();
searchHistoryBox?.clearSelection();
};
const openDropdown = () => {
@ -133,14 +136,19 @@
const closeDropdown = () => {
showSuggestions = false;
clearSelection();
searchHistoryBox?.clearSelection();
};
const onsubmit = (event: Event) => {
event.preventDefault();
onSubmit();
};
</script>
<svelte:window
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input.select() },
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input?.select() },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
]}
/>
@ -151,9 +159,9 @@
autocomplete="off"
class="select-text text-sm"
action={AppRoute.SEARCH}
on:reset={() => (value = '')}
on:submit|preventDefault={onSubmit}
on:focusin={onFocusIn}
onreset={() => (value = '')}
{onsubmit}
onfocusin={onFocusIn}
role="search"
>
<div use:focusOutside={{ onFocusOut: closeDropdown }} tabindex="-1">
@ -171,8 +179,8 @@
pattern="^(?!m:$).*$"
bind:value
bind:this={input}
on:focus={openDropdown}
on:input={onInput}
onfocus={openDropdown}
oninput={onInput}
disabled={showFilter}
role="combobox"
aria-controls={listboxId}
@ -191,13 +199,11 @@
<!-- SEARCH HISTORY BOX -->
<SearchHistoryBox
bind:this={searchHistoryBox}
bind:isSearchSuggestions
id={listboxId}
searchQuery={value}
isOpen={showSuggestions}
bind:isSearchSuggestions
bind:moveSelection
bind:clearSelection
bind:selectActiveOption
onClearAllSearchTerms={clearAllSearchTerms}
onClearSearchTerm={(searchTerm) => clearSearchTerm(searchTerm)}
onSelectSearchTerm={(searchTerm) => handlePromiseError(onHistoryTermClick(searchTerm))}
@ -206,19 +212,30 @@
</div>
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-2'} flex items-center pl-6 transition-all">
<CircleIconButton title={$t('show_search_options')} icon={mdiTune} on:click={onFilterClick} size="20" />
<CircleIconButton title={$t('show_search_options')} icon={mdiTune} onclick={onFilterClick} size="20" />
</div>
{#if showClearIcon}
<div class="absolute inset-y-0 right-0 flex items-center pr-2">
<CircleIconButton on:click={onClear} icon={mdiClose} title={$t('clear')} size="20" />
<CircleIconButton onclick={onClear} icon={mdiClose} title={$t('clear')} size="20" />
</div>
{/if}
<div class="absolute inset-y-0 left-0 flex items-center pl-2">
<CircleIconButton type="submit" disabled={showFilter} title={$t('search')} icon={mdiMagnify} size="20" />
<CircleIconButton
type="submit"
disabled={showFilter}
title={$t('search')}
icon={mdiMagnify}
size="20"
onclick={() => {}}
/>
</div>
</form>
{#if showFilter}
<SearchFilterModal {searchQuery} onSearch={(payload) => onSearch(payload)} onClose={() => (showFilter = false)} />
<SearchFilterModal
{searchQuery}
onSearch={(payload) => handleSearch(payload)}
onClose={() => (showFilter = false)}
/>
{/if}
</div>

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export interface SearchCameraFilter {
make?: string;
model?: string;
@ -6,20 +6,21 @@
</script>
<script lang="ts">
import { run } from 'svelte/legacy';
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import { t } from 'svelte-i18n';
export let filters: SearchCameraFilter;
interface Props {
filters: SearchCameraFilter;
}
let makes: string[] = [];
let models: string[] = [];
let { filters = $bindable() }: Props = $props();
$: makeFilter = filters.make;
$: modelFilter = filters.model;
$: handlePromiseError(updateMakes());
$: handlePromiseError(updateModels(makeFilter));
let makes: string[] = $state([]);
let models: string[] = $state([]);
async function updateMakes() {
const results: Array<string | null> = await getSearchSuggestions({
@ -47,6 +48,14 @@
filters.model = undefined;
}
}
let makeFilter = $derived(filters.make);
let modelFilter = $derived(filters.model);
run(() => {
handlePromiseError(updateMakes());
});
run(() => {
handlePromiseError(updateModels(makeFilter));
});
</script>
<div id="camera-selection">

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export interface SearchDateFilter {
takenBefore?: string;
takenAfter?: string;
@ -9,7 +9,11 @@
import DateInput from '$lib/components/elements/date-input.svelte';
import { t } from 'svelte-i18n';
export let filters: SearchDateFilter;
interface Props {
filters: SearchDateFilter;
}
let { filters = $bindable() }: Props = $props();
</script>
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export interface SearchDisplayFilters {
isNotInAlbum?: boolean;
isArchive?: boolean;
@ -10,7 +10,11 @@
import Checkbox from '$lib/components/elements/checkbox.svelte';
import { t } from 'svelte-i18n';
export let filters: SearchDisplayFilters;
interface Props {
filters: SearchDisplayFilters;
}
let { filters = $bindable() }: Props = $props();
</script>
<div id="display-options-selection">

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
import type { SearchLocationFilter } from './search-location-section.svelte';
import type { SearchDisplayFilters } from './search-display-section.svelte';
import type { SearchDateFilter } from './search-date-section.svelte';
@ -36,10 +36,15 @@
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { mdiTune } from '@mdi/js';
import { generateId } from '$lib/utils/generate-id';
import { SvelteSet } from 'svelte/reactivity';
export let searchQuery: MetadataSearchDto | SmartSearchDto;
export let onClose: () => void;
export let onSearch: (search: SmartSearchDto | MetadataSearchDto) => void;
interface Props {
searchQuery: MetadataSearchDto | SmartSearchDto;
onClose: () => void;
onSearch: (search: SmartSearchDto | MetadataSearchDto) => void;
}
let { searchQuery, onClose, onSearch }: Props = $props();
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
@ -50,10 +55,10 @@
return value === null ? undefined : value;
}
let filter: SearchFilter = {
let filter: SearchFilter = $state({
query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '',
queryType: 'query' in searchQuery ? 'smart' : 'metadata',
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
location: {
country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state),
@ -78,7 +83,7 @@
: searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
};
});
const resetForm = () => {
filter = {
@ -122,10 +127,20 @@
onSearch(payload);
};
const onreset = (event: Event) => {
event.preventDefault();
resetForm();
};
const onsubmit = (event: Event) => {
event.preventDefault();
search();
};
</script>
<FullScreenModal icon={mdiTune} width="extra-wide" title={$t('search_options')} {onClose}>
<form id={formId} autocomplete="off" on:submit|preventDefault={search} on:reset|preventDefault={resetForm}>
<form id={formId} autocomplete="off" {onsubmit} {onreset}>
<div class="space-y-10 pb-10" tabindex="-1">
<!-- PEOPLE -->
<SearchPeopleSection bind:selectedPeople={filter.personIds} />
@ -152,8 +167,8 @@
</div>
</form>
<svelte:fragment slot="sticky-bottom">
{#snippet stickyBottom()}
<Button type="reset" color="gray" fullwidth form={formId}>{$t('clear_all')}</Button>
<Button type="submit" fullwidth form={formId}>{$t('search')}</Button>
</svelte:fragment>
{/snippet}
</FullScreenModal>

View file

@ -6,22 +6,41 @@
import { t } from 'svelte-i18n';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
export let id: string;
export let searchQuery: string = '';
export let isSearchSuggestions: boolean = false;
export let isOpen: boolean = false;
export let onSelectSearchTerm: (searchTerm: string) => void;
export let onClearSearchTerm: (searchTerm: string) => void;
export let onClearAllSearchTerms: () => void;
export let onActiveSelectionChange: (selectedId: string | undefined) => void;
interface Props {
id: string;
searchQuery?: string;
isSearchSuggestions?: boolean;
isOpen?: boolean;
onSelectSearchTerm: (searchTerm: string) => void;
onClearSearchTerm: (searchTerm: string) => void;
onClearAllSearchTerms: () => void;
onActiveSelectionChange: (selectedId: string | undefined) => void;
}
$: filteredSearchTerms = $savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase()));
$: isSearchSuggestions = filteredSearchTerms.length > 0;
$: showClearAll = searchQuery === '';
$: suggestionCount = showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length;
let {
id,
searchQuery = '',
isSearchSuggestions = $bindable(false),
isOpen = false,
onSelectSearchTerm,
onClearSearchTerm,
onClearAllSearchTerms,
onActiveSelectionChange,
}: Props = $props();
let selectedIndex: number | undefined = undefined;
let element: HTMLDivElement;
let filteredSearchTerms = $derived(
$savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())),
);
$effect(() => {
isSearchSuggestions = filteredSearchTerms.length > 0;
});
let showClearAll = $derived(searchQuery === '');
let suggestionCount = $derived(showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length);
let selectedIndex: number | undefined = $state(undefined);
let element = $state<HTMLDivElement>();
export function moveSelection(increment: 1 | -1) {
if (!isSearchSuggestions) {
@ -45,7 +64,7 @@
if (selectedIndex === undefined) {
return;
}
const selectedElement = element.querySelector(`#${getId(selectedIndex)}`) as HTMLElement;
const selectedElement = element?.querySelector(`#${getId(selectedIndex)}`) as HTMLElement;
selectedElement?.click();
}
@ -86,7 +105,7 @@
type="button"
class="rounded-lg p-2 font-semibold text-immich-primary aria-selected:bg-immich-primary/25 hover:bg-immich-primary/25 dark:text-immich-dark-primary"
role="option"
on:click={() => handleClearAll()}
onclick={() => handleClearAll()}
tabindex="-1"
aria-selected={selectedIndex === 0}
aria-label={$t('clear_all_recent_searches')}
@ -100,11 +119,11 @@
{@const index = showClearAll ? i + 1 : i}
<div class="flex w-full items-center justify-between text-sm text-black dark:text-gray-300">
<div class="relative w-full items-center">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
id={getId(index)}
class="relative flex w-full cursor-pointer gap-3 py-3 pl-5 hover:bg-gray-100 aria-selected:bg-gray-100 dark:aria-selected:bg-gray-500/30 dark:hover:bg-gray-500/30"
on:click={() => handleSelect(savedSearchTerm)}
onclick={() => handleSelect(savedSearchTerm)}
role="option"
tabindex="-1"
aria-selected={selectedIndex === index}
@ -120,7 +139,7 @@
size="18"
padding="1"
tabindex={-1}
on:click={() => handleClearSingle(savedSearchTerm)}
onclick={() => handleClearSingle(savedSearchTerm)}
/>
</div>
</div>

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export interface SearchLocationFilter {
country?: string;
state?: string;
@ -7,22 +7,22 @@
</script>
<script lang="ts">
import { run } from 'svelte/legacy';
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { t } from 'svelte-i18n';
export let filters: SearchLocationFilter;
interface Props {
filters: SearchLocationFilter;
}
let countries: string[] = [];
let states: string[] = [];
let cities: string[] = [];
let { filters = $bindable() }: Props = $props();
$: countryFilter = filters.country;
$: stateFilter = filters.state;
$: handlePromiseError(updateCountries());
$: handlePromiseError(updateStates(countryFilter));
$: handlePromiseError(updateCities(countryFilter, stateFilter));
let countries: string[] = $state([]);
let states: string[] = $state([]);
let cities: string[] = $state([]);
async function updateCountries() {
const results: Array<string | null> = await getSearchSuggestions({
@ -64,6 +64,17 @@
filters.city = undefined;
}
}
let countryFilter = $derived(filters.country);
let stateFilter = $derived(filters.state);
run(() => {
handlePromiseError(updateCountries());
});
run(() => {
handlePromiseError(updateStates(countryFilter));
});
run(() => {
handlePromiseError(updateCities(countryFilter, stateFilter));
});
</script>
<div id="location-selection">

View file

@ -3,7 +3,11 @@
import { MediaType } from './search-filter-modal.svelte';
import { t } from 'svelte-i18n';
export let filteredMedia: MediaType;
interface Props {
filteredMedia: MediaType;
}
let { filteredMedia = $bindable() }: Props = $props();
</script>
<div id="media-type-selection">

View file

@ -10,12 +10,16 @@
import { t } from 'svelte-i18n';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
export let selectedPeople: Set<string>;
interface Props {
selectedPeople: Set<string>;
}
let { selectedPeople = $bindable() }: Props = $props();
let peoplePromise = getPeople();
let showAllPeople = false;
let name = '';
let numberOfPeople = 1;
let showAllPeople = $state(false);
let name = $state('');
let numberOfPeople = $state(1);
function orderBySelectedPeopleFirst(people: PersonResponseDto[]) {
return [
@ -72,7 +76,7 @@
)
? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white'
: 'border-transparent'}"
on:click={() => togglePersonSelection(person.id)}
onclick={() => togglePersonSelection(person.id)}
>
<ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" />
<p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p>
@ -86,7 +90,7 @@
shadow={false}
color="text-primary"
class="flex gap-2 place-items-center"
on:click={() => (showAllPeople = !showAllPeople)}
onclick={() => (showAllPeople = !showAllPeople)}
>
{#if showAllPeople}
<span><Icon path={mdiClose} ariaHidden /></span>

View file

@ -2,8 +2,12 @@
import RadioButton from '$lib/components/elements/radio-button.svelte';
import { t } from 'svelte-i18n';
export let query: string | undefined;
export let queryType: 'smart' | 'metadata' = 'smart';
interface Props {
query: string | undefined;
queryType?: 'smart' | 'metadata';
}
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
</script>
<fieldset>

View file

@ -7,10 +7,13 @@
import { mdiAlert } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
export let onClose: () => void;
interface Props {
onClose: () => void;
info: ServerAboutResponseDto;
versions: ServerVersionHistoryResponseDto[];
}
export let info: ServerAboutResponseDto;
export let versions: ServerVersionHistoryResponseDto[];
let { onClose, info, versions }: Props = $props();
</script>
<Portal>

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export type AccordionState = Set<string>;
const { get: getAccordionState, set: setAccordionState } = createContext<Writable<AccordionState>>();
@ -11,25 +11,33 @@
import { page } from '$app/stores';
import { handlePromiseError } from '$lib/utils';
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
const getParamValues = (param: string) => {
return new Set(($page.url.searchParams.get(param) || '').split(' ').filter((x) => x !== ''));
};
export let queryParam: string;
export let state: Writable<AccordionState> = writable(getParamValues(queryParam));
interface Props {
queryParam: string;
state?: Writable<AccordionState>;
children?: Snippet;
}
let { queryParam, state = writable(getParamValues(queryParam)), children }: Props = $props();
setAccordionState(state);
$: if (queryParam && $state) {
const searchParams = new URLSearchParams($page.url.searchParams);
if ($state.size > 0) {
searchParams.set(queryParam, [...$state].join(' '));
} else {
searchParams.delete(queryParam);
}
$effect(() => {
if (queryParam && $state) {
const searchParams = new URLSearchParams($page.url.searchParams);
if ($state.size > 0) {
searchParams.set(queryParam, [...$state].join(' '));
} else {
searchParams.delete(queryParam);
}
handlePromiseError(goto(`?${searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true }));
}
handlePromiseError(goto(`?${searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true }));
}
});
</script>
<slot />
{@render children?.()}

View file

@ -1,21 +1,34 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { getAccordionState } from './setting-accordion-state.svelte';
import { onDestroy } from 'svelte';
import { onDestroy, onMount, type Snippet } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
const accordionState = getAccordionState();
export let title: string;
export let subtitle = '';
export let key: string;
export let isOpen = $accordionState.has(key);
export let autoScrollTo = false;
export let icon = '';
interface Props {
title: string;
subtitle?: string;
key: string;
isOpen?: boolean;
autoScrollTo?: boolean;
icon?: string;
subtitleSnippet?: Snippet;
children?: Snippet;
}
let accordionElement: HTMLDivElement;
let {
title,
subtitle = '',
key,
isOpen = $bindable($accordionState.has(key)),
autoScrollTo = false,
icon = '',
subtitleSnippet,
children,
}: Props = $props();
$: setIsOpen(isOpen);
let accordionElement: HTMLDivElement | undefined = $state();
const setIsOpen = (isOpen: boolean) => {
if (isOpen) {
@ -23,7 +36,7 @@
if (autoScrollTo) {
setTimeout(() => {
accordionElement.scrollIntoView({
accordionElement?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
@ -38,6 +51,15 @@
onDestroy(() => {
setIsOpen(false);
});
const onclick = () => {
isOpen = !isOpen;
setIsOpen(isOpen);
};
onMount(() => {
setIsOpen(isOpen);
});
</script>
<div
@ -49,7 +71,7 @@
<button
type="button"
aria-expanded={isOpen}
on:click={() => (isOpen = !isOpen)}
{onclick}
class="flex w-full place-items-center justify-between text-left"
>
<div>
@ -62,9 +84,9 @@
</h2>
</div>
<slot name="subtitle">
{#if subtitleSnippet}{@render subtitleSnippet()}{:else}
<p class="text-sm dark:text-immich-dark-fg mt-1">{subtitle}</p>
</slot>
{/if}
</div>
<div
@ -88,7 +110,7 @@
{#if isOpen}
<ul transition:slide={{ duration: 150 }} class="mb-2 ml-4">
<slot />
{@render children?.()}
</ul>
{/if}
</div>

View file

@ -3,10 +3,14 @@
import type { ResetOptions } from '$lib/utils/dipatch';
import { t } from 'svelte-i18n';
export let showResetToDefault = true;
export let disabled = false;
export let onReset: (options: ResetOptions) => void;
export let onSave: () => void;
interface Props {
showResetToDefault?: boolean;
disabled?: boolean;
onReset: (options: ResetOptions) => void;
onSave: () => void;
}
let { showResetToDefault = true, disabled = false, onReset, onSave }: Props = $props();
</script>
<div class="mt-8 flex justify-between gap-2">
@ -14,7 +18,7 @@
{#if showResetToDefault}
<button
type="button"
on:click={() => onReset({ default: true })}
onclick={() => onReset({ default: true })}
class="bg-none text-sm font-medium text-immich-primary hover:text-immich-primary/75 dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75"
>
{$t('reset_to_default')}
@ -23,7 +27,7 @@
</div>
<div class="right">
<Button {disabled} size="sm" color="gray" on:click={() => onReset({ default: false })}>{$t('reset')}</Button>
<Button type="submit" {disabled} size="sm" on:click={() => onSave()}>{$t('save')}</Button>
<Button {disabled} size="sm" color="gray" onclick={() => onReset({ default: false })}>{$t('reset')}</Button>
<Button type="submit" {disabled} size="sm" onclick={() => onSave()}>{$t('save')}</Button>
</div>
</div>

View file

@ -4,13 +4,25 @@
import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n';
export let value: string[];
export let options: { value: string; text: string }[];
export let label = '';
export let desc = '';
export let name = '';
export let isEdited = false;
export let disabled = false;
interface Props {
value: string[];
options: { value: string; text: string }[];
label?: string;
desc?: string;
name?: string;
isEdited?: boolean;
disabled?: boolean;
}
let {
value = $bindable(),
options,
label = '',
desc = '',
name = '',
isEdited = false,
disabled = false,
}: Props = $props();
function handleCheckboxChange(option: string) {
value = value.includes(option) ? value.filter((item) => item !== option) : [...value, option];
@ -46,7 +58,7 @@
checked={value.includes(option.value)}
{disabled}
labelClass="text-gray-500 dark:text-gray-300"
on:change={() => handleCheckboxChange(option.value)}
onchange={() => handleCheckboxChange(option.value)}
/>
{/each}
</div>

View file

@ -3,14 +3,29 @@
import { fly } from 'svelte/transition';
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte';
export let title: string;
export let comboboxPlaceholder: string;
export let subtitle = '';
export let isEdited = false;
export let options: ComboBoxOption[];
export let selectedOption: ComboBoxOption;
export let onSelect: (combobox: ComboBoxOption | undefined) => void;
interface Props {
title: string;
comboboxPlaceholder: string;
subtitle?: string;
isEdited?: boolean;
options: ComboBoxOption[];
selectedOption: ComboBoxOption;
onSelect: (combobox: ComboBoxOption | undefined) => void;
children?: Snippet;
}
let {
title,
comboboxPlaceholder,
subtitle = '',
isEdited = false,
options,
selectedOption,
onSelect,
children,
}: Props = $props();
</script>
<div class="grid grid-cols-2">
@ -33,6 +48,6 @@
</div>
<div class="flex items-center">
<Combobox label={title} hideLabel={true} {selectedOption} {options} placeholder={comboboxPlaceholder} {onSelect} />
<slot />
{@render children?.()}
</div>
</div>

View file

@ -3,14 +3,27 @@
import { fly } from 'svelte/transition';
import Dropdown, { type RenderedOption } from '$lib/components/elements/dropdown.svelte';
import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte';
export let title: string;
export let subtitle = '';
export let options: RenderedOption[];
export let selectedOption: RenderedOption;
export let isEdited = false;
interface Props {
title: string;
subtitle?: string;
options: RenderedOption[];
selectedOption: RenderedOption;
isEdited?: boolean;
onToggle: (option: RenderedOption) => void;
children?: Snippet;
}
export let onToggle: (option: RenderedOption) => void;
let {
title,
subtitle = '',
options,
selectedOption = $bindable(),
isEdited = false,
onToggle,
children,
}: Props = $props();
</script>
<div class="flex place-items-center justify-between">
@ -30,7 +43,7 @@
</div>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
<slot />
{@render children?.()}
</div>
<div class="w-fit">
<Dropdown

View file

@ -1,7 +1,7 @@
import { SettingInputFieldType } from '$lib/constants';
import { render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
// @ts-expect-error the import works but tsc check errors
import SettingInputField, { SettingInputFieldType } from './setting-input-field.svelte';
import SettingInputField from './setting-input-field.svelte';
describe('SettingInputField component', () => {
it('validates number input on blur', async () => {

View file

@ -1,36 +1,47 @@
<script lang="ts" context="module">
export enum SettingInputFieldType {
EMAIL = 'email',
TEXT = 'text',
NUMBER = 'number',
PASSWORD = 'password',
COLOR = 'color',
}
</script>
<script lang="ts">
import { quintOut } from 'svelte/easing';
import type { FormEventHandler } from 'svelte/elements';
import { fly } from 'svelte/transition';
import PasswordField from '../password-field.svelte';
import { t } from 'svelte-i18n';
import { onMount, tick } from 'svelte';
import { onMount, tick, type Snippet } from 'svelte';
import { SettingInputFieldType } from '$lib/constants';
export let inputType: SettingInputFieldType;
export let value: string | number;
export let min = Number.MIN_SAFE_INTEGER;
export let max = Number.MAX_SAFE_INTEGER;
export let step = '1';
export let label = '';
export let desc = '';
export let title = '';
export let required = false;
export let disabled = false;
export let isEdited = false;
export let autofocus = false;
export let passwordAutocomplete: AutoFill = 'current-password';
interface Props {
inputType: SettingInputFieldType;
value: string | number;
min?: number;
max?: number;
step?: string;
label?: string;
description?: string;
title?: string;
required?: boolean;
disabled?: boolean;
isEdited?: boolean;
autofocus?: boolean;
passwordAutocomplete?: AutoFill;
descriptionSnippet?: Snippet;
}
let input: HTMLInputElement;
let {
inputType,
value = $bindable(),
min = Number.MIN_SAFE_INTEGER,
max = Number.MAX_SAFE_INTEGER,
step = '1',
label = '',
description = '',
title = '',
required = false,
disabled = false,
isEdited = false,
autofocus = false,
passwordAutocomplete = 'current-password',
descriptionSnippet,
}: Props = $props();
let input: HTMLInputElement | undefined = $state();
const handleChange: FormEventHandler<HTMLInputElement> = (e) => {
value = e.currentTarget.value;
@ -73,12 +84,12 @@
{/if}
</div>
{#if desc}
{#if description}
<p class="immich-form-label pb-2 text-sm" id="{label}-desc">
{desc}
{description}
</p>
{:else}
<slot name="desc" />
{@render descriptionSnippet?.()}
{/if}
{#if inputType !== SettingInputFieldType.PASSWORD}
@ -87,7 +98,7 @@
<input
bind:this={input}
class="immich-form-input w-full pb-2 rounded-none mr-1"
aria-describedby={desc ? `${label}-desc` : undefined}
aria-describedby={description ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
@ -97,7 +108,7 @@
{step}
{required}
{value}
on:change={handleChange}
onchange={handleChange}
{disabled}
{title}
/>
@ -107,7 +118,7 @@
bind:this={input}
class="immich-form-input w-full pb-2"
class:color-picker={inputType === SettingInputFieldType.COLOR}
aria-describedby={desc ? `${label}-desc` : undefined}
aria-describedby={description ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
@ -117,14 +128,14 @@
{step}
{required}
{value}
on:change={handleChange}
onchange={handleChange}
{disabled}
{title}
/>
</div>
{:else}
<PasswordField
aria-describedby={desc ? `${label}-desc` : undefined}
aria-describedby={description ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}

View file

@ -5,15 +5,29 @@
import Icon from '$lib/components/elements/icon.svelte';
import { mdiChevronDown } from '@mdi/js';
export let value: string | number;
export let options: { value: string | number; text: string }[];
export let label = '';
export let desc = '';
export let name = '';
export let isEdited = false;
export let number = false;
export let disabled = false;
export let onSelect: (setting: string | number) => void = () => {};
interface Props {
value: string | number;
options: { value: string | number; text: string }[];
label?: string;
desc?: string;
name?: string;
isEdited?: boolean;
number?: boolean;
disabled?: boolean;
onSelect?: (setting: string | number) => void;
}
let {
value = $bindable(),
options,
label = '',
desc = '',
name = '',
isEdited = false,
number = false,
disabled = false,
onSelect = () => {},
}: Props = $props();
const handleChange = (e: Event) => {
value = (e.target as HTMLInputElement).value;
@ -62,7 +76,7 @@
{name}
id="{name}-select"
bind:value
on:change={handleChange}
onchange={handleChange}
>
{#each options as option}
<option value={option.value}>{option.text}</option>

View file

@ -4,18 +4,32 @@
import Slider from '$lib/components/elements/slider.svelte';
import { generateId } from '$lib/utils/generate-id';
import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte';
export let title: string;
export let subtitle = '';
export let checked = false;
export let disabled = false;
export let isEdited = false;
export let onToggle: (isChecked: boolean) => void = () => {};
interface Props {
title: string;
subtitle?: string;
checked?: boolean;
disabled?: boolean;
isEdited?: boolean;
onToggle?: (isChecked: boolean) => void;
children?: Snippet;
}
let {
title,
subtitle = '',
checked = $bindable(false),
disabled = false,
isEdited = false,
onToggle = () => {},
children,
}: Props = $props();
let id: string = generateId();
$: sliderId = `${id}-slider`;
$: subtitleId = subtitle ? `${id}-subtitle` : undefined;
let sliderId = $derived(`${id}-slider`);
let subtitleId = $derived(subtitle ? `${id}-subtitle` : undefined);
</script>
<div class="flex place-items-center justify-between">
@ -37,7 +51,7 @@
{#if subtitle}
<p id={subtitleId} class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
{/if}
<slot />
{@render children?.()}
</div>
<Slider id={sliderId} bind:checked {disabled} {onToggle} ariaDescribedBy={subtitleId} />

View file

@ -2,13 +2,27 @@
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte';
export let value: string;
export let label = '';
export let desc = '';
export let required = false;
export let disabled = false;
export let isEdited = false;
interface Props {
value: string;
label?: string;
description?: string;
required?: boolean;
disabled?: boolean;
isEdited?: boolean;
descriptionSnippet?: Snippet;
}
let {
value = $bindable(),
label = '',
description = '',
required = false,
disabled = false,
isEdited = false,
descriptionSnippet,
}: Props = $props();
const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value;
@ -32,23 +46,23 @@
{/if}
</div>
{#if desc}
{#if description}
<p class="immich-form-label pb-2 text-sm" id="{label}-desc">
{desc}
{description}
</p>
{:else}
<slot name="desc" />
{@render descriptionSnippet?.()}
{/if}
<textarea
class="immich-form-input w-full pb-2"
aria-describedby={desc ? `${label}-desc` : undefined}
aria-describedby={description ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
{required}
{value}
on:input={handleInput}
oninput={handleInput}
{disabled}
></textarea>
</div>

View file

@ -15,25 +15,31 @@
info?: string;
}
export let onClose: () => void;
interface Props {
onClose: () => void;
shortcuts?: Shortcuts;
}
export let shortcuts: Shortcuts = {
general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
{ key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },
{ key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
],
actions: [
{ key: ['f'], action: $t('favorite_or_unfavorite_photo') },
{ key: ['i'], action: $t('show_or_hide_info') },
{ key: ['s'], action: $t('stack_selected_photos') },
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
{ key: ['⇧', 'd'], action: $t('download') },
{ key: ['Space'], action: $t('play_or_pause_video') },
{ key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
],
};
let {
onClose,
shortcuts = {
general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
{ key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },
{ key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
],
actions: [
{ key: ['f'], action: $t('favorite_or_unfavorite_photo') },
{ key: ['i'], action: $t('show_or_hide_info') },
{ key: ['s'], action: $t('stack_selected_photos') },
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
{ key: ['⇧', 'd'], action: $t('download') },
{ key: ['Space'], action: $t('play_or_pause_video') },
{ key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
],
},
}: Props = $props();
</script>
<FullScreenModal title={$t('keyboard_shortcuts')} width="auto" {onClose}>

View file

@ -3,7 +3,11 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n';
export let albumType: keyof AlbumStatisticsResponseDto;
interface Props {
albumType: keyof AlbumStatisticsResponseDto;
}
let { albumType }: Props = $props();
const handleAlbumCount = async () => {
try {

View file

@ -3,7 +3,11 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n';
export let assetStats: NonNullable<Parameters<typeof getAssetStatistics>[0]>;
interface Props {
assetStats: NonNullable<Parameters<typeof getAssetStatistics>[0]>;
}
let { assetStats }: Props = $props();
</script>
{#await getAssetStatistics(assetStats)}

View file

@ -18,12 +18,12 @@
import { getButtonVisibility } from '$lib/utils/purchase-utils';
import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte';
let showMessage = false;
let isOpen = false;
let hoverMessage = false;
let hoverButton = false;
let showMessage = $state(false);
let isOpen = $state(false);
let hoverMessage = $state(false);
let hoverButton = $state(false);
let showBuyButton = getButtonVisibility();
let showBuyButton = $state(getButtonVisibility());
const { isPurchased } = purchaseStore;
@ -63,13 +63,15 @@
}
};
$: if (showMessage && !hoverMessage && !hoverButton) {
setTimeout(() => {
if (!hoverMessage && !hoverButton) {
showMessage = false;
}
}, 300);
}
$effect(() => {
if (showMessage && !hoverMessage && !hoverButton) {
setTimeout(() => {
if (!hoverMessage && !hoverButton) {
showMessage = false;
}
}, 300);
}
});
</script>
{#if isOpen}
@ -79,7 +81,7 @@
<div class="hidden md:block license-status pl-4 text-sm">
{#if $isPurchased && $preferences.purchase.showSupportBadge}
<button
on:click={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)}
onclick={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)}
class="w-full"
type="button"
>
@ -88,11 +90,11 @@
{:else if !$isPurchased && showBuyButton && getAccountAge() > 14}
<button
type="button"
on:click={openPurchaseModal}
on:mouseover={onButtonHover}
on:mouseleave={() => (hoverButton = false)}
on:focus={onButtonHover}
on:blur={() => (hoverButton = false)}
onclick={openPurchaseModal}
onmouseover={onButtonHover}
onmouseleave={() => (hoverButton = false)}
onfocus={onButtonHover}
onblur={() => (hoverButton = false)}
class="p-2 flex justify-between place-items-center place-content-center border border-immich-primary/20 dark:border-immich-dark-primary/10 mt-2 rounded-lg shadow-md dark:bg-immich-dark-primary/10 w-full"
>
<div class="flex justify-between w-full place-items-center place-content-center">
@ -122,10 +124,10 @@
<div
class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
transition:fade={{ duration: 150 }}
on:mouseover={() => (hoverMessage = true)}
on:mouseleave={() => (hoverMessage = false)}
on:focus={() => (hoverMessage = true)}
on:blur={() => (hoverMessage = false)}
onmouseover={() => (hoverMessage = true)}
onmouseleave={() => (hoverMessage = false)}
onfocus={() => (hoverMessage = true)}
onblur={() => (hoverMessage = false)}
role="dialog"
>
<div class="flex justify-between place-items-center">
@ -134,7 +136,7 @@
</div>
<CircleIconButton
icon={mdiClose}
on:click={() => {
onclick={() => {
showMessage = false;
}}
title={$t('close')}
@ -157,12 +159,12 @@
</p>
</div>
<Button class="mt-2" fullwidth on:click={openPurchaseModal}>{$t('purchase_button_buy_immich')}</Button>
<Button class="mt-2" fullwidth onclick={openPurchaseModal}>{$t('purchase_button_buy_immich')}</Button>
<div class="mt-3 flex gap-4">
<Button size="sm" fullwidth shadow={false} color="transparent-gray" on:click={() => hideButton(true)}>
<Button size="sm" fullwidth shadow={false} color="transparent-gray" onclick={() => hideButton(true)}>
{$t('purchase_button_never_show_again')}
</Button>
<Button size="sm" fullwidth shadow={false} color="transparent-gray" on:click={() => hideButton(false)}>
<Button size="sm" fullwidth shadow={false} color="transparent-gray" onclick={() => hideButton(false)}>
{$t('purchase_button_reminder')}
</Button>
</div>

View file

@ -15,21 +15,22 @@
const { serverVersion, connected } = websocketStore;
let isOpen = false;
let isOpen = $state(false);
$: isMain = info?.sourceRef === 'main' && info.repository === 'immich-app/immich';
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
let info: ServerAboutResponseDto;
let versions: ServerVersionHistoryResponseDto[] = [];
let info: ServerAboutResponseDto | undefined = $state();
let versions: ServerVersionHistoryResponseDto[] = $state([]);
onMount(async () => {
await requestServerInfo();
[info, versions] = await Promise.all([getAboutInfo(), getVersionHistory()]);
});
let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich');
let version = $derived(
$serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
);
</script>
{#if isOpen}
{#if isOpen && info}
<ServerAboutModal onClose={() => (isOpen = false)} {info} {versions} />
{/if}
@ -50,9 +51,9 @@
<div class="flex justify-between justify-items-center">
{#if $connected && version}
<button type="button" on:click={() => (isOpen = true)} class="dark:text-immich-gray flex gap-1">
<button type="button" onclick={() => (isOpen = true)} class="dark:text-immich-gray flex gap-1">
{#if isMain}
<Icon path={mdiAlert} size="1.5em" color="#ffcc4d" /> {info.sourceRef}
<Icon path={mdiAlert} size="1.5em" color="#ffcc4d" /> {info?.sourceRef}
{:else}
{version}
{/if}

View file

@ -4,17 +4,34 @@
import { mdiInformationOutline } from '@mdi/js';
import { resolveRoute } from '$app/paths';
import { page } from '$app/stores';
import type { Snippet } from 'svelte';
export let title: string;
export let routeId: string;
export let icon: string;
export let flippedLogo = false;
export let isSelected = false;
export let preloadData = true;
interface Props {
title: string;
routeId: string;
icon: string;
flippedLogo?: boolean;
isSelected?: boolean;
preloadData?: boolean;
moreInformation?: Snippet;
}
let showMoreInformation = false;
$: routePath = resolveRoute(routeId, {});
$: isSelected = ($page.route.id?.match(/^\/(admin|\(user\))\/[^/]*/) || [])[0] === routeId;
let {
title,
routeId,
icon,
flippedLogo = false,
isSelected = $bindable(false),
preloadData = true,
moreInformation,
}: Props = $props();
let showMoreInformation = $state(false);
let routePath = $derived(resolveRoute(routeId, {}));
$effect(() => {
isSelected = ($page.route.id?.match(/^\/(admin|\(user\))\/[^/]*/) || [])[0] === routeId;
});
</script>
<a
@ -37,12 +54,12 @@
<div
class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible"
>
{#if $$slots.moreInformation}
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if moreInformation}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative flex cursor-default select-none justify-center"
on:mouseenter={() => (showMoreInformation = true)}
on:mouseleave={() => (showMoreInformation = false)}
onmouseenter={() => (showMoreInformation = true)}
onmouseleave={() => (showMoreInformation = false)}
>
<div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400">
<Icon path={mdiInformationOutline} />
@ -55,7 +72,7 @@
class:hidden={!showMoreInformation}
transition:fade={{ duration: 200 }}
>
<slot name="moreInformation" />
{@render moreInformation?.()}
</div>
</div>
{/if}

View file

@ -1,4 +1,11 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children?: Snippet;
}
let { children }: Props = $props();
</script>
<section
@ -6,5 +13,5 @@
tabindex="-1"
class="immich-scrollbar group relative z-10 flex w-18 flex-col gap-1 overflow-y-auto bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg hover:sm:w-64 hover:sm:border-r hover:sm:pr-6 hover:sm:shadow-2xl hover:sm:dark:border-r-immich-dark-gray md:w-64 md:pr-6 hover:md:border-none hover:md:shadow-none"
>
<slot />
{@render children?.()}
</section>

View file

@ -30,14 +30,14 @@
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import { preferences } from '$lib/stores/user.store';
let isArchiveSelected: boolean;
let isFavoritesSelected: boolean;
let isMapSelected: boolean;
let isPeopleSelected: boolean;
let isPhotosSelected: boolean;
let isSharingSelected: boolean;
let isTrashSelected: boolean;
let isUtilitiesSelected: boolean;
let isArchiveSelected: boolean = $state(false);
let isFavoritesSelected: boolean = $state(false);
let isMapSelected: boolean = $state(false);
let isPeopleSelected: boolean = $state(false);
let isPhotosSelected: boolean = $state(false);
let isSharingSelected: boolean = $state(false);
let isTrashSelected: boolean = $state(false);
let isUtilitiesSelected: boolean = $state(false);
</script>
<SideBarSection>
@ -48,9 +48,9 @@
bind:isSelected={isPhotosSelected}
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
>
<svelte:fragment slot="moreInformation">
{#snippet moreInformation()}
<MoreInformationAssets assetStats={{ isArchived: false }} />
</svelte:fragment>
{/snippet}
</SideBarLink>
{#if $featureFlags.search}
@ -81,9 +81,9 @@
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
bind:isSelected={isSharingSelected}
>
<svelte:fragment slot="moreInformation">
{#snippet moreInformation()}
<MoreInformationAlbums albumType="shared" />
</svelte:fragment>
{/snippet}
</SideBarLink>
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
@ -97,15 +97,15 @@
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
bind:isSelected={isFavoritesSelected}
>
<svelte:fragment slot="moreInformation">
{#snippet moreInformation()}
<MoreInformationAssets assetStats={{ isFavorite: true }} />
</svelte:fragment>
{/snippet}
</SideBarLink>
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
<svelte:fragment slot="moreInformation">
{#snippet moreInformation()}
<MoreInformationAlbums albumType="owned" />
</svelte:fragment>
{/snippet}
</SideBarLink>
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
@ -129,9 +129,9 @@
bind:isSelected={isArchiveSelected}
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
>
<svelte:fragment slot="moreInformation">
{#snippet moreInformation()}
<MoreInformationAssets assetStats={{ isArchived: true }} />
</svelte:fragment>
{/snippet}
</SideBarLink>
{#if $featureFlags.trash}
@ -141,9 +141,9 @@
bind:isSelected={isTrashSelected}
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
>
<svelte:fragment slot="moreInformation">
{#snippet moreInformation()}
<MoreInformationAssets assetStats={{ isTrashed: true }} />
</svelte:fragment>
{/snippet}
</SideBarLink>
{/if}
</nav>

View file

@ -8,12 +8,12 @@
import { getByteUnitString } from '../../../utils/byte-units';
import LoadingSpinner from '../loading-spinner.svelte';
let usageClasses = '';
let usageClasses = $state('');
$: hasQuota = $user?.quotaSizeInBytes !== null;
$: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0;
$: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
$: usedPercentage = Math.min(Math.round((usedBytes / availableBytes) * 100), 100);
let hasQuota = $derived($user?.quotaSizeInBytes !== null);
let availableBytes = $derived((hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0);
let usedBytes = $derived((hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0);
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
const onUpdate = () => {
usageClasses = getUsageClass();
@ -31,9 +31,11 @@
return 'bg-immich-primary dark:bg-immich-dark-primary';
};
$: if ($user) {
onUpdate();
}
$effect(() => {
if ($user) {
onUpdate();
}
});
onMount(async () => {
await requestServerInfo();

View file

@ -2,8 +2,12 @@
import { t } from 'svelte-i18n';
import ImmichLogo from '../immich-logo.svelte';
export let centered = false;
export let logoSize: 'sm' | 'lg' = 'sm';
interface Props {
centered?: boolean;
logoSize?: 'sm' | 'lg';
}
let { centered = false, logoSize = 'sm' }: Props = $props();
</script>
<div

View file

@ -1,10 +1,14 @@
<script lang="ts">
let className = '';
export { className as class };
export let itemCount = 1;
interface Props {
class?: string;
itemCount?: number;
children?: import('svelte').Snippet<[{ itemCount: number }]>;
}
let container: HTMLElement | undefined;
let contentRect: DOMRectReadOnly | undefined;
let { class: className = '', itemCount = $bindable(1), children }: Props = $props();
let container: HTMLElement | undefined = $state();
let contentRect: DOMRectReadOnly | undefined = $state();
const getGridGap = (element: Element) => {
const style = getComputedStyle(element);
@ -28,11 +32,13 @@
return Math.floor((containerWidth + columnGap) / (childWidth + columnGap)) || 1;
};
$: if (container && contentRect) {
itemCount = getItemCount(container, contentRect.width);
}
$effect(() => {
if (container && contentRect) {
itemCount = getItemCount(container, contentRect.width);
}
});
</script>
<div class={className} bind:this={container} bind:contentRect>
<slot {itemCount} />
{@render children?.({ itemCount })}
</div>

View file

@ -5,17 +5,23 @@
import { generateId } from '$lib/utils/generate-id';
import { t } from 'svelte-i18n';
export let count = 5;
export let rating: number;
export let readOnly = false;
export let onRating: (rating: number) => void | undefined;
interface Props {
count?: number;
rating: number;
readOnly?: boolean;
onRating: (rating: number) => void | undefined;
}
let ratingSelection = 0;
let hoverRating = 0;
let focusRating = 0;
let { count = 5, rating, readOnly = false, onRating }: Props = $props();
let ratingSelection = $state(rating);
let hoverRating = $state(0);
let focusRating = $state(0);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
$: ratingSelection = rating;
$effect(() => {
ratingSelection = rating;
});
const starIcon =
'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z';
@ -53,10 +59,10 @@
};
</script>
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<fieldset
class="text-immich-primary dark:text-immich-dark-primary w-fit cursor-default"
on:mouseleave={() => setHoverRating(0)}
onmouseleave={() => setHoverRating(0)}
use:focusOutside={{ onFocusOut: reset }}
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
@ -69,13 +75,13 @@
{@const value = index + 1}
{@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)}
{@const starId = `${id}-${value}`}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<label
for={starId}
class:cursor-pointer={!readOnly}
class:ring-2={focusRating === value}
on:mouseover={() => setHoverRating(value)}
onmouseover={() => setHoverRating(value)}
tabindex={-1}
data-testid="star"
>
@ -96,10 +102,10 @@
id={starId}
bind:group={ratingSelection}
disabled={readOnly}
on:focus={() => {
onfocus={() => {
focusRating = value;
}}
on:change={() => handleSelectDebounced(value)}
onchange={() => handleSelectDebounced(value)}
class="sr-only"
/>
{/each}
@ -108,7 +114,7 @@
{#if ratingSelection > 0 && !readOnly}
<button
type="button"
on:click={() => {
onclick={() => {
ratingSelection = 0;
handleSelect(ratingSelection);
}}

View file

@ -5,14 +5,15 @@
import { colorTheme, handleToggleTheme } from '$lib/stores/preferences.store';
import { t } from 'svelte-i18n';
// svelte-ignore reactive_declaration_non_reactive_property
$: icon = $colorTheme.value === Theme.LIGHT ? moonPath : sunPath;
// svelte-ignore reactive_declaration_non_reactive_property
$: viewBox = $colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox;
// svelte-ignore reactive_declaration_non_reactive_property
$: isDark = $colorTheme.value === Theme.DARK;
let icon = $derived($colorTheme.value === Theme.LIGHT ? moonPath : sunPath);
let viewBox = $derived($colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox);
let isDark = $derived($colorTheme.value === Theme.DARK);
export let padding: Padding = '3';
interface Props {
padding?: Padding;
}
let { padding = '3' }: Props = $props();
</script>
{#if !$colorTheme.system}
@ -22,7 +23,7 @@
{viewBox}
role="switch"
aria-checked={isDark ? 'true' : 'false'}
on:click={handleToggleTheme}
onclick={handleToggleTheme}
{padding}
/>
{/if}

View file

@ -4,12 +4,16 @@
import { mdiArrowUpLeft, mdiChevronRight } from '@mdi/js';
import { t } from 'svelte-i18n';
export let pathSegments: string[] = [];
export let getLink: (path: string) => string;
export let title: string;
export let icon: string;
interface Props {
pathSegments?: string[];
getLink: (path: string) => string;
title: string;
icon: string;
}
$: isRoot = pathSegments.length === 0;
let { pathSegments = [], getLink, title, icon }: Props = $props();
let isRoot = $derived(pathSegments.length === 0);
</script>
<nav class="flex items-center py-2">
@ -21,6 +25,7 @@
href={getLink(pathSegments.slice(0, -1).join('/'))}
class="mr-2"
padding="2"
onclick={() => {}}
/>
</div>
{/if}
@ -37,6 +42,7 @@
size="1.25em"
padding="2"
aria-current={isRoot ? 'page' : undefined}
onclick={() => {}}
/>
</li>
{#each pathSegments as segment, index}

View file

@ -1,9 +1,13 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
export let items: string[] = [];
export let icon: string;
export let onClick: (path: string) => void;
interface Props {
items?: string[];
icon: string;
onClick: (path: string) => void;
}
let { items = [], icon, onClick }: Props = $props();
</script>
{#if items.length > 0}
@ -13,7 +17,7 @@
{#each items as item}
<button
class="flex flex-col place-items-center gap-2 py-2 px-4 hover:bg-immich-primary/10 dark:hover:bg-immich-primary/40 rounded-xl"
on:click={() => onClick(item)}
onclick={() => onClick(item)}
title={item}
type="button"
>

View file

@ -2,12 +2,16 @@
import Tree from '$lib/components/shared-components/tree/tree.svelte';
import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
export let items: RecursiveObject;
export let parent = '';
export let active = '';
export let icons: { default: string; active: string };
export let getLink: (path: string) => string;
export let getColor: (path: string) => string | undefined = () => undefined;
interface Props {
items: RecursiveObject;
parent?: string;
active?: string;
icons: { default: string; active: string };
getLink: (path: string) => string;
getColor?: (path: string) => string | undefined;
}
let { items, parent = '', active = '', icons, getLink, getColor = () => undefined }: Props = $props();
</script>
<ul class="list-none ml-2">

View file

@ -4,19 +4,31 @@
import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
import { mdiChevronDown, mdiChevronRight } from '@mdi/js';
export let tree: RecursiveObject;
export let parent: string;
export let value: string;
export let active = '';
export let icons: { default: string; active: string };
export let getLink: (path: string) => string;
export let getColor: (path: string) => string | undefined;
interface Props {
tree: RecursiveObject;
parent: string;
value: string;
active?: string;
icons: { default: string; active: string };
getLink: (path: string) => string;
getColor: (path: string) => string | undefined;
}
$: path = normalizeTreePath(`${parent}/${value}`);
$: isActive = active === path || active.startsWith(`${path}/`);
$: isOpen = isActive;
$: isTarget = active === path;
$: color = getColor(path);
let { tree, parent, value, active = '', icons, getLink, getColor }: Props = $props();
let path = $derived(normalizeTreePath(`${parent}/${value}`));
let isActive = $derived(active === path || active.startsWith(`${path}/`));
let isOpen = $state(false);
$effect(() => {
isOpen = isActive;
});
let isTarget = $derived(active === path);
let color = $derived(getColor(path));
const onclick = (event: MouseEvent) => {
event.preventDefault();
isOpen = !isOpen;
};
</script>
<a
@ -24,11 +36,7 @@
title={value}
class={`flex flex-grow place-items-center pl-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
>
<button
type="button"
on:click|preventDefault={() => (isOpen = !isOpen)}
class={Object.values(tree).length === 0 ? 'invisible' : ''}
>
<button type="button" {onclick} class={Object.values(tree).length === 0 ? 'invisible' : ''}>
<Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} />
</button>
<div>

View file

@ -20,7 +20,11 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
export let uploadAsset: UploadAsset;
interface Props {
uploadAsset: UploadAsset;
}
let { uploadAsset }: Props = $props();
const handleDismiss = (uploadAsset: UploadAsset) => {
uploadAssetsStore.removeItem(uploadAsset.id);
@ -74,16 +78,16 @@
>
<Icon path={mdiOpenInNew} size="20" />
</a>
<button type="button" on:click={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<button type="button" onclick={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<Icon path={mdiClose} size="20" />
</button>
</div>
{:else if uploadAsset.state === UploadState.ERROR}
<div class="flex items-center justify-between gap-1">
<button type="button" on:click={() => handleRetry(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<button type="button" onclick={() => handleRetry(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<Icon path={mdiRestart} size="20" />
</button>
<button type="button" on:click={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<button type="button" onclick={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
<Icon path={mdiClose} size="20" />
</button>
</div>

View file

@ -11,9 +11,9 @@
import { t } from 'svelte-i18n';
import { locale } from '$lib/stores/preferences.store';
let showDetail = false;
let showOptions = false;
let concurrency = uploadExecutionQueue.concurrency;
let showDetail = $state(false);
let showOptions = $state(false);
let concurrency = $state(uploadExecutionQueue.concurrency);
let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore;
@ -27,16 +27,18 @@
}
};
$: if ($isUploading) {
autoHide();
}
$effect(() => {
if ($isUploading) {
autoHide();
}
});
</script>
{#if $isUploading}
<div
in:fade={{ duration: 250 }}
out:fade={{ duration: 250 }}
on:outroend={() => {
onoutroend={() => {
if ($stats.errors > 0) {
notificationController.show({
message: $t('upload_errors', { values: { count: $stats.errors } }),
@ -92,14 +94,14 @@
icon={mdiCog}
size="14"
padding="1"
on:click={() => (showOptions = !showOptions)}
onclick={() => (showOptions = !showOptions)}
/>
<CircleIconButton
title={$t('minimize')}
icon={mdiWindowMinimize}
size="14"
padding="1"
on:click={() => (showDetail = false)}
onclick={() => (showDetail = false)}
/>
</div>
{#if $isDismissible}
@ -108,7 +110,7 @@
icon={mdiCancel}
size="14"
padding="1"
on:click={() => uploadAssetsStore.dismissErrors()}
onclick={() => uploadAssetsStore.dismissErrors()}
/>
{/if}
</div>
@ -128,7 +130,7 @@
max="50"
step="1"
bind:value={concurrency}
on:change={() => (uploadExecutionQueue.concurrency = concurrency)}
onchange={() => (uploadExecutionQueue.concurrency = concurrency)}
/>
</div>
{/if}
@ -143,7 +145,7 @@
<button
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
on:click={() => (showDetail = true)}
onclick={() => (showDetail = true)}
class="absolute -left-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-primary p-5 text-xs text-gray-200"
>
{$remainingUploads.toLocaleString($locale)}
@ -152,7 +154,7 @@
<button
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
on:click={() => (showDetail = true)}
onclick={() => (showDetail = true)}
class="absolute -right-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-error p-5 text-xs text-gray-200"
>
{$stats.errors.toLocaleString($locale)}
@ -161,7 +163,7 @@
<button
type="button"
in:scale={{ duration: 250, easing: quartInOut }}
on:click={() => (showDetail = true)}
onclick={() => (showDetail = true)}
class="flex h-16 w-16 place-content-center place-items-center rounded-full bg-gray-200 p-5 text-sm text-immich-primary shadow-lg dark:bg-gray-600 dark:text-immich-gray"
>
<div class="animate-pulse">

View file

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export type Size = 'full' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl';
</script>
@ -16,25 +16,34 @@
profileChangedAt: string;
}
export let user: User;
export let color: UserAvatarColor | undefined = undefined;
export let size: Size = 'full';
export let rounded = true;
export let interactive = false;
export let showTitle = true;
export let showProfileImage = true;
export let label: string | undefined = undefined;
interface Props {
user: User;
color?: UserAvatarColor | undefined;
size?: Size;
rounded?: boolean;
interactive?: boolean;
showTitle?: boolean;
showProfileImage?: boolean;
label?: string | undefined;
}
let img: HTMLImageElement;
let showFallback = true;
let {
user,
color = undefined,
size = 'full',
rounded = true,
interactive = false,
showTitle = true,
showProfileImage = true,
label = undefined,
}: Props = $props();
// sveeeeeeelteeeeee fiveeeeee
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$: img, user, void tryLoadImage();
let img: HTMLImageElement | undefined = $state();
let showFallback = $state(true);
const tryLoadImage = async () => {
try {
await img.decode();
await img?.decode();
showFallback = false;
} catch {
showFallback = true;
@ -64,12 +73,20 @@
xxxl: 'w-28 h-28',
};
$: colorClass = colorClasses[color || user.avatarColor];
$: sizeClass = sizeClasses[size];
$: title = label ?? `${user.name} (${user.email})`;
$: interactiveClass = interactive
? 'border-2 border-immich-primary hover:border-immich-dark-primary dark:hover:border-immich-primary dark:border-immich-dark-primary transition-colors'
: '';
$effect(() => {
if (img && user) {
tryLoadImage().catch(console.error);
}
});
let colorClass = $derived(colorClasses[color || user.avatarColor]);
let sizeClass = $derived(sizeClasses[size]);
let title = $derived(label ?? `${user.name} (${user.email})`);
let interactiveClass = $derived(
interactive
? 'border-2 border-immich-primary hover:border-immich-dark-primary dark:hover:border-immich-primary dark:border-immich-dark-primary transition-colors'
: '',
);
</script>
<figure

View file

@ -6,18 +6,12 @@
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
let showModal = false;
let showModal = $state(false);
const { release } = websocketStore;
const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`;
$: releaseVersion = $release && semverToName($release.releaseVersion);
$: serverVersion = $release && semverToName($release.serverVersion);
$: if ($release?.isAvailable) {
handleRelease();
}
const onAcknowledge = () => {
localStorage.setItem('appVersion', releaseVersion);
showModal = false;
@ -34,21 +28,30 @@
console.error('Error [VersionAnnouncementBox]:', error);
}
};
let releaseVersion = $derived($release && semverToName($release.releaseVersion));
let serverVersion = $derived($release && semverToName($release.serverVersion));
$effect(() => {
if ($release?.isAvailable) {
handleRelease();
}
});
</script>
{#if showModal}
<FullScreenModal title="🎉 {$t('new_version_available')}" onClose={() => (showModal = false)}>
<div>
<FormatMessage key="version_announcement_message" let:tag let:message>
{#if tag === 'link'}
<span class="font-medium underline">
<a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer">
{message}
</a>
</span>
{:else if tag === 'code'}
<code>{message}</code>
{/if}
<FormatMessage key="version_announcement_message">
{#snippet children({ tag, message })}
{#if tag === 'link'}
<span class="font-medium underline">
<a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer">
{message}
</a>
</span>
{:else if tag === 'code'}
<code>{message}</code>
{/if}
{/snippet}
</FormatMessage>
</div>
@ -60,8 +63,8 @@
<code>{$t('latest_version')}: {releaseVersion}</code>
</div>
<svelte:fragment slot="sticky-bottom">
<Button fullwidth on:click={onAcknowledge}>{$t('acknowledge')}</Button>
</svelte:fragment>
{#snippet stickyBottom()}
<Button fullwidth onclick={onAcknowledge}>{$t('acknowledge')}</Button>
{/snippet}
</FullScreenModal>
{/if}