jedmund-svelte/src/lib/components/admin/ImagePicker.svelte
Justin Edmund a31291d69f refactor: replace deprecated $grey- variables with $gray-
- Replace 802 instances of $grey- variables with $gray- across 106 files
- Remove legacy color aliases from variables.scss
- Maintain consistent naming convention throughout codebase

This completes the migration to the new color scale system.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 21:41:50 -04:00

402 lines
7.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import Button from './Button.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import type { Media } from '@prisma/client'
interface Props {
label: string
value?: Media | null
aspectRatio?: string
placeholder?: string
required?: boolean
error?: string
showDimensions?: boolean
}
let {
label,
value = $bindable(),
aspectRatio,
placeholder = 'No image selected',
required = false,
error,
showDimensions = true
}: Props = $props()
let showModal = $state(false)
let isHovering = $state(false)
function handleImageSelect(media: Media) {
value = media
showModal = false
}
function handleClear() {
value = null
}
function openModal() {
showModal = true
}
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 hasImage = $derived(value !== null && value !== undefined)
const selectedIds = $derived(hasImage ? [value!.id] : [])
// Calculate aspect ratio styles
const aspectRatioStyle = $derived(
!aspectRatio
? 'aspect-ratio: 16/9;'
: (() => {
const [width, height] = aspectRatio.split(':').map(Number)
return width && height ? `aspect-ratio: ${width}/${height};` : 'aspect-ratio: 16/9;'
})()
)
</script>
<div class="image-picker">
<label class="input-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
<!-- Image Preview Area -->
<div
class="image-preview-container"
class:has-image={hasImage}
class:has-error={error}
style={aspectRatioStyle}
role="button"
tabindex="0"
onclick={openModal}
onkeydown={(e) => e.key === 'Enter' && openModal()}
onmouseenter={() => (isHovering = true)}
onmouseleave={() => (isHovering = false)}
>
{#if hasImage && value}
<!-- Image Display -->
<img src={value.url} alt={value.filename} class="preview-image" />
<!-- Hover Overlay -->
{#if isHovering}
<div class="image-overlay">
<div class="overlay-actions">
<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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Change
</Button>
<Button variant="ghost" onclick={handleClear}>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 6h18m-2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Remove
</Button>
</div>
</div>
{/if}
{:else}
<!-- Empty State -->
<div class="empty-state">
<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">{placeholder}</p>
<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>
Select Image
</Button>
</div>
{/if}
</div>
<!-- Image Details -->
{#if hasImage && value}
<div class="image-details">
<div class="detail-row">
<span class="detail-label">Filename:</span>
<span class="detail-value">{value.filename}</span>
</div>
<div class="detail-row">
<span class="detail-label">Size:</span>
<span class="detail-value">{formatFileSize(value.size)}</span>
</div>
{#if showDimensions && value.width && value.height}
<div class="detail-row">
<span class="detail-label">Dimensions:</span>
<span class="detail-value">{value.width} × {value.height} px</span>
</div>
{/if}
</div>
{/if}
<!-- Error Message -->
{#if error}
<p class="error-message">{error}</p>
{/if}
<!-- Media Library Modal -->
<UnifiedMediaModal
bind:isOpen={showModal}
mode="single"
fileType="image"
{selectedIds}
title="Select Image"
confirmText="Select Image"
onSelect={handleImageSelect}
onClose={() => (showModal = false)}
/>
</div>
<style lang="scss">
.image-picker {
display: flex;
flex-direction: column;
gap: $unit;
}
.input-label {
font-size: 0.875rem;
font-weight: 500;
color: $gray-20;
.required {
color: $red-60;
margin-left: $unit-half;
}
}
.image-preview-container {
position: relative;
width: 100%;
border: 2px dashed $gray-80;
border-radius: $card-corner-radius;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
background-color: $gray-95;
&:hover {
border-color: $gray-60;
}
&:focus {
outline: none;
border-color: $blue-60;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&.has-image {
border-style: solid;
border-color: $gray-80;
background-color: transparent;
&:hover {
border-color: $blue-60;
}
}
&.has-error {
border-color: $red-60;
&:focus {
border-color: $red-60;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
}
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
animation: fadeIn 0.2s ease forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
.overlay-actions {
display: flex;
gap: $unit-2x;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $unit-4x;
text-align: center;
height: 100%;
min-height: 200px;
gap: $unit-2x;
}
.empty-icon {
color: $gray-60;
margin-bottom: $unit;
}
.empty-text {
margin: 0;
font-size: 0.875rem;
color: $gray-40;
margin-bottom: $unit;
}
.image-details {
padding: $unit-2x;
background-color: $gray-95;
border-radius: $card-corner-radius;
display: flex;
flex-direction: column;
gap: $unit-half;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
}
.detail-label {
font-weight: 500;
color: $gray-30;
}
.detail-value {
color: $gray-10;
text-align: right;
word-break: break-all;
}
.error-message {
margin: 0;
font-size: 0.75rem;
color: $red-60;
}
// Responsive adjustments
@media (max-width: 640px) {
.empty-state {
padding: $unit-3x;
min-height: 150px;
}
.empty-icon svg {
width: 32px;
height: 32px;
}
.overlay-actions {
flex-direction: column;
gap: $unit;
}
.detail-row {
flex-direction: column;
align-items: flex-start;
gap: $unit-half;
}
.detail-value {
text-align: left;
}
}
</style>