jedmund-svelte/src/lib/components/admin/GalleryManager.svelte

646 lines
13 KiB
Svelte

<script lang="ts">
import Button from './Button.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import type { Media } from '@prisma/client'
interface Props {
label: string
value?: Media[]
maxItems?: number
required?: boolean
error?: string
showFileInfo?: boolean
}
let {
label,
value = $bindable([]),
maxItems,
required = false,
error,
showFileInfo = false
}: Props = $props()
let showModal = $state(false)
let draggedIndex = $state<number | null>(null)
let dragOverIndex = $state<number | null>(null)
function handleImagesSelect(media: Media[]) {
// Add new images to existing ones, avoiding duplicates
const existingIds = new Set(value.map((item) => item.id))
const newImages = media.filter((item) => !existingIds.has(item.id))
if (maxItems) {
const availableSlots = maxItems - value.length
value = [...value, ...newImages.slice(0, availableSlots)]
} else {
value = [...value, ...newImages]
}
showModal = false
}
function removeImage(index: number) {
value = value.filter((_, i) => i !== index)
}
function openModal() {
showModal = true
}
// Drag and Drop functionality
function handleDragStart(event: DragEvent, index: number) {
if (!event.dataTransfer) return
draggedIndex = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/html', '')
// Add dragging class to the dragged element
const target = event.target as HTMLElement
target.style.opacity = '0.5'
}
function handleDragEnd(event: DragEvent) {
const target = event.target as HTMLElement
target.style.opacity = '1'
draggedIndex = null
dragOverIndex = null
}
function handleDragOver(event: DragEvent, index: number) {
event.preventDefault()
if (!event.dataTransfer) return
event.dataTransfer.dropEffect = 'move'
dragOverIndex = index
}
function handleDragLeave() {
dragOverIndex = null
}
function handleDrop(event: DragEvent, dropIndex: number) {
event.preventDefault()
if (draggedIndex === null || draggedIndex === dropIndex) {
return
}
// Reorder the array
const newValue = [...value]
const draggedItem = newValue[draggedIndex]
// Remove the dragged item
newValue.splice(draggedIndex, 1)
// Insert at the new position (adjust index if necessary)
const insertIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
newValue.splice(insertIndex, 0, draggedItem)
value = newValue
// Reset drag state
draggedIndex = null
dragOverIndex = null
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// Computed properties
const hasImages = $derived(value.length > 0)
const canAddMore = $derived(!maxItems || value.length < maxItems)
const selectedIds = $derived(value.map((item) => item.id))
const itemsText = $derived(value.length === 1 ? '1 image' : `${value.length} images`)
</script>
<div class="gallery-manager">
<div class="header">
<label class="input-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
{#if hasImages}
<span class="items-count">
{itemsText}
{#if maxItems}
of {maxItems} max
{/if}
</span>
{/if}
</div>
<!-- Gallery Grid -->
{#if hasImages}
<div class="gallery-grid" class:has-error={error}>
{#each value as item, index (item.id)}
<div
class="gallery-item"
class:drag-over={dragOverIndex === index}
draggable="true"
ondragstart={(e) => handleDragStart(e, index)}
ondragend={handleDragEnd}
ondragover={(e) => handleDragOver(e, index)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, index)}
>
<!-- Drag Handle -->
<div class="drag-handle">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="9" cy="12" r="1" fill="currentColor" />
<circle cx="9" cy="5" r="1" fill="currentColor" />
<circle cx="9" cy="19" r="1" fill="currentColor" />
<circle cx="15" cy="12" r="1" fill="currentColor" />
<circle cx="15" cy="5" r="1" fill="currentColor" />
<circle cx="15" cy="19" r="1" fill="currentColor" />
</svg>
</div>
<!-- Image -->
<div class="image-container">
{#if item.thumbnailUrl}
<img src={item.thumbnailUrl} alt={item.filename} />
{:else}
<div class="image-placeholder">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg>
</div>
{/if}
</div>
<!-- Image Info -->
{#if showFileInfo}
<div class="image-info">
<p class="filename">{item.filename}</p>
<p class="file-size">{formatFileSize(item.size)}</p>
</div>
{/if}
<!-- Remove Button -->
<button
type="button"
class="remove-button"
onclick={() => removeImage(index)}
aria-label="Remove image"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<!-- Order Indicator -->
<div class="order-indicator">
{index + 1}
</div>
</div>
{/each}
<!-- Add More Button (if within grid) -->
{#if canAddMore}
<button type="button" class="add-more-item" onclick={openModal}>
<div class="add-icon">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
<span>Add Images</span>
</button>
{/if}
</div>
{:else}
<!-- Empty State -->
<div class="empty-state" class:has-error={error}>
<div class="empty-content">
<div class="empty-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
</div>
<p class="empty-text">No images added yet</p>
<Button variant="primary" onclick={openModal}>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
Add Images
</Button>
</div>
</div>
{/if}
<!-- Add More Button (outside grid) -->
{#if hasImages && canAddMore}
<div class="add-more-container">
<Button variant="ghost" onclick={openModal}>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
Add More Images
</Button>
</div>
{/if}
<!-- Error Message -->
{#if error}
<p class="error-message">{error}</p>
{/if}
<!-- Help Text -->
{#if hasImages}
<p class="help-text">Drag and drop to reorder images</p>
{/if}
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={showModal}
mode="multiple"
fileType="image"
{selectedIds}
title="Add Images to Gallery"
confirmText="Add Selected Images"
onselect={handleImagesSelect}
/>
</div>
<style lang="scss">
.gallery-manager {
display: flex;
flex-direction: column;
gap: $unit;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: $unit-2x;
}
.input-label {
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
.required {
color: $red-60;
margin-left: $unit-half;
}
}
.items-count {
font-size: 0.75rem;
color: $grey-40;
font-weight: 500;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: $unit-2x;
padding: $unit-2x;
border: 1px solid $grey-85;
border-radius: $card-corner-radius;
background-color: $grey-97;
&.has-error {
border-color: $red-60;
}
}
.gallery-item {
position: relative;
aspect-ratio: 1;
border-radius: $card-corner-radius;
overflow: hidden;
cursor: move;
transition: all 0.2s ease;
background-color: white;
border: 1px solid $grey-90;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.remove-button {
opacity: 1;
}
}
&.drag-over {
border-color: $blue-60;
background-color: rgba(59, 130, 246, 0.05);
}
}
.drag-handle {
position: absolute;
top: $unit-half;
left: $unit-half;
z-index: 3;
background-color: rgba(0, 0, 0, 0.6);
color: white;
padding: $unit-half;
border-radius: 4px;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
.gallery-item:hover & {
opacity: 1;
}
}
.image-container {
width: 100%;
height: 100%;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.image-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background-color: $grey-95;
color: $grey-60;
}
.image-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
padding: $unit-2x $unit $unit;
color: white;
.filename {
margin: 0 0 $unit-fourth 0;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
margin: 0;
font-size: 0.625rem;
opacity: 0.8;
}
}
.remove-button {
position: absolute;
top: $unit-half;
right: $unit-half;
z-index: 3;
background-color: rgba(239, 68, 68, 0.9);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
&:hover {
background-color: $red-60;
transform: scale(1.1);
}
}
.order-indicator {
position: absolute;
top: $unit-half;
right: $unit-half;
z-index: 2;
background-color: rgba(0, 0, 0, 0.7);
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: $unit-fourth $unit-half;
border-radius: 12px;
min-width: 20px;
text-align: center;
line-height: 1;
}
.add-more-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
aspect-ratio: 1;
border: 2px dashed $grey-70;
border-radius: $card-corner-radius;
background-color: transparent;
color: $grey-50;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
&:hover {
border-color: $blue-60;
color: $blue-60;
background-color: rgba(59, 130, 246, 0.05);
}
}
.add-icon {
color: inherit;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 2px dashed $grey-80;
border-radius: $card-corner-radius;
background-color: $grey-97;
&.has-error {
border-color: $red-60;
}
}
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit-2x;
text-align: center;
padding: $unit-4x;
}
.empty-icon {
color: $grey-60;
}
.empty-text {
margin: 0;
font-size: 0.875rem;
color: $grey-40;
}
.add-more-container {
display: flex;
justify-content: center;
padding-top: $unit;
}
.error-message {
margin: 0;
font-size: 0.75rem;
color: $red-60;
}
.help-text {
margin: 0;
font-size: 0.75rem;
color: $grey-50;
text-align: center;
font-style: italic;
}
// Responsive adjustments
@media (max-width: 640px) {
.gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: $unit;
padding: $unit;
}
.header {
flex-direction: column;
align-items: flex-start;
gap: $unit;
}
.order-indicator {
font-size: 0.625rem;
padding: $unit-fourth $unit-half;
}
.remove-button {
opacity: 1; // Always visible on mobile
}
.image-info {
display: none; // Hide on mobile to save space
}
}
</style>