806 lines
25 KiB
Vue
806 lines
25 KiB
Vue
<script setup lang="ts">
|
||
import DropZone from 'dropzone-vue';
|
||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||
import {
|
||
Field,
|
||
FieldDescription,
|
||
FieldError,
|
||
FieldLabel,
|
||
} from '@/components/ui/field';
|
||
import { uploadFileAndGetKey } from '@/lib/s3-upload';
|
||
import type { MediaUploadState } from '@/types/media';
|
||
import { createMediaUploadState } from '@/types/media';
|
||
import 'dropzone-vue/dist/dropzone-vue.common.css';
|
||
import { computed, ref, watch } from 'vue';
|
||
|
||
// ─── Client-side image compression ───────────────────────────────────────────
|
||
const COMPRESS_MAX_PX = 1200; // max width/height in pixels
|
||
const COMPRESS_QUALITY = 0.80; // JPEG quality (0–1)
|
||
|
||
async function compressImage(file: File): Promise<File> {
|
||
// Only compress raster images; skip SVG, GIF, etc.
|
||
if (!file.type.startsWith('image/') || file.type === 'image/svg+xml' || file.type === 'image/gif') {
|
||
return file;
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
const originalUrl = URL.createObjectURL(file);
|
||
|
||
img.onload = () => {
|
||
URL.revokeObjectURL(originalUrl);
|
||
|
||
let { width, height } = img;
|
||
|
||
// Scale down proportionally if image exceeds max dimension
|
||
if (width > COMPRESS_MAX_PX || height > COMPRESS_MAX_PX) {
|
||
if (width >= height) {
|
||
height = Math.round((height / width) * COMPRESS_MAX_PX);
|
||
width = COMPRESS_MAX_PX;
|
||
} else {
|
||
width = Math.round((width / height) * COMPRESS_MAX_PX);
|
||
height = COMPRESS_MAX_PX;
|
||
}
|
||
}
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
|
||
const ctx = canvas.getContext('2d')!;
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
// Output as JPEG for photos regardless of original format (except PNG transparency)
|
||
const outputMime = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||
|
||
canvas.toBlob(
|
||
(blob) => {
|
||
if (!blob || blob.size >= file.size) {
|
||
// Compression made it bigger or failed — keep original
|
||
resolve(file);
|
||
|
||
return;
|
||
}
|
||
|
||
resolve(new File([blob], file.name, { type: outputMime, lastModified: Date.now() }));
|
||
},
|
||
outputMime,
|
||
COMPRESS_QUALITY,
|
||
);
|
||
};
|
||
|
||
img.onerror = () => {
|
||
URL.revokeObjectURL(originalUrl);
|
||
resolve(file); // fallback to original
|
||
};
|
||
|
||
img.src = originalUrl;
|
||
});
|
||
}
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
id: string;
|
||
label: string;
|
||
description?: string;
|
||
maxFiles?: number;
|
||
maxFileSize?: number;
|
||
required?: boolean;
|
||
errors?: string[];
|
||
acceptedFiles?: string[];
|
||
}>(),
|
||
{
|
||
maxFiles: 5,
|
||
maxFileSize: 5_000_000,
|
||
required: false,
|
||
errors: () => [],
|
||
acceptedFiles: () => ['image'],
|
||
},
|
||
);
|
||
|
||
const state = defineModel<MediaUploadState>({
|
||
default: () => createMediaUploadState(),
|
||
});
|
||
|
||
// ─── Upload progress tracking ────────────────────────────────────────────────
|
||
const uploadProgress = ref<Record<string, number>>({});
|
||
const uploadErrors = ref<Record<string, string>>({});
|
||
|
||
// ─── Dialog ──────────────────────────────────────────────────────────────────
|
||
const previewOpen = ref(false);
|
||
const previewUrl = ref<string | null>(null);
|
||
|
||
function openPreview(url: string) {
|
||
previewUrl.value = url;
|
||
previewOpen.value = true;
|
||
}
|
||
|
||
// ─── New file tracking (dropzone) ────────────────────────────────────────────
|
||
type FilePreview = { id: string; file: File; objectUrl: string };
|
||
|
||
const filePreviews = ref<FilePreview[]>([]);
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (bytes < 1024) {
|
||
return `${bytes} B`;
|
||
}
|
||
|
||
if (bytes < 1024 * 1024) {
|
||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||
}
|
||
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function onAddedFile(item: { id: string; file: File }) {
|
||
if (props.maxFiles === 1) {
|
||
state.value.existing.forEach((existingItem) => {
|
||
if (!state.value.removeIds.includes(existingItem.id)) {
|
||
state.value.removeIds.push(existingItem.id);
|
||
}
|
||
});
|
||
state.value.existing = [];
|
||
|
||
const previewsCopy = [...filePreviews.value];
|
||
previewsCopy.forEach((p) => {
|
||
dropzoneRef.value?.removeFile(p.id);
|
||
});
|
||
filePreviews.value.forEach((p) => URL.revokeObjectURL(p.objectUrl));
|
||
filePreviews.value = [];
|
||
state.value.newFiles = [];
|
||
state.value.newFileS3Keys = [];
|
||
}
|
||
|
||
// Compress image asynchronously, then upload to S3
|
||
compressImage(item.file).then((compressed) => {
|
||
const fileIndex = state.value.newFiles.length;
|
||
|
||
state.value.newFiles.push(compressed);
|
||
filePreviews.value.push({
|
||
id: item.id,
|
||
file: compressed,
|
||
objectUrl: URL.createObjectURL(compressed),
|
||
});
|
||
|
||
// Upload to S3 in background
|
||
uploadProgress.value[item.id] = 0;
|
||
delete uploadErrors.value[item.id];
|
||
state.value.pendingUploads++;
|
||
|
||
uploadFileAndGetKey(compressed, (percent) => {
|
||
uploadProgress.value[item.id] = percent;
|
||
}).then((s3Key) => {
|
||
state.value.newFileS3Keys[fileIndex] = s3Key;
|
||
delete uploadProgress.value[item.id];
|
||
state.value.pendingUploads--;
|
||
}).catch((error: Error) => {
|
||
uploadErrors.value[item.id] = error.message;
|
||
delete uploadProgress.value[item.id];
|
||
state.value.pendingUploads--;
|
||
// Remove the file from state on upload failure
|
||
const idx = state.value.newFiles.indexOf(compressed);
|
||
|
||
if (idx !== -1) {
|
||
state.value.newFiles.splice(idx, 1);
|
||
state.value.newFileS3Keys.splice(idx, 1);
|
||
}
|
||
|
||
const pi = filePreviews.value.findIndex((p) => p.id === item.id);
|
||
|
||
if (pi !== -1) {
|
||
URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
|
||
filePreviews.value.splice(pi, 1);
|
||
}
|
||
|
||
dropzoneRef.value?.removeFile(item.id);
|
||
});
|
||
});
|
||
}
|
||
|
||
function onRemovedFile(item: { id: string; file: File }) {
|
||
const idx = state.value.newFiles.indexOf(item.file);
|
||
|
||
if (idx !== -1) {
|
||
state.value.newFiles.splice(idx, 1);
|
||
state.value.newFileS3Keys.splice(idx, 1);
|
||
}
|
||
|
||
const pi = filePreviews.value.findIndex((p) => p.id === item.id);
|
||
|
||
if (pi !== -1) {
|
||
URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
|
||
filePreviews.value.splice(pi, 1);
|
||
}
|
||
|
||
delete uploadProgress.value[item.id];
|
||
delete uploadErrors.value[item.id];
|
||
}
|
||
|
||
// ─── Unified normalized entries ───────────────────────────────────────────────
|
||
type NormalizedEntry = {
|
||
key: string;
|
||
thumbSrc: string; // src for thumbnail img
|
||
isImage: boolean; // false → show file icon
|
||
previewSrc: string; // src for fullscreen preview
|
||
name: string;
|
||
sizeLabel: string;
|
||
uploadPercent: number | null;
|
||
uploadError: string | null;
|
||
onRemove: () => void;
|
||
};
|
||
|
||
const dropzoneRef = ref<InstanceType<typeof DropZone> | null>(null);
|
||
|
||
const allPreviews = computed<NormalizedEntry[]>(() => {
|
||
const existing: NormalizedEntry[] = state.value.existing.map((item) => ({
|
||
key: `existing-${item.id}`,
|
||
thumbSrc: item.url, // thumb_url terlalu kecil → pixelated; pakai url full
|
||
isImage: true,
|
||
previewSrc: item.url,
|
||
name: item.url.split('/').pop() ?? `image-${item.id}`,
|
||
sizeLabel: 'Tersimpan',
|
||
uploadPercent: null,
|
||
uploadError: null,
|
||
onRemove: () => {
|
||
const idx = state.value.existing.findIndex((e) => e.id === item.id);
|
||
|
||
if (idx !== -1) {
|
||
state.value.existing.splice(idx, 1);
|
||
|
||
if (!state.value.removeIds.includes(item.id)) {
|
||
state.value.removeIds.push(item.id);
|
||
}
|
||
}
|
||
},
|
||
}));
|
||
|
||
const newFiles: NormalizedEntry[] = filePreviews.value.map((p) => ({
|
||
key: `new-${p.id}`,
|
||
thumbSrc: p.file.type.startsWith('image/') ? p.objectUrl : '',
|
||
isImage: p.file.type.startsWith('image/'),
|
||
previewSrc: p.objectUrl,
|
||
name: p.file.name,
|
||
sizeLabel: formatBytes(p.file.size),
|
||
uploadPercent: uploadProgress.value[p.id] ?? null,
|
||
uploadError: uploadErrors.value[p.id] ?? null,
|
||
onRemove: () => dropzoneRef.value?.removeFile(p.id),
|
||
}));
|
||
|
||
return [...existing, ...newFiles];
|
||
});
|
||
|
||
// ─── Dropzone picker trigger ──────────────────────────────────────────────────
|
||
function triggerDropzonePicker(event?: MouseEvent) {
|
||
event?.stopPropagation();
|
||
|
||
const container = document.getElementById(`dz-container-${props.id}`);
|
||
const input =
|
||
container?.querySelector<HTMLInputElement>('input[type="file"]');
|
||
|
||
input?.click();
|
||
}
|
||
|
||
watch(
|
||
() => state.value.newFiles,
|
||
(newFiles) => {
|
||
if (newFiles.length === 0 && filePreviews.value.length > 0) {
|
||
const previewsCopy = [...filePreviews.value];
|
||
previewsCopy.forEach((p) => {
|
||
dropzoneRef.value?.removeFile(p.id);
|
||
});
|
||
filePreviews.value.forEach((p) => URL.revokeObjectURL(p.objectUrl));
|
||
filePreviews.value = [];
|
||
}
|
||
},
|
||
{ deep: true },
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="media-dropzone-root">
|
||
<Field>
|
||
<FieldLabel :for="id" :required="required">
|
||
{{ label }}
|
||
</FieldLabel>
|
||
<FieldDescription v-if="description">
|
||
{{ description }}
|
||
</FieldDescription>
|
||
<FieldDescription v-else-if="maxFiles > 1">
|
||
Maks. {{ maxFiles }} gambar. Gambar pertama akan dijadikan
|
||
thumbnail.
|
||
</FieldDescription>
|
||
|
||
<div class="dropzone-wrapper">
|
||
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
|
||
<div :id="`dz-container-${id}`" :class="[
|
||
'dz-form-host',
|
||
{ 'dz-form-hidden': allPreviews.length > 0 },
|
||
]">
|
||
<DropZone :id="id" ref="dropzoneRef" :max-files="maxFiles" :max-file-size="maxFileSize"
|
||
:accepted-files="acceptedFiles" :upload-on-drop="false" :clickable="true"
|
||
:hidden-input-container="`#dz-container-${id}`" dropzone-class-name="dz-box"
|
||
:dropzone-message-class-name="`dz-message-${id}`" @added-file="onAddedFile"
|
||
@removed-file="onRemovedFile">
|
||
<template #message>
|
||
<div class="dz-placeholder">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24"
|
||
fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"
|
||
stroke-linejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||
<polyline points="17 8 12 3 7 8" />
|
||
<line x1="12" y1="3" x2="12" y2="15" />
|
||
</svg>
|
||
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
|
||
<span class="dz-placeholder-secondary">
|
||
{{ acceptedFiles.join(', ') }} — Maks.
|
||
{{ maxFiles }} file,
|
||
{{ formatBytes(maxFileSize) }}/file
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</DropZone>
|
||
</div>
|
||
|
||
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
|
||
<div v-if="allPreviews.length > 0" class="dz-overlay" @click.stop="triggerDropzonePicker($event)">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
|
||
stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||
<polyline points="17 8 12 3 7 8" />
|
||
<line x1="12" y1="3" x2="12" y2="15" />
|
||
</svg>
|
||
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
|
||
<span class="dz-placeholder-secondary">
|
||
{{ acceptedFiles.join(', ') }} — Maks.
|
||
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Preview grid (existing + new) -->
|
||
<div v-if="allPreviews.length > 0" :class="[
|
||
'preview-grid',
|
||
{ 'preview-grid--single': maxFiles === 1 },
|
||
]">
|
||
<div v-for="entry in allPreviews" :key="entry.key" class="preview-card">
|
||
<!-- Thumbnail -->
|
||
<div class="preview-thumb" @click="openPreview(entry.previewSrc)">
|
||
<img v-if="entry.isImage" :src="entry.thumbSrc" :alt="entry.name" />
|
||
<div v-else class="preview-thumb-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"
|
||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||
stroke-linejoin="round">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||
<polyline points="14 2 14 8 20 8" />
|
||
</svg>
|
||
</div>
|
||
<!-- Upload progress overlay -->
|
||
<div v-if="entry.uploadPercent !== null" class="upload-progress-overlay">
|
||
<div class="upload-progress-bar">
|
||
<div class="upload-progress-fill" :style="{ width: `${entry.uploadPercent}%` }" />
|
||
</div>
|
||
<span class="upload-progress-text">{{ entry.uploadPercent }}%</span>
|
||
</div>
|
||
<!-- Upload error overlay -->
|
||
<div v-if="entry.uploadError" class="upload-error-overlay">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||
stroke-linejoin="round">
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="15" y1="9" x2="9" y2="15" />
|
||
<line x1="9" y1="9" x2="15" y2="15" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Info + Remove -->
|
||
<div class="preview-footer">
|
||
<div class="preview-info">
|
||
<p class="preview-name" :title="entry.name">
|
||
{{ entry.name }}
|
||
</p>
|
||
<p class="preview-size">
|
||
{{ entry.uploadError ? 'Gagal' : entry.sizeLabel }}
|
||
</p>
|
||
</div>
|
||
<button type="button" class="preview-remove" title="Hapus" @click="entry.onRemove()">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||
stroke-linejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18" />
|
||
<line x1="6" y1="6" x2="18" y2="18" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<FieldError :errors="errors" />
|
||
|
||
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
|
||
</Field>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* === Root containment: prevents MediaDropzone from expanding parent grid/flex === */
|
||
.media-dropzone-root {
|
||
width: 100%;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* === Wrapper === */
|
||
.dropzone-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Host div that wraps the dropzone <form> */
|
||
.dz-form-host {
|
||
display: block;
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* When previews exist: collapse form out of view (keep in DOM so dropzone JS stays alive) */
|
||
.dz-form-hidden {
|
||
position: absolute;
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
width: 100%;
|
||
height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* === Dropzone box (the <form> element) === */
|
||
/* Target both our custom class and dropzone-vue's own .dropzone class */
|
||
.dropzone-wrapper :deep(.dz-box),
|
||
.dropzone-wrapper :deep(.dropzone) {
|
||
width: 100% !important;
|
||
max-width: 100% !important;
|
||
min-width: 0 !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
|
||
.dropzone-wrapper :deep(.dz-box) {
|
||
border: 2px dashed var(--border);
|
||
border-radius: var(--radius-lg);
|
||
background: color-mix(in oklch, var(--muted) 50%, transparent);
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.2s,
|
||
background-color 0.2s;
|
||
display: flex !important;
|
||
flex-direction: column !important;
|
||
min-height: 0 !important;
|
||
}
|
||
|
||
.dropzone-wrapper :deep(.dz-box:hover) {
|
||
border-color: var(--primary);
|
||
background: color-mix(in oklch, var(--muted) 80%, transparent);
|
||
}
|
||
|
||
/* Hide built-in item previews */
|
||
.dropzone-wrapper :deep(.dropzone__item--style),
|
||
.dropzone-wrapper :deep(.dropzone__item),
|
||
.dropzone-wrapper :deep(.dropzone__container),
|
||
.dropzone-wrapper :deep(.dropzone__items) {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Message slot wrapper */
|
||
.dropzone-wrapper :deep(.dropzone__message),
|
||
.dropzone-wrapper :deep([class*='dz-message-']) {
|
||
flex: 1 !important;
|
||
display: flex !important;
|
||
padding: 0 !important;
|
||
margin: 0 !important;
|
||
}
|
||
|
||
/* === Placeholder inside slot === */
|
||
.dz-placeholder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
gap: 0.5rem;
|
||
padding: 1rem;
|
||
width: 100%;
|
||
min-width: 0;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.dz-placeholder {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
text-align: left;
|
||
gap: 0.75rem;
|
||
padding: 0.75rem 1rem;
|
||
}
|
||
}
|
||
|
||
/* === Overlay (shown when previews exist) === */
|
||
.dz-overlay {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
gap: 0.5rem;
|
||
padding: 1rem;
|
||
width: 100%;
|
||
cursor: pointer;
|
||
border: 2px dashed var(--border);
|
||
border-radius: var(--radius-lg);
|
||
background: color-mix(in oklch, var(--muted) 50%, transparent);
|
||
transition:
|
||
border-color 0.2s,
|
||
background-color 0.2s;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.dz-overlay {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
text-align: left;
|
||
gap: 0.75rem;
|
||
padding: 0.75rem 1rem;
|
||
}
|
||
}
|
||
|
||
.dz-overlay:hover {
|
||
border-color: var(--primary);
|
||
background: color-mix(in oklch, var(--muted) 80%, transparent);
|
||
}
|
||
|
||
.dz-placeholder svg,
|
||
.dz-overlay svg {
|
||
flex-shrink: 0;
|
||
opacity: 0.7;
|
||
color: var(--muted-foreground);
|
||
}
|
||
|
||
.dz-placeholder-primary {
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
color: var(--foreground);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.dz-placeholder-primary {
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.dz-placeholder-secondary {
|
||
font-size: 0.75rem;
|
||
color: var(--muted-foreground);
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.dz-placeholder-secondary {
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
}
|
||
|
||
/* ---- Preview grid — multiple mode (default) ---- */
|
||
.preview-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 0.5rem;
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
@media (min-width: 400px) {
|
||
.preview-grid {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (min-width: 560px) {
|
||
.preview-grid {
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (min-width: 720px) {
|
||
.preview-grid {
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
.preview-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
background: color-mix(in oklch, var(--card) 90%, transparent);
|
||
overflow: hidden;
|
||
transition: box-shadow 0.15s;
|
||
}
|
||
|
||
.preview-card:hover {
|
||
box-shadow: 0 2px 8px color-mix(in oklch, var(--foreground) 8%, transparent);
|
||
}
|
||
|
||
/* ---- Preview grid — single mode (maxFiles === 1) ---- */
|
||
.preview-grid--single {
|
||
grid-template-columns: 1fr !important;
|
||
}
|
||
|
||
/* Single: card is horizontal — thumbnail left, info right */
|
||
.preview-grid--single .preview-card {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.preview-grid--single .preview-thumb {
|
||
width: 56px;
|
||
height: 56px;
|
||
aspect-ratio: unset;
|
||
flex-shrink: 0;
|
||
border-radius: 0;
|
||
}
|
||
|
||
.preview-grid--single .preview-footer {
|
||
flex: 1;
|
||
border-top: none;
|
||
border-left: 1px solid var(--border);
|
||
padding: 0.4rem 0.6rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Thumbnail: square, full-width */
|
||
.preview-thumb {
|
||
width: 100%;
|
||
aspect-ratio: 1 / 1;
|
||
overflow: hidden;
|
||
background: var(--muted);
|
||
cursor: zoom-in;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
}
|
||
|
||
.preview-thumb img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.preview-thumb:hover img {
|
||
transform: scale(1.04);
|
||
}
|
||
|
||
.preview-thumb-icon {
|
||
color: var(--muted-foreground);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* Upload progress overlay */
|
||
.upload-progress-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: color-mix(in oklch, var(--background) 80%, transparent);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.25rem;
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
.upload-progress-bar {
|
||
width: 80%;
|
||
height: 4px;
|
||
background: var(--muted);
|
||
border-radius: 9999px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.upload-progress-fill {
|
||
height: 100%;
|
||
background: var(--primary);
|
||
border-radius: 9999px;
|
||
transition: width 0.2s;
|
||
}
|
||
|
||
.upload-progress-text {
|
||
font-size: 0.65rem;
|
||
font-weight: 600;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
/* Upload error overlay */
|
||
.upload-error-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: color-mix(in oklch, var(--destructive) 20%, transparent);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--destructive);
|
||
}
|
||
|
||
/* Footer row: info + remove button */
|
||
.preview-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.2rem;
|
||
padding: 0.3rem 0.35rem 0.3rem 0.45rem;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
/* Info */
|
||
.preview-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.1rem;
|
||
}
|
||
|
||
.preview-name {
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
color: var(--foreground);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
margin: 0;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.preview-size {
|
||
font-size: 0.65rem;
|
||
color: var(--muted-foreground);
|
||
margin: 0;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
/* Remove button */
|
||
.preview-remove {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--muted-foreground);
|
||
cursor: pointer;
|
||
transition:
|
||
background 0.15s,
|
||
color 0.15s;
|
||
padding: 0;
|
||
}
|
||
|
||
.preview-remove:hover {
|
||
background: color-mix(in oklch, var(--destructive) 15%, transparent);
|
||
color: var(--destructive);
|
||
}
|
||
</style>
|