mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 01:11:46 +03:00
pr feedback
This commit is contained in:
105
web/src/lib/attachments/drag-and-drop.svelte.ts
Normal file
105
web/src/lib/attachments/drag-and-drop.svelte.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { Attachment } from 'svelte/attachments';
|
||||||
|
|
||||||
|
export interface DragAndDropOptions {
|
||||||
|
index: number;
|
||||||
|
onDragStart?: (index: number) => void;
|
||||||
|
onDragEnter?: (index: number) => void;
|
||||||
|
onDrop?: (e: DragEvent, index: number) => void;
|
||||||
|
onDragEnd?: () => void;
|
||||||
|
isDragging?: boolean;
|
||||||
|
isDragOver?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dragAndDrop(options: DragAndDropOptions): Attachment {
|
||||||
|
return (node: Element) => {
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
|
||||||
|
|
||||||
|
const isFormElement = (el: HTMLElement) => {
|
||||||
|
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (e: DragEvent) => {
|
||||||
|
// Prevent drag if it originated from an input, textarea, or select element
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (isFormElement(target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDragStart?.(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = () => {
|
||||||
|
onDragEnter?.(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent) => {
|
||||||
|
onDrop?.(e, index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
onDragEnd?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
|
||||||
|
const handleFocusIn = (e: FocusEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (isFormElement(target)) {
|
||||||
|
element.setAttribute('draggable', 'false');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocusOut = (e: FocusEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (isFormElement(target)) {
|
||||||
|
element.setAttribute('draggable', 'true');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update classes based on drag state
|
||||||
|
const updateClasses = (dragging: boolean, dragOver: boolean) => {
|
||||||
|
// Remove all drag-related classes first
|
||||||
|
element.classList.remove('opacity-50', 'border-light-500', 'border-solid');
|
||||||
|
|
||||||
|
// Add back only the active ones
|
||||||
|
if (dragging) {
|
||||||
|
element.classList.add('opacity-50');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragOver) {
|
||||||
|
element.classList.add('border-light-500', 'border-solid');
|
||||||
|
element.classList.remove('border-transparent');
|
||||||
|
} else {
|
||||||
|
element.classList.add('border-transparent');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
element.setAttribute('draggable', 'true');
|
||||||
|
element.setAttribute('role', 'button');
|
||||||
|
element.setAttribute('tabindex', '0');
|
||||||
|
|
||||||
|
element.addEventListener('dragstart', handleDragStart);
|
||||||
|
element.addEventListener('dragenter', handleDragEnter);
|
||||||
|
element.addEventListener('dragover', handleDragOver);
|
||||||
|
element.addEventListener('drop', handleDrop);
|
||||||
|
element.addEventListener('dragend', handleDragEnd);
|
||||||
|
element.addEventListener('focusin', handleFocusIn);
|
||||||
|
element.addEventListener('focusout', handleFocusOut);
|
||||||
|
|
||||||
|
updateClasses(isDragging || false, isDragOver || false);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('dragstart', handleDragStart);
|
||||||
|
element.removeEventListener('dragenter', handleDragEnter);
|
||||||
|
element.removeEventListener('dragover', handleDragOver);
|
||||||
|
element.removeEventListener('drop', handleDrop);
|
||||||
|
element.removeEventListener('dragend', handleDragEnd);
|
||||||
|
element.removeEventListener('focusin', handleFocusIn);
|
||||||
|
element.removeEventListener('focusout', handleFocusOut);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import WorkflowPickerItemCard from '$lib/components/workflows/WorkflowPickerItemCard.svelte';
|
||||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||||
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
|
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
|
||||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
|
||||||
import type { ComponentConfig } from '$lib/utils/workflow';
|
import type { ComponentConfig } from '$lib/utils/workflow';
|
||||||
import { getAlbumInfo, getPerson, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
import { getAlbumInfo, getPerson, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { Button, Card, CardBody, Field, IconButton, modalManager, Text } from '@immich/ui';
|
import { Button, Field, modalManager } from '@immich/ui';
|
||||||
import { mdiClose, mdiPlus } from '@mdi/js';
|
import { mdiPlus } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -99,58 +99,14 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet pickerItemCard(item: AlbumResponseDto | PersonResponseDto, onRemove: () => void)}
|
|
||||||
<Card color="secondary">
|
|
||||||
<CardBody class="flex items-center gap-3">
|
|
||||||
<div class="shrink-0">
|
|
||||||
{#if isAlbum && 'albumThumbnailAssetId' in item}
|
|
||||||
{#if item.albumThumbnailAssetId}
|
|
||||||
<img
|
|
||||||
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
|
|
||||||
alt={item.albumName}
|
|
||||||
class="h-12 w-12 rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="h-12 w-12 rounded-lg"></div>
|
|
||||||
{/if}
|
|
||||||
{:else if !isAlbum && 'name' in item}
|
|
||||||
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="h-12 w-12 rounded-full object-cover" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<Text class="font-semibold truncate">
|
|
||||||
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
|
|
||||||
</Text>
|
|
||||||
{#if isAlbum && 'assetCount' in item}
|
|
||||||
<Text size="small" color="muted">
|
|
||||||
{$t('items_count', { values: { count: item.assetCount } })}
|
|
||||||
</Text>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
type="button"
|
|
||||||
onclick={onRemove}
|
|
||||||
class="shrink-0"
|
|
||||||
shape="round"
|
|
||||||
aria-label={$t('remove')}
|
|
||||||
icon={mdiClose}
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
/>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
|
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
|
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
|
||||||
{@render pickerItemCard(pickerMetadata, removeSelection)}
|
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={removeSelection} />
|
||||||
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
|
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each pickerMetadata as item (item.id)}
|
{#each pickerMetadata as item (item.id)}
|
||||||
{@render pickerItemCard(item, () => removeItemFromSelection(item.id))}
|
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
|
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||||
|
import { Card, CardBody, IconButton, Text } from '@immich/ui';
|
||||||
|
import { mdiClose } from '@mdi/js';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: AlbumResponseDto | PersonResponseDto;
|
||||||
|
isAlbum: boolean;
|
||||||
|
onRemove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { item, isAlbum, onRemove }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card color="secondary">
|
||||||
|
<CardBody class="flex items-center gap-3">
|
||||||
|
<div class="shrink-0">
|
||||||
|
{#if isAlbum && 'albumThumbnailAssetId' in item}
|
||||||
|
{#if item.albumThumbnailAssetId}
|
||||||
|
<img
|
||||||
|
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
|
||||||
|
alt={item.albumName}
|
||||||
|
class="h-12 w-12 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="h-12 w-12 rounded-lg"></div>
|
||||||
|
{/if}
|
||||||
|
{:else if !isAlbum && 'name' in item}
|
||||||
|
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="h-12 w-12 rounded-full object-cover" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<Text class="font-semibold truncate">
|
||||||
|
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
|
||||||
|
</Text>
|
||||||
|
{#if isAlbum && 'assetCount' in item}
|
||||||
|
<Text size="small" color="muted">
|
||||||
|
{$t('items_count', { values: { count: item.assetCount } })}
|
||||||
|
</Text>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
onclick={onRemove}
|
||||||
|
class="shrink-0"
|
||||||
|
shape="round"
|
||||||
|
aria-label={$t('remove')}
|
||||||
|
icon={mdiClose}
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
@@ -248,7 +248,6 @@
|
|||||||
icon: mdiPencil,
|
icon: mdiPencil,
|
||||||
onAction: () => void handleEditWorkflow(workflow),
|
onAction: () => void handleEditWorkflow(workflow),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: expandedWorkflows.has(workflow.id) ? $t('hide_schema') : $t('show_schema'),
|
title: expandedWorkflows.has(workflow.id) ? $t('hide_schema') : $t('show_schema'),
|
||||||
icon: mdiCodeJson,
|
icon: mdiCodeJson,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
import { dragAndDrop } from '$lib/actions/drag-and-drop';
|
import { dragAndDrop } from '$lib/attachments/drag-and-drop.svelte';
|
||||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||||
import SchemaFormFields from '$lib/components/workflows/SchemaFormFields.svelte';
|
import SchemaFormFields from '$lib/components/workflows/SchemaFormFields.svelte';
|
||||||
import WorkflowCardConnector from '$lib/components/workflows/WorkflowCardConnector.svelte';
|
import WorkflowCardConnector from '$lib/components/workflows/WorkflowCardConnector.svelte';
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet cardOrder(index: number)}
|
{#snippet cardOrder(index: number)}
|
||||||
<div class="h-8 w-8 rounded-lg flex place-items-center place-content-center shrink-0 border">
|
<div class="h-8 w-8 rounded-lg flex place-items-center place-content-center shrink-0 border bg-light-50">
|
||||||
<Text size="small" class="font-mono font-bold">
|
<Text size="small" class="font-mono font-bold">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -455,7 +455,7 @@
|
|||||||
{@render stepSeparator()}
|
{@render stepSeparator()}
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
use:dragAndDrop={{
|
{@attach dragAndDrop({
|
||||||
index,
|
index,
|
||||||
onDragStart: handleFilterDragStart,
|
onDragStart: handleFilterDragStart,
|
||||||
onDragEnter: handleFilterDragEnter,
|
onDragEnter: handleFilterDragEnter,
|
||||||
@@ -463,7 +463,7 @@
|
|||||||
onDragEnd: handleFilterDragEnd,
|
onDragEnd: handleFilterDragEnd,
|
||||||
isDragging: draggedFilterIndex === index,
|
isDragging: draggedFilterIndex === index,
|
||||||
isDragOver: dragOverFilterIndex === index,
|
isDragOver: dragOverFilterIndex === index,
|
||||||
}}
|
})}
|
||||||
class="mb-4 cursor-move rounded-2xl border-2 p-4 transition-all bg-light-50 border-dashed hover:border-light-300"
|
class="mb-4 cursor-move rounded-2xl border-2 p-4 transition-all bg-light-50 border-dashed hover:border-light-300"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
{@render stepSeparator()}
|
{@render stepSeparator()}
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
use:dragAndDrop={{
|
{@attach dragAndDrop({
|
||||||
index,
|
index,
|
||||||
onDragStart: handleActionDragStart,
|
onDragStart: handleActionDragStart,
|
||||||
onDragEnter: handleActionDragEnter,
|
onDragEnter: handleActionDragEnter,
|
||||||
@@ -532,8 +532,8 @@
|
|||||||
onDragEnd: handleActionDragEnd,
|
onDragEnd: handleActionDragEnd,
|
||||||
isDragging: draggedActionIndex === index,
|
isDragging: draggedActionIndex === index,
|
||||||
isDragOver: dragOverActionIndex === index,
|
isDragOver: dragOverActionIndex === index,
|
||||||
}}
|
})}
|
||||||
class="mb-4 cursor-move rounded-2xl border-2 p-4 transition-all bg-light-50 border-dashed hover:border-light-300"
|
class="mb-4 cursor-move rounded-2xl border-2 p-4 transition-all bg-light-100 border-dashed hover:border-light-300"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
{@render cardOrder(index)}
|
{@render cardOrder(index)}
|
||||||
|
|||||||
Reference in New Issue
Block a user