mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
chore(web): migration svelte 5 syntax (#13883)
This commit is contained in:
parent
9203a61709
commit
0b3742cf13
310 changed files with 6435 additions and 4176 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'}"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="/">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<script lang="ts">
|
||||
export let size: string = '24';
|
||||
interface Props {
|
||||
size?: string;
|
||||
}
|
||||
|
||||
let { size = '24' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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?.()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue