- 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>
402 lines
7.9 KiB
Svelte
402 lines
7.9 KiB
Svelte
<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>
|