mirror of
https://github.com/immich-app/immich.git
synced 2025-12-22 01:11:20 +03:00
refactor: move components/elements to elements/ (#22091)
This commit is contained in:
18
web/src/lib/elements/Badge.svelte
Normal file
18
web/src/lib/elements/Badge.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
rounded?: boolean | 'full';
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { rounded = true, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="bg-primary text-subtle inline-block h-min whitespace-nowrap px-3 py-1 text-center align-baseline text-xs leading-none"
|
||||
class:rounded-md={rounded === true}
|
||||
class:rounded-full={rounded === 'full'}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
37
web/src/lib/elements/DateInput.svelte
Normal file
37
web/src/lib/elements/DateInput.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
type: 'date' | 'datetime-local';
|
||||
value?: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
class?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
onkeydown?: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
let { type, value = $bindable(), max = undefined, onkeydown, ...rest }: Props = $props();
|
||||
|
||||
let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
|
||||
|
||||
// Updating `value` directly causes the date input to reset itself or
|
||||
// interfere with user changes.
|
||||
let updatedValue = $derived(value);
|
||||
</script>
|
||||
|
||||
<input
|
||||
{...rest}
|
||||
{type}
|
||||
bind:value
|
||||
max={max || fallbackMax}
|
||||
oninput={(e) => (updatedValue = e.currentTarget.value)}
|
||||
onblur={() => (value = updatedValue)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
value = updatedValue;
|
||||
}
|
||||
onkeydown?.(e);
|
||||
}}
|
||||
/>
|
||||
141
web/src/lib/elements/Dropdown.svelte
Normal file
141
web/src/lib/elements/Dropdown.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts" module>
|
||||
// Necessary for eslint
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type T = any;
|
||||
|
||||
export type RenderedOption = {
|
||||
title: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="T">
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import { Button, Text } from '@immich/ui';
|
||||
import { mdiCheck } from '@mdi/js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { fly } from 'svelte/transition';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
options: T[];
|
||||
selectedOption?: any;
|
||||
showMenu?: boolean;
|
||||
controlable?: boolean;
|
||||
hideTextOnSmallScreen?: boolean;
|
||||
title?: string | undefined;
|
||||
position?: 'bottom-left' | 'bottom-right';
|
||||
onSelect: (option: T) => void;
|
||||
onClickOutside?: () => void;
|
||||
render?: (item: T) => string | RenderedOption;
|
||||
}
|
||||
|
||||
let {
|
||||
position = 'bottom-left',
|
||||
class: className = '',
|
||||
options,
|
||||
selectedOption = $bindable(options[0]),
|
||||
showMenu = $bindable(false),
|
||||
controlable = false,
|
||||
hideTextOnSmallScreen = true,
|
||||
title = undefined,
|
||||
onSelect,
|
||||
onClickOutside = () => {},
|
||||
render = String,
|
||||
}: Props = $props();
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (!controlable) {
|
||||
showMenu = false;
|
||||
}
|
||||
|
||||
onClickOutside();
|
||||
};
|
||||
|
||||
const handleSelectOption = (option: T) => {
|
||||
onSelect(option);
|
||||
selectedOption = option;
|
||||
|
||||
showMenu = false;
|
||||
};
|
||||
|
||||
const renderOption = (option: T): RenderedOption => {
|
||||
const renderedOption = render(option);
|
||||
switch (typeof renderedOption) {
|
||||
case 'string': {
|
||||
return { title: renderedOption };
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
title: renderedOption.title,
|
||||
icon: renderedOption.icon,
|
||||
disabled: renderedOption.disabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let renderedSelectedOption = $derived(renderOption(selectedOption));
|
||||
|
||||
const getAlignClass = (position: 'bottom-left' | 'bottom-right') => {
|
||||
switch (position) {
|
||||
case 'bottom-left': {
|
||||
return 'start-0';
|
||||
}
|
||||
case 'bottom-right': {
|
||||
return 'end-0';
|
||||
}
|
||||
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }} class="relative">
|
||||
<!-- BUTTON TITLE -->
|
||||
<Button onclick={() => (showMenu = true)} fullWidth {title} variant="ghost" color="secondary" size="small">
|
||||
{#if renderedSelectedOption?.icon}
|
||||
<Icon path={renderedSelectedOption.icon} />
|
||||
{/if}
|
||||
<Text class={hideTextOnSmallScreen ? 'hidden sm:block' : ''}>{renderedSelectedOption.title}</Text>
|
||||
</Button>
|
||||
|
||||
<!-- DROP DOWN MENU -->
|
||||
{#if showMenu}
|
||||
<div
|
||||
transition:fly={{ y: -30, duration: 250 }}
|
||||
class="text-sm font-medium z-1 absolute flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className} {getAlignClass(
|
||||
position,
|
||||
)}"
|
||||
>
|
||||
{#each options as option (option)}
|
||||
{@const renderedOption = renderOption(option)}
|
||||
{@const buttonStyle = renderedOption.disabled ? '' : 'transition-all hover:bg-gray-300 dark:hover:bg-gray-800'}
|
||||
<button
|
||||
type="button"
|
||||
class="grid grid-cols-[36px_1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}"
|
||||
disabled={renderedOption.disabled}
|
||||
onclick={() => !renderedOption.disabled && handleSelectOption(option)}
|
||||
>
|
||||
{#if isEqual(selectedOption, option)}
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<Icon path={mdiCheck} />
|
||||
</div>
|
||||
<p class="justify-self-start text-immich-primary dark:text-immich-dark-primary">
|
||||
{renderedOption.title}
|
||||
</p>
|
||||
{:else}
|
||||
<div></div>
|
||||
<p class="justify-self-start">
|
||||
{renderedOption.title}
|
||||
</p>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
52
web/src/lib/elements/DurationInput.svelte
Normal file
52
web/src/lib/elements/DurationInput.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
class?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let { value = $bindable(), class: className = '', ...rest }: Props = $props();
|
||||
|
||||
function minToParts(minutes: number) {
|
||||
const duration = Duration.fromObject({ minutes: Math.abs(minutes) }).shiftTo('days', 'hours', 'minutes');
|
||||
return {
|
||||
sign: minutes < 0 ? -1 : 1,
|
||||
days: duration.days === 0 ? null : duration.days,
|
||||
hours: duration.hours === 0 ? null : duration.hours,
|
||||
minutes: duration.minutes === 0 ? null : duration.minutes,
|
||||
};
|
||||
}
|
||||
|
||||
function partsToMin(sign: number, days: number | null, hours: number | null, minutes: number | null) {
|
||||
return (
|
||||
sign *
|
||||
Duration.fromObject({ days: days ?? 0, hours: hours ?? 0, minutes: minutes ?? 0 }).shiftTo('minutes').minutes
|
||||
);
|
||||
}
|
||||
|
||||
const initial = minToParts(value);
|
||||
let sign = $state(initial.sign);
|
||||
let days = $state(initial.days);
|
||||
let hours = $state(initial.hours);
|
||||
let minutes = $state(initial.minutes);
|
||||
|
||||
$effect(() => {
|
||||
value = partsToMin(sign, days, hours, minutes);
|
||||
});
|
||||
|
||||
function toggleSign() {
|
||||
sign = -sign;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={`flex gap-2 ${className}`} {...rest}>
|
||||
<button type="button" class="w-8 text-xl font-bold leading-none" onclick={toggleSign} title="Toggle sign">
|
||||
{sign >= 0 ? '+' : '-'}
|
||||
</button>
|
||||
<input type="number" min="0" placeholder={$t('days')} class="w-1/3" bind:value={days} />
|
||||
<input type="number" min="0" max="23" placeholder={$t('hours')} class="w-1/3" bind:value={hours} />
|
||||
<input type="number" min="0" max="59" placeholder={$t('minutes')} class="w-1/3" bind:value={minutes} />
|
||||
</div>
|
||||
40
web/src/lib/elements/GroupTab.svelte
Normal file
40
web/src/lib/elements/GroupTab.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
|
||||
interface Props {
|
||||
filters: string[];
|
||||
labels?: string[];
|
||||
selected: string;
|
||||
label: string;
|
||||
onSelect: (selected: string) => void;
|
||||
}
|
||||
|
||||
let { filters, selected, label, labels, onSelect }: Props = $props();
|
||||
|
||||
const id = `group-tab-${generateId()}`;
|
||||
</script>
|
||||
|
||||
<fieldset
|
||||
class="dark:bg-immich-dark-gray flex h-full rounded-2xl bg-gray-200 ring-gray-400 has-focus-visible:ring dark:ring-gray-600"
|
||||
>
|
||||
<legend class="sr-only">{label}</legend>
|
||||
{#each filters as filter, index (`${id}-${index}`)}
|
||||
<div class="group">
|
||||
<input
|
||||
type="radio"
|
||||
name={id}
|
||||
id="{id}-{index}"
|
||||
class="peer sr-only"
|
||||
value={filter}
|
||||
checked={filter === selected}
|
||||
onchange={() => onSelect(filter)}
|
||||
/>
|
||||
<label
|
||||
for="{id}-{index}"
|
||||
class="flex h-full cursor-pointer items-center px-4 text-sm hover:bg-gray-300 group-first-of-type:rounded-s-2xl group-last-of-type:rounded-e-2xl peer-checked:bg-gray-300 dark:hover:bg-gray-800 peer-checked:dark:bg-gray-700"
|
||||
>
|
||||
{labels?.[index] ?? filter}
|
||||
</label>
|
||||
</div>
|
||||
{/each}
|
||||
</fieldset>
|
||||
60
web/src/lib/elements/Icon.svelte
Normal file
60
web/src/lib/elements/Icon.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { AriaRole } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
size?: string | number;
|
||||
color?: string;
|
||||
path: string;
|
||||
title?: string | null;
|
||||
desc?: string;
|
||||
flipped?: boolean;
|
||||
class?: string;
|
||||
viewBox?: string;
|
||||
role?: AriaRole;
|
||||
ariaHidden?: boolean | undefined;
|
||||
ariaLabel?: string | undefined;
|
||||
ariaLabelledby?: string | undefined;
|
||||
strokeWidth?: number;
|
||||
strokeColor?: string;
|
||||
spin?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
size = '1em',
|
||||
color = 'currentColor',
|
||||
path,
|
||||
title = null,
|
||||
desc = '',
|
||||
flipped = false,
|
||||
class: className = '',
|
||||
viewBox = '0 0 24 24',
|
||||
role = 'img',
|
||||
ariaHidden = undefined,
|
||||
ariaLabel = undefined,
|
||||
ariaLabelledby = undefined,
|
||||
strokeWidth = 0,
|
||||
strokeColor = 'currentColor',
|
||||
spin = false,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
{viewBox}
|
||||
class="{className} {flipped ? '-scale-x-100' : ''} {spin ? 'animate-spin' : ''}"
|
||||
{role}
|
||||
stroke={strokeColor}
|
||||
stroke-width={strokeWidth}
|
||||
aria-label={ariaLabel}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
>
|
||||
{#if title}
|
||||
<title>{title}</title>
|
||||
{/if}
|
||||
{#if desc}
|
||||
<desc>{desc}</desc>
|
||||
{/if}
|
||||
<path d={path} fill={color} />
|
||||
</svg>
|
||||
16
web/src/lib/elements/RadioButton.svelte
Normal file
16
web/src/lib/elements/RadioButton.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
name: string;
|
||||
value: string;
|
||||
group?: string | undefined;
|
||||
}
|
||||
|
||||
let { id, label, name, value, group = $bindable(undefined) }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="radio" {name} {id} {value} class="focus-visible:ring" bind:group />
|
||||
<label for={id}>{label}</label>
|
||||
</div>
|
||||
80
web/src/lib/elements/SearchBar.svelte
Normal file
80
web/src/lib/elements/SearchBar.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import type { SearchOptions } from '$lib/utils/dipatch';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
roundedBottom?: boolean;
|
||||
showLoadingSpinner: boolean;
|
||||
placeholder: string;
|
||||
onSearch?: (options: SearchOptions) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
name = $bindable(),
|
||||
roundedBottom = true,
|
||||
showLoadingSpinner,
|
||||
placeholder,
|
||||
onSearch = () => {},
|
||||
onReset = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let inputRef = $state<HTMLElement>();
|
||||
|
||||
const resetSearch = () => {
|
||||
name = '';
|
||||
onReset();
|
||||
inputRef?.focus();
|
||||
};
|
||||
|
||||
const handleSearch = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSearch({ force: true });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center text-sm {roundedBottom
|
||||
? 'rounded-2xl'
|
||||
: 'rounded-t-lg'} bg-gray-200 p-2 dark:bg-immich-dark-gray gap-2 place-items-center h-full"
|
||||
>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon={mdiMagnify}
|
||||
aria-label={$t('search')}
|
||||
size="small"
|
||||
onclick={() => onSearch({ force: true })}
|
||||
/>
|
||||
<input
|
||||
class="w-full gap-2 bg-gray-200 dark:bg-immich-dark-gray dark:text-white"
|
||||
type="text"
|
||||
{placeholder}
|
||||
bind:value={name}
|
||||
bind:this={inputRef}
|
||||
onkeydown={handleSearch}
|
||||
oninput={() => onSearch({ force: false })}
|
||||
/>
|
||||
{#if showLoadingSpinner}
|
||||
<div class="flex place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
{#if name}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon={mdiClose}
|
||||
aria-label={$t('clear_value')}
|
||||
size="small"
|
||||
onclick={resetSearch}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
69
web/src/lib/elements/SkipLink.svelte
Normal file
69
web/src/lib/elements/SkipLink.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
import { Button } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Target for the skip link to move focus to.
|
||||
*/
|
||||
target?: string;
|
||||
/**
|
||||
* Text for the skip link button.
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* Breakpoint at which the skip link is visible. Defaults to always being visible.
|
||||
*/
|
||||
breakpoint?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
}
|
||||
|
||||
let { target = 'main', text = $t('skip_to_content'), breakpoint }: Props = $props();
|
||||
|
||||
let isFocused = $state(false);
|
||||
|
||||
const moveFocus = () => {
|
||||
const targetEl = document.querySelector<HTMLElement>(target);
|
||||
if (targetEl) {
|
||||
const element = getTabbable(targetEl)[0];
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getBreakpoint = () => {
|
||||
if (!breakpoint) {
|
||||
return '';
|
||||
}
|
||||
switch (breakpoint) {
|
||||
case 'sm': {
|
||||
return 'hidden sm:block';
|
||||
}
|
||||
case 'md': {
|
||||
return 'hidden md:block';
|
||||
}
|
||||
case 'lg': {
|
||||
return 'hidden lg:block';
|
||||
}
|
||||
case 'xl': {
|
||||
return 'hidden xl:block';
|
||||
}
|
||||
case '2xl': {
|
||||
return 'hidden 2xl:block';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="absolute top-2 start-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
|
||||
<Button
|
||||
size="small"
|
||||
onclick={moveFocus}
|
||||
class={getBreakpoint()}
|
||||
onfocus={() => (isFocused = true)}
|
||||
onblur={() => (isFocused = false)}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
</div>
|
||||
Reference in New Issue
Block a user