Advanced Media Uploader
A complete media field — a Quasar-based extension, built from Quasar controls and driven by one ivue class, showing how ivue works WITH an existing UI framework: drag-and-drop or picked uploads, per-file rows with inline rename, a lightbox preview dialog, download and delete, and — missing from Quasar's stock uploader — preexisting media: hand the field bare server ids and it hydrates the rows itself. Extracted from a production application built on ivue, with the app's service layer swapped for the playground's ServerApi. In the playground the bytes land in your browser's IndexedDB with canvas-generated thumbnails; against the reference server the same component uploads to disk or S3 with sharp-generated thumbnails.
Open in StackBlitz ⚡ — boots the playground on this example's route with the class open.
The performance story
MediaField.ts derives 28 values through plain getters and zero computed()s — tile URLs, human-readable sizes, capacity checks, accept-list parsing, all at zero bytes per instance. The extension class adds one computed() (sortedFiles) because a whole-list sort deserves a memo. A component this rich, with a reactive footprint this small, is the argument the performance page makes in numbers.
Extension, two mechanisms at once
ExtendedMediaField is the showcase the base component was built for:
- Extend the class —
$ExtendedMediaField extends MediaField.$Classadds sort-by-name/newest, image dimensions (read viacreateImageBitmapon upload), captions persisted through the backend, copy-URL, and a total-size summary. The base SFC accepts the child class through itsrunnerprop. - Inject templates — the base template exposes
before--/after--slots at the header, tiles and actions; the extended variant decorates them without duplicating a line of the base template.
Inheritance in ivue is native-JS inheritance — super, overridden getters, all of it (Inheritance & super) — so "variant of a complex component" is a subclass, not a fork.
What to notice in the playground
- The preexisting-media section starts full. Its model is three bare ids; the field fetched the rows from the backend on its own — the attach-existing-documents flow every real app needs and stock
QUploadercannot express. - Drop several images at once — each uploads through ServerApi, thumbnails appear as the backend responds.
- Click a tile for the lightbox: prev/next, name, size, download.
- Rename inline; the change persists through the backend.
- The extended variant shows dimensions badges, captions and the size summary — same base template, one subclass.
The source
// MediaField.ts — the Advanced Media Uploader engine class.
//
// Derivation budget: 28 PLAIN getters (0 bytes/instance, reactive via leaf
// tracking) and 0 computed() in this class. No derivation here is expensive
// enough to earn the ~300 bytes/instance a computed costs — each one is a
// cheap expression over already-cached state or props. The one computed
// that DOES earn it lives in ExtendedMediaField (a whole-list sort, cached
// so a render doesn't re-sort per read).
import { ref, watch } from 'vue';
import { Reactive } from '../../../ivue';
import { ServerApi } from '../server/ServerApi';
import type {
MediaFieldEmits,
MediaFieldModel,
MediaFieldProps,
MediaItem,
} from './MediaFieldProps';
export class $MediaField {
constructor(
public props: MediaFieldProps,
public emit: MediaFieldEmits,
) {
watch(
() => this.props.modelValue,
(value) => this.hydrateModel(value),
{ immediate: true },
);
}
// --- state ---
get files() {
return ref<MediaItem[]>([]);
}
get isUploading() {
return ref(false);
}
get uploadingCount() {
return ref(0);
}
get isHydrating() {
return ref(false);
}
get isDragOver() {
return ref(false);
}
get errorMessage() {
return ref<string | null>(null);
}
get renameId() {
return ref<string | null>(null);
}
get renameDraft() {
return ref('');
}
get previewOpen() {
return ref(false);
}
get previewIndex() {
return ref(0);
}
get fileInput() {
return ref<HTMLInputElement | null>(null);
}
// --- props ---
get multiple() {
return this.props.multiple;
}
get accept() {
return this.props.accept;
}
get maxFileSize() {
return this.props.maxFileSize;
}
get label() {
return this.props.label;
}
get hint() {
return this.props.hint;
}
get readonly() {
return this.props.readonly;
}
get disable() {
return this.props.disable;
}
get dense() {
return this.props.dense;
}
get canPreview() {
return this.props.canPreview;
}
get canDownload() {
return this.props.canDownload;
}
get canRename() {
return this.props.canRename && this.isInteractive;
}
get canRenameCaption() {
return this.props.canRenameCaption && this.isInteractive;
}
get canRemove() {
return this.props.canRemove && this.isInteractive;
}
// --- derived ---
get canSort() {
return this.multiple && this.isInteractive && this.files.value.length > 1;
}
get dragIndex() {
return ref<number | null>(null);
}
get isInteractive() {
return !this.props.readonly && !this.props.disable;
}
get maxFiles() {
return this.multiple ? this.props.maxFiles : 1;
}
get remainingSlots() {
return Math.max(0, this.maxFiles - this.files.value.length);
}
get canAddMore() {
return this.isInteractive && this.remainingSlots > 0;
}
get hasFiles() {
return this.files.value.length > 0;
}
get fileCountLabel() {
return `${this.files.value.length} / ${this.maxFiles}`;
}
/** The list the template renders — subclasses override to reorder. */
get displayFiles(): MediaItem[] {
return this.files.value;
}
get acceptedTypesText() {
return this.acceptTokens.join(', ');
}
get acceptTokens() {
return this.accept
.split(',')
.map((token) => token.trim().toLowerCase())
.filter(Boolean);
}
get maxFileSizeLabel() {
return this.formatBytes(this.maxFileSize, 0);
}
get dropHint() {
return this.multiple
? 'Drop files here or click to browse'
: 'Drop a file here or click to browse';
}
get uploadProgressLabel() {
const count = this.uploadingCount.value;
return `Uploading ${count} file${count === 1 ? '' : 's'}…`;
}
get thumbnailStyle() {
return { width: this.props.thumbnailSize + 'px' };
}
get activeFile(): MediaItem | null {
return this.displayFiles[this.previewIndex.value] ?? null;
}
get hasError() {
return !!this.errorMessage.value;
}
// --- model hydration & emission ---
/** Bare ids in the model are fetched; rows are used as-is. Echoes of our own emit are skipped by id-comparison. */
async hydrateModel(value: MediaFieldModel | undefined) {
const entries =
value == null || value === ''
? []
: Array.isArray(value)
? value
: [value];
const limited = this.multiple ? entries : entries.slice(0, 1);
const ids = limited.map((entry) =>
typeof entry === 'string' ? entry : entry.id,
);
if (ids.join(',') === this.files.value.map((row) => row.id).join(','))
return;
const missingIds = limited.filter(
(entry): entry is string => typeof entry === 'string',
);
const fetchedRows = new Map<string, MediaItem>();
if (missingIds.length) {
this.isHydrating.value = true;
try {
for (const row of await ServerApi.media.get(missingIds)) {
fetchedRows.set(row.id, row);
}
} catch {
this.setError('Failed to load existing media.');
} finally {
this.isHydrating.value = false;
}
}
const rows: MediaItem[] = [];
for (const entry of limited) {
if (typeof entry === 'string') {
const fetched = fetchedRows.get(entry);
if (fetched) rows.push(fetched);
} else {
rows.push(entry);
}
}
this.files.value = rows;
this.onFilesLoaded(rows);
}
/** Hook for subclasses — runs after hydration replaces the list. */
onFilesLoaded(rows: MediaItem[]) {}
moveFile(fromIndex: number, toIndex: number) {
const list = this.files.value;
if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= list.length ||
toIndex >= list.length
) {
return;
}
const [moved] = list.splice(fromIndex, 1);
list.splice(toIndex, 0, moved);
this.files.value = [...list];
this.emitModel();
}
fileMoveUp(index: number) {
this.moveFile(index, index - 1);
}
fileMoveDown(index: number) {
this.moveFile(index, index + 1);
}
onRowDragStart(index: number) {
this.dragIndex.value = index;
}
onRowDrop(index: number) {
if (this.dragIndex.value !== null) {
this.moveFile(this.dragIndex.value, index);
}
this.dragIndex.value = null;
}
emitModel() {
const rows = this.files.value;
this.emit(
'update:modelValue',
this.multiple ? [...rows] : (rows[0] ?? null),
);
}
// --- picking & drag-drop ---
pickFiles() {
if (this.canAddMore) this.fileInput.value?.click();
}
onFilesPicked(event: Event) {
const input = event.target as HTMLInputElement;
const pickedFiles = Array.from(input.files ?? []);
input.value = '';
void this.uploadFiles(pickedFiles);
}
onDragOver(event: DragEvent) {
event.preventDefault();
if (this.canAddMore) this.isDragOver.value = true;
}
onDragLeave() {
this.isDragOver.value = false;
}
onDrop(event: DragEvent) {
event.preventDefault();
this.isDragOver.value = false;
if (!this.canAddMore) return;
void this.uploadFiles(Array.from(event.dataTransfer?.files ?? []));
}
// --- upload ---
async uploadFiles(pickedFiles: File[]) {
this.clearError();
const acceptedFiles = this.validateFiles(pickedFiles);
if (!acceptedFiles.length) return;
this.isUploading.value = true;
this.uploadingCount.value = acceptedFiles.length;
try {
const rows = await ServerApi.media.upload(acceptedFiles);
this.onUploaded(rows);
} catch {
this.setError('Upload failed.');
} finally {
this.isUploading.value = false;
this.uploadingCount.value = 0;
}
}
/** Hook for subclasses — appends the uploaded rows and reports them. */
onUploaded(rows: MediaItem[]) {
this.files.value.push(...rows);
this.emitModel();
this.emit('uploaded', rows);
}
/** Filters by accept list, size limit, and remaining slots; reports every rejection. */
validateFiles(pickedFiles: File[]): File[] {
const problems: string[] = [];
const acceptedFiles: File[] = [];
for (const file of pickedFiles) {
if (!this.isAccepted(file)) {
problems.push(`${file.name} is not an accepted type.`);
} else if (file.size > this.maxFileSize) {
problems.push(
`${file.name} exceeds the ${this.maxFileSizeLabel} limit.`,
);
} else {
acceptedFiles.push(file);
}
}
if (acceptedFiles.length > this.remainingSlots) {
problems.push(`Only ${this.maxFiles} file(s) allowed.`);
acceptedFiles.length = this.remainingSlots;
}
if (problems.length) this.setError(problems.join(' '));
return acceptedFiles;
}
isAccepted(file: File): boolean {
if (!this.acceptTokens.length) return true;
const fileName = file.name.toLowerCase();
const mimetype = file.type.toLowerCase();
return this.acceptTokens.some((token) => {
if (token.startsWith('.')) return fileName.endsWith(token);
if (token.endsWith('/*')) return mimetype.startsWith(token.slice(0, -1));
return mimetype === token;
});
}
// --- remove / rename / download ---
async removeFile(row: MediaItem) {
if (!this.canRemove) return;
try {
await ServerApi.media.remove(row.id);
} catch {
this.setError(`Failed to remove ${row.name}.`);
return;
}
const foundIndex = this.files.value.findIndex(
(existing) => existing.id === row.id,
);
if (foundIndex !== -1) this.files.value.splice(foundIndex, 1);
if (this.previewIndex.value >= this.files.value.length) {
this.previewIndex.value = Math.max(0, this.files.value.length - 1);
}
if (!this.hasFiles) this.previewOpen.value = false;
this.emitModel();
this.emit('removed', row);
}
startRename(row: MediaItem) {
if (!this.canRename) return;
this.renameId.value = row.id;
this.renameDraft.value = this.baseName(row.name);
}
async commitRename() {
const renameId = this.renameId.value;
const row = this.files.value.find((existing) => existing.id === renameId);
if (!row) return this.cancelRename();
const draft = this.renameDraft.value.trim();
if (!draft || !this.validFilenameRegExp.test(draft)) {
this.setError('Invalid file name.');
return;
}
const extension = this.extensionOf(row.name);
const newName = extension ? `${draft}.${extension}` : draft;
try {
await ServerApi.media.update({ id: row.id, name: newName });
row.name = newName;
this.clearError();
this.emitModel();
} catch {
this.setError(`Failed to rename ${row.name}.`);
}
this.renameId.value = null;
}
cancelRename() {
this.renameId.value = null;
}
downloadFile(row: MediaItem) {
const anchor = document.createElement('a');
anchor.href = row.url;
anchor.download = row.name;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
// --- preview navigation ---
openPreview(index: number) {
if (!this.canPreview) return;
this.previewIndex.value = index;
this.previewOpen.value = true;
}
closePreview() {
this.previewOpen.value = false;
}
nextPreview() {
const count = this.displayFiles.length;
if (count) this.previewIndex.value = (this.previewIndex.value + 1) % count;
}
prevPreview() {
const count = this.displayFiles.length;
if (count) {
this.previewIndex.value = (this.previewIndex.value - 1 + count) % count;
}
}
// --- per-row helpers ---
isImage(row: MediaItem): boolean {
return row.mimetype.startsWith('image/');
}
fileExtension(row: MediaItem): string {
return this.extensionOf(row.name).toUpperCase();
}
fileIcon(row: MediaItem): string {
if (row.mimetype === 'application/pdf') return 'picture_as_pdf';
if (row.mimetype.startsWith('video/')) return 'movie';
if (row.mimetype.startsWith('audio/')) return 'audiotrack';
return 'insert_drive_file';
}
sizeLabel(row: MediaItem): string {
return this.formatBytes(row.size);
}
baseName(filename: string): string {
const dotIndex = filename.lastIndexOf('.');
return dotIndex > 0 ? filename.slice(0, dotIndex) : filename;
}
extensionOf(filename: string): string {
const dotIndex = filename.lastIndexOf('.');
return dotIndex > 0 ? filename.slice(dotIndex + 1) : '';
}
formatBytes(bytes: number, decimals = 1): string {
if (!+bytes) return '0 B';
const kilobyte = 1024;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const unitIndex = Math.min(
units.length - 1,
Math.floor(Math.log(bytes) / Math.log(kilobyte)),
);
const amount = bytes / Math.pow(kilobyte, unitIndex);
return `${parseFloat(amount.toFixed(decimals))} ${units[unitIndex]}`;
}
// --- errors ---
setError(message: string) {
this.errorMessage.value = message;
this.emit('error', message);
}
clearError() {
this.errorMessage.value = null;
}
/** Rejects Windows/macOS-forbidden characters and leading dots/spaces. */
validFilenameRegExp = /^(?!^[ .])(?!.*[/\\:*?"<>|])(?![. ]+$)[^/\\:*?"<>|]+$/;
}
export namespace MediaField {
export const $Class = $MediaField; // raw — children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance;
}<script lang="ts" setup>
// MediaField.vue — the Advanced Media Uploader.
//
// Two extension mechanisms are showcased here:
// 1. The `runner` prop swaps the driving CLASS (any MediaField subclass).
// 2. Every slot has `before--`/`after--` variants (ExtendSlots) — a parent
// injects templates at any point without duplicating this file — and the
// `item` slot REPLACES the whole per-file row (ExtendedMediaField.vue
// swaps the list rows for a square-tile grid through it).
// ExtendedMediaField.vue uses BOTH at once.
import {
QBadge,
QBtn,
QIcon,
QLinearProgress,
QSpinner,
QTooltip,
} from 'quasar';
import { MediaField } from './MediaField';
import MediaFieldPreviewDialog from './MediaFieldPreviewDialog.vue';
import {
mediaFieldEmits,
mediaFieldProps,
type MediaFieldEmits,
type MediaFieldSlots,
} from './MediaFieldProps';
const props = defineProps(mediaFieldProps);
/** Object-declared emits — ExtractEmitTypes derives the class-facing type. */
const emit = defineEmits(mediaFieldEmits) as MediaFieldEmits;
defineSlots<MediaFieldSlots>();
/** Runner implementation: swap the class that drives this component. */
const RunnerClass = (props.runner ?? MediaField.Class) as typeof MediaField.Class;
const media = new RunnerClass(props, emit);
const {
// state refs
isUploading,
isHydrating,
isDragOver,
errorMessage,
renameId,
renameDraft,
previewOpen,
// element refs
fileInput,
} = media;
defineExpose(media as MediaField.Instance);
</script>
<template>
<div
class="media-field"
:class="{
'media-field--dense': media.dense,
'media-field--disabled': media.disable,
'media-field--readonly': media.readonly,
'media-field--error': media.hasError,
}"
>
<!-- HEADER -->
<slot name="before--header" :field="media" />
<slot name="header" :field="media">
<div class="media-field__header">
<span class="media-field__label">{{ media.label }}</span>
<q-badge
v-if="media.hasFiles"
rounded
color="grey-3"
text-color="black"
class="media-field__count"
:label="media.fileCountLabel"
/>
<q-spinner v-if="isUploading || isHydrating" size="16px" :thickness="2" />
</div>
</slot>
<slot name="after--header" :field="media" />
<!-- DROP ZONE -->
<div
class="media-field__dropzone"
:class="{
'media-field__dropzone--over': isDragOver,
'media-field__dropzone--clickable': media.canAddMore,
}"
@dragover="media.onDragOver"
@dragleave="media.onDragLeave"
@drop="media.onDrop"
>
<input
ref="fileInput"
type="file"
class="media-field__native-input"
:accept="media.accept"
:multiple="media.multiple"
@change="media.onFilesPicked"
/>
<!-- EMPTY STATE -->
<template v-if="!media.hasFiles">
<slot name="before--empty" :field="media" />
<slot name="empty" :field="media">
<div class="media-field__empty" @click="media.pickFiles()">
<q-icon name="cloud_upload" size="34px" color="grey-6" />
<div class="media-field__empty-hint">{{ media.dropHint }}</div>
<div class="media-field__empty-types">
{{ media.acceptedTypesText }} — up to
{{ media.maxFileSizeLabel }} each
</div>
</div>
</slot>
<slot name="after--empty" :field="media" />
</template>
<!-- FILE LIST — v1-style rows: [thumb] [name / caption / meta] [remove] -->
<div v-else class="media-field__list">
<div
v-for="(row, index) in media.displayFiles"
:key="row.id"
class="media-field__item"
:class="{ 'media-field__item--dragging': media.dragIndex.value === index }"
:draggable="media.canSort"
@dragstart="media.onRowDragStart(index)"
@dragover.prevent
@drop.prevent="media.onRowDrop(index)"
@dragend="media.dragIndex.value = null"
>
<slot name="before--item" :row="row" :index="index" :field="media" />
<slot name="item" :row="row" :index="index" :field="media">
<!-- SORT BUTTONS — visible on hover, like the original -->
<div v-if="media.canSort" class="media-field__sort" @click.stop>
<q-btn
icon="arrow_upward"
flat
size="xs"
round
:disable="index === 0"
@click="media.fileMoveUp(index)"
>
<q-tooltip class="bg-grey-9">Move up</q-tooltip>
</q-btn>
<q-btn
icon="arrow_downward"
flat
size="xs"
round
:disable="index === media.displayFiles.length - 1"
@click="media.fileMoveDown(index)"
>
<q-tooltip class="bg-grey-9">Move down</q-tooltip>
</q-btn>
</div>
<!-- THUMBNAIL BOX -->
<div
class="media-field__thumb"
:class="{ 'media-field__thumb--clickable': media.canPreview }"
@click="media.openPreview(index)"
>
<img
v-if="media.isImage(row)"
:src="row.thumbnailUrl"
:alt="row.name"
class="media-field__thumb-image"
/>
<div v-else class="media-field__thumb-extension">
{{ media.fileExtension(row) }}
</div>
<!-- DOWNLOAD — overlays the thumb's bottom-right corner -->
<div class="media-field__thumb-download" @click.stop>
<q-btn
v-if="media.canDownload"
dense
unelevated
size="10px"
padding="2px 4px"
icon="download"
class="media-field__download-btn"
@click="media.downloadFile(row)"
>
<q-tooltip class="bg-grey-9">Download</q-tooltip>
</q-btn>
</div>
</div>
<!-- FILE DETAILS -->
<div class="media-field__details">
<!-- FILE NAME — inline rename (v1 borderless input) -->
<input
v-if="media.canRename"
class="media-field__filename-input"
placeholder="File Name"
type="text"
:value="renameId === row.id ? renameDraft : row.name"
:title="row.name"
@focus="media.startRename(row)"
@input="renameDraft = ($event.target as HTMLInputElement).value"
@keydown.enter.prevent="media.commitRename()"
@keydown.esc="media.cancelRename()"
@blur="media.commitRename()"
/>
<div v-else class="media-field__item-name" :title="row.name">
{{ row.name }}
</div>
<!-- FILE CAPTION -->
<input
v-if="media.canRenameCaption"
v-model="row.caption"
class="media-field__filename-input media-field__caption-line"
placeholder="File Caption"
type="text"
@keydown.enter="(event) => event.preventDefault()"
/>
<div v-else-if="row.caption" class="media-field__caption-line">
{{ row.caption }}
</div>
<!-- EXTENSION BADGE / SIZE / STATUS -->
<div class="media-field__meta">
<q-badge
color="grey-3"
text-color="black"
class="media-field__meta-extension"
:label="media.fileExtension(row)"
/>
<span class="media-field__meta-size">
{{ media.sizeLabel(row) }}
</span>
<span class="media-field__meta-status">
<q-icon name="check_circle" size="12px" color="positive" />
Uploaded
</span>
</div>
</div>
<!-- ROW ACTIONS — remove at the far right, top-aligned -->
<div class="media-field__row-side" @click.stop>
<slot
name="before--item-actions"
:row="row"
:index="index"
:field="media"
/>
<slot
name="item-actions"
:row="row"
:index="index"
:field="media"
/>
<slot
name="after--item-actions"
:row="row"
:index="index"
:field="media"
/>
<q-btn
v-if="media.canRemove"
dense
flat
round
size="10px"
icon="close"
@click="media.removeFile(row)"
>
<q-tooltip class="bg-grey-9">Remove</q-tooltip>
</q-btn>
</div>
</slot>
<slot name="after--item" :row="row" :index="index" :field="media" />
</div>
<!-- ADD-MORE AFFORDANCE -->
<div
v-if="media.canAddMore"
class="media-field__add"
@click="media.pickFiles()"
>
<q-icon name="attach_file" size="16px" />
<span class="media-field__add-label">Add files</span>
</div>
</div>
<q-linear-progress
v-if="isUploading"
indeterminate
color="primary"
class="media-field__progress"
/>
<div v-if="isUploading" class="media-field__progress-label">
{{ media.uploadProgressLabel }}
</div>
</div>
<!-- ERROR / HINT LINE -->
<div v-if="media.hasError" class="media-field__bottom media-field__bottom--error">
{{ errorMessage }}
</div>
<div v-else-if="media.hint" class="media-field__bottom">
{{ media.hint }}
</div>
<!-- PREVIEW LIGHTBOX -->
<MediaFieldPreviewDialog v-model="previewOpen" :field="media" />
</div>
</template>
<style scoped>
.media-field {
width: 100%;
}
.media-field--disabled {
opacity: 0.6;
pointer-events: none;
}
.media-field__header {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 6px;
}
.media-field__label {
font-size: 14px;
color: color-mix(in srgb, currentColor 70%, transparent);
}
.media-field--error .media-field__label {
color: var(--q-negative, #c10015);
}
.media-field__count {
font-size: 10px;
}
.media-field__dropzone {
position: relative;
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
border-radius: 6px;
background: color-mix(in srgb, currentColor 3%, transparent);
transition: border-color 0.2s, background 0.2s;
}
.media-field__dropzone--over {
border-color: var(--q-primary, #1976d2);
background: rgba(25, 118, 210, 0.06);
}
.media-field--error .media-field__dropzone {
border-color: var(--q-negative, #c10015);
}
.media-field__native-input {
display: none;
}
.media-field__empty {
padding: 26px 16px;
text-align: center;
cursor: pointer;
user-select: none;
}
.media-field--dense .media-field__empty {
padding: 14px 12px;
}
.media-field__empty-hint {
margin-top: 6px;
font-size: 13px;
color: color-mix(in srgb, currentColor 70%, transparent);
}
.media-field__empty-types {
margin-top: 2px;
font-size: 13px;
color: color-mix(in srgb, currentColor 45%, transparent);
}
/* --- v1 LIST-ROW LAYOUT --- */
.media-field__list {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
}
.media-field--dense .media-field__list {
gap: 2px;
padding: 4px;
}
/* Each file is a full-width horizontal row. */
.media-field__item {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 5px 6px;
border-radius: 4px;
transition: background 0.3s;
}
.media-field__item:hover {
background: color-mix(in srgb, currentColor 4%, transparent);
}
/* Thumbnail box — v1: max-width 120px, height 60px, inset-shadow well. */
.media-field__thumb {
position: relative;
flex: 0 0 120px;
max-width: 120px;
height: 60px;
border-radius: 4px;
overflow: hidden;
background: color-mix(in srgb, currentColor 8%, transparent);
box-shadow: inset 2px 0 10px 5px
color-mix(in srgb, currentColor 7%, transparent);
display: flex;
align-items: center;
justify-content: center;
}
.media-field__thumb--clickable {
cursor: pointer;
}
.media-field__thumb-image {
width: 100%;
height: 100%;
aspect-ratio: 16 / 9;
object-fit: contain;
display: block;
}
.media-field__thumb-extension {
font-size: 24px;
letter-spacing: 1px;
text-transform: uppercase;
color: color-mix(in srgb, currentColor 45%, transparent);
}
.media-field__thumb-download {
position: absolute;
right: 4px;
bottom: 4px;
}
.media-field__download-btn {
border-radius: 3px;
background: color-mix(in srgb, currentColor 12%, transparent);
color: var(--q-primary, #1976d2);
}
/* Details column — name / caption / meta line. */
.media-field__details {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
padding-top: 2px;
}
.media-field__item-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* v1 inline-rename input — borderless; the border materializes on row hover. */
.media-field__filename-input {
width: 100%;
font-size: 12px;
color: inherit;
background: none;
outline: none;
border: 0;
border-bottom: 1px solid transparent;
line-height: 14px;
padding: 0 0 1px;
transition: border-color 0.3s;
}
.media-field__item:hover .media-field__filename-input {
border-bottom-color: color-mix(in srgb, currentColor 22%, transparent);
}
.media-field__item:hover .media-field__filename-input:hover {
border-bottom-color: color-mix(in srgb, currentColor 45%, transparent);
}
.media-field__filename-input:focus,
.media-field__item:hover .media-field__filename-input:focus {
border-bottom-color: var(--q-primary, #1976d2);
}
.media-field__caption-line {
color: color-mix(in srgb, currentColor 55%, transparent);
}
.media-field__caption-line::placeholder {
color: color-mix(in srgb, currentColor 35%, transparent);
}
/* Meta line — extension badge + size + status. */
.media-field__meta {
display: flex;
flex-wrap: wrap;
min-width: 0;
align-items: center;
gap: 4px 8px;
font-size: 11px;
color: color-mix(in srgb, currentColor 55%, transparent);
}
.media-field__meta-extension {
font-size: 10px;
text-transform: uppercase;
}
.media-field__meta-size {
text-transform: uppercase;
}
.media-field__meta-status {
display: inline-flex;
align-items: center;
gap: 3px;
text-transform: uppercase;
}
/* Remove button cluster — far right, top-aligned. */
.media-field__row-side {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 2px;
align-self: flex-start;
color: color-mix(in srgb, currentColor 60%, transparent);
}
/* Add-more affordance — a solid full-width row. */
.media-field__add {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
border-radius: 4px;
cursor: pointer;
color: color-mix(in srgb, currentColor 60%, transparent);
transition: border-color 0.2s;
}
.media-field__add:hover {
border-color: var(--q-primary, #1976d2);
}
.media-field__add-label {
font-size: 13.5px;
font-weight: 500;
}
.media-field__progress {
border-radius: 0 0 6px 6px;
}
.media-field__progress-label {
position: absolute;
right: 8px;
bottom: 6px;
font-size: 11px;
color: color-mix(in srgb, currentColor 55%, transparent);
}
/*
* Hint/error line with native-Quasar spacing. The v1 field let the hint
* crowd the control (no top padding — a long-standing bug); Quasar's own
* .q-field__bottom reserves breathing room, so we match it here.
*/
.media-field__bottom {
padding-top: 6px;
font-size: 12px;
line-height: 1;
min-height: 18px;
color: color-mix(in srgb, currentColor 60%, transparent);
}
.media-field__bottom--error {
color: var(--q-negative, #c10015);
}
/* sort buttons: hidden until the row is hovered, like the original */
.media-field__sort {
display: flex;
flex-direction: column;
justify-content: center;
visibility: hidden;
opacity: 0.7;
}
.media-field__item:hover .media-field__sort {
visibility: visible;
}
.media-field__item--dragging {
opacity: 0.45;
}
</style>// MediaFieldProps.ts — the params-types + params-defaults architecture.
//
// Types and defaults are declared as two separate literals so a child
// component (or app) can spread-and-extend either one, then
// `propsWithDefaults` fuses them into a defineComponent()-style props
// object. Emits are object-declared and extracted with ExtractEmitTypes;
// slots gain automatic `before--`/`after--` extension points via ExtendSlots.
import type { ExtractPropTypes, PropType } from 'vue';
import {
propsWithDefaults,
type ExtractEmitTypes,
type ExtractPropDefaultTypes,
type ExtendSlots,
} from '../../../ivue';
import type { MediaRow } from '../server/ServerApi';
/** A media row plus the client-editable caption. */
export type MediaItem = MediaRow & { caption?: string };
/**
* The model accepts rows, bare ids, or a mix — ids are hydrated through
* `ServerApi.media.get`. Single mode holds one entry (or null), multiple
* mode holds an array.
*/
export type MediaFieldModel =
| MediaItem
| string
| Array<MediaItem | string>
| null;
/** Params Types */
export const mediaFieldParamsTypes = {
multiple: { type: Boolean as PropType<boolean> },
/** Comma-separated accept list — extensions (`.pdf`) and mime patterns (`image/*`). */
accept: { type: String as PropType<string> },
maxFiles: { type: Number as PropType<number> },
/** Per-file limit in bytes. */
maxFileSize: { type: Number as PropType<number> },
label: { type: String as PropType<string> },
hint: { type: String as PropType<string> },
readonly: { type: Boolean as PropType<boolean> },
disable: { type: Boolean as PropType<boolean> },
dense: { type: Boolean as PropType<boolean> },
/** Preview grid tile size in pixels. */
thumbnailSize: { type: Number as PropType<number> },
canPreview: { type: Boolean as PropType<boolean> },
canDownload: { type: Boolean as PropType<boolean> },
canRename: { type: Boolean as PropType<boolean> },
canRenameCaption: { type: Boolean as PropType<boolean> },
canRemove: { type: Boolean as PropType<boolean> },
/**
* Runner implementation — pass a subclass's `Class` to swap the logic
* that drives the component (the class-extension showcase).
* @see ExtendedMediaField.vue
*/
runner: { type: Function as PropType<any> },
};
/** Params Defaults */
export const mediaFieldParamsDefaults: ExtractPropDefaultTypes<
typeof mediaFieldParamsTypes
> = {
multiple: false,
accept: '.pdf, image/*',
maxFiles: 12,
maxFileSize: 100 * 1024 * 1024, // 100 MB
label: 'Media',
hint: '',
readonly: false,
disable: false,
dense: false,
thumbnailSize: 132,
canPreview: true,
canDownload: true,
canRename: true,
canRenameCaption: true,
canRemove: true,
runner: null,
};
/** Generated Params */
export const mediaFieldParams = propsWithDefaults(
mediaFieldParamsDefaults,
mediaFieldParamsTypes,
);
export type MediaFieldParams = ExtractPropTypes<typeof mediaFieldParams>;
/** Props */
export const mediaFieldProps = {
modelValue: {
type: [Object, Array, String] as PropType<MediaFieldModel>,
default: null,
},
...mediaFieldParams,
};
export type MediaFieldProps = ExtractPropTypes<typeof mediaFieldProps>;
/** Emits */
export const mediaFieldEmits = {
'update:modelValue': (value: MediaFieldModel) => true,
uploaded: (rows: MediaItem[]) => true,
removed: (row: MediaItem) => true,
error: (message: string) => true,
};
export type MediaFieldEmits = ExtractEmitTypes<typeof mediaFieldEmits>;
/**
* Slot props. `field` is typed loose on purpose: the runner prop can swap
* in ANY MediaField subclass, and injected templates call the subclass's
* extra members (see ExtendedMediaField.vue).
*/
export interface MediaFieldSlotProps {
field: any;
}
export interface MediaFieldItemSlotProps extends MediaFieldSlotProps {
row: MediaItem;
index: number;
}
/**
* Base slots — ExtendSlots wraps every one with `before--` & `after--`
* variants, the template-injection mechanism extended components use.
*/
export type MediaFieldSlots = ExtendSlots<{
header(slotProps: MediaFieldSlotProps): any;
empty(slotProps: MediaFieldSlotProps): any;
item(slotProps: MediaFieldItemSlotProps): any;
'item-actions'(slotProps: MediaFieldItemSlotProps): any;
}>;<script lang="ts">
// MediaFieldPreviewDialog — a QDialog lightbox over the field's media list.
//
// Derivations: 10 plain getters (0 bytes/instance) and 1 computed() —
// `isOpenModel`, a WRITABLE computed, which earns its ~300 bytes because a
// v-model target must be a stable ref handle with a setter.
import { computed, ref } from 'vue';
import { Reactive } from '../../../ivue';
import type { MediaField } from './MediaField';
import type { MediaItem } from './MediaFieldProps';
export interface MediaFieldPreviewDialogProps {
/** The owning field instance — the dialog navigates ITS preview state. */
field: MediaField.Instance;
/** Dialog open state (v-model). */
modelValue: boolean;
}
export interface MediaFieldPreviewDialogEmits {
(event: 'update:modelValue', open: boolean): void;
}
class $MediaFieldPreviewDialog {
constructor(
public props: MediaFieldPreviewDialogProps,
public emit: MediaFieldPreviewDialogEmits,
) {}
// --- state ---
get isImageLoading() {
return ref(false);
}
get isMaximized() {
return ref(false);
}
// --- derived ---
get field() {
return this.props.field;
}
get activeFile(): MediaItem | null {
return this.field.activeFile;
}
get isActiveImage() {
return !!this.activeFile && this.field.isImage(this.activeFile);
}
get hasMultiple() {
return this.field.displayFiles.length > 1;
}
get positionLabel() {
return `${this.field.previewIndex.value + 1} / ${this.field.displayFiles.length}`;
}
get activeSizeLabel() {
return this.activeFile ? this.field.sizeLabel(this.activeFile) : '';
}
get activeExtension() {
return this.activeFile ? this.field.fileExtension(this.activeFile) : '';
}
get isRenamingActive() {
return (
!!this.activeFile && this.field.renameId.value === this.activeFile.id
);
}
beginRename() {
if (this.activeFile) this.field.startRename(this.activeFile);
}
get maximizeIcon() {
return this.isMaximized.value ? 'close_fullscreen' : 'open_in_full';
}
get isOpenModel() {
return computed({
get: () => this.isOpenValue(),
set: (open: boolean) => this.setOpen(open),
});
}
// --- methods ---
isOpenValue() {
return this.props.modelValue;
}
setOpen(open: boolean) {
this.emit('update:modelValue', open);
}
close() {
this.setOpen(false);
}
showPrev() {
this.isImageLoading.value = true;
this.field.prevPreview();
}
showNext() {
this.isImageLoading.value = true;
this.field.nextPreview();
}
onImageLoaded() {
this.isImageLoading.value = false;
}
download() {
if (this.activeFile) this.field.downloadFile(this.activeFile);
}
toggleMaximized() {
this.isMaximized.value = !this.isMaximized.value;
}
}
export namespace MediaFieldPreviewDialog {
export const $Class = $MediaFieldPreviewDialog;
export let Class = Reactive($Class);
export type Instance = typeof Class.Instance;
}
</script>
<script lang="ts" setup>
import { QBtn, QCard, QDialog, QIcon, QSpinner, QTooltip } from 'quasar';
const props = defineProps<MediaFieldPreviewDialogProps>();
const emit = defineEmits<MediaFieldPreviewDialogEmits>();
const dialog = new MediaFieldPreviewDialog.Class(props, emit);
// the field's rename draft drives the footer's inline editor
const { renameDraft } = props.field;
const {
// state refs
isImageLoading,
isMaximized,
// computed refs
isOpenModel,
} = dialog;
defineExpose(dialog as MediaFieldPreviewDialog.Instance);
</script>
<template>
<q-dialog
v-model="isOpenModel"
class="media-preview"
:maximized="isMaximized"
no-shake
>
<q-card
class="media-preview__card"
:class="{ 'media-preview__card--maximized': isMaximized }"
>
<!-- STAGE -->
<div class="media-preview__stage" v-if="dialog.activeFile">
<img
v-if="dialog.isActiveImage"
:key="dialog.activeFile.id"
:src="dialog.activeFile.url"
class="media-preview__image"
@load="dialog.onImageLoaded()"
@error="dialog.onImageLoaded()"
/>
<div v-else class="media-preview__placeholder">
<q-icon
:name="dialog.field.fileIcon(dialog.activeFile)"
size="72px"
color="grey-5"
/>
<div class="media-preview__placeholder-extension">
{{ dialog.activeExtension }}
</div>
</div>
<q-spinner
v-if="isImageLoading"
class="media-preview__spinner"
color="white"
size="42px"
:thickness="3"
/>
</div>
<!-- FOOTER -->
<div class="media-preview__footer" v-if="dialog.activeFile">
<div class="media-preview__details">
<div
v-if="!dialog.isRenamingActive"
class="media-preview__name"
:class="{ 'media-preview__name--editable': dialog.field.canRename }"
:title="dialog.activeFile.name"
@click="dialog.beginRename()"
>
{{ dialog.activeFile.name }}
<q-icon
v-if="dialog.field.canRename"
name="edit"
size="14px"
class="media-preview__name-edit"
/>
</div>
<input
v-else
class="media-preview__name-input"
:value="renameDraft"
autofocus
@input="(event: any) => (renameDraft = event.target.value)"
@keyup.enter="dialog.field.commitRename()"
@keyup.esc="dialog.field.cancelRename()"
@blur="dialog.field.commitRename()"
/>
<div class="media-preview__meta">
{{ dialog.activeExtension }} — {{ dialog.activeSizeLabel }}
</div>
</div>
<div class="media-preview__controls">
<q-btn
v-if="dialog.field.canDownload"
flat
round
icon="download"
color="primary"
@click="dialog.download()"
>
<q-tooltip class="bg-grey-9">Download</q-tooltip>
</q-btn>
<span v-if="dialog.hasMultiple" class="media-preview__position">
{{ dialog.positionLabel }}
</span>
<q-btn
v-if="dialog.hasMultiple"
flat
round
icon="chevron_left"
color="primary"
@click="dialog.showPrev()"
>
<q-tooltip class="bg-grey-9">Previous</q-tooltip>
</q-btn>
<q-btn
v-if="dialog.hasMultiple"
flat
round
icon="chevron_right"
color="primary"
@click="dialog.showNext()"
>
<q-tooltip class="bg-grey-9">Next</q-tooltip>
</q-btn>
<q-btn
flat
round
:icon="dialog.maximizeIcon"
color="primary"
@click="dialog.toggleMaximized()"
>
<q-tooltip class="bg-grey-9">
{{ isMaximized ? 'Minimize' : 'Maximize' }}
</q-tooltip>
</q-btn>
<q-btn flat round icon="check" color="primary" @click="dialog.close()">
<q-tooltip class="bg-grey-9">Done</q-tooltip>
</q-btn>
</div>
</div>
</q-card>
</q-dialog>
</template>
<style scoped>
.media-preview__card {
width: 100%;
max-width: 960px;
display: flex;
flex-direction: column;
}
.media-preview__card--maximized {
max-width: 100%;
height: 100%;
}
.media-preview__stage {
position: relative;
flex: 1 1 auto;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(229deg, #000 0%, #020d18 50%, #000 100%);
}
.media-preview__image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.media-preview__card--maximized .media-preview__image {
max-height: calc(100vh - 80px);
}
.media-preview__placeholder {
text-align: center;
padding: 48px 0;
}
.media-preview__placeholder-extension {
color: #9e9e9e;
font-size: 20px;
letter-spacing: 2px;
margin-top: 8px;
}
.media-preview__spinner {
position: absolute;
top: calc(50% - 21px);
left: calc(50% - 21px);
}
.media-preview__footer {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 8px 8px 16px;
border-top: 1px solid #eee;
background: #fff;
}
.body--dark .media-preview__footer {
background: #1a2032;
border-top-color: rgba(255, 255, 255, 0.08);
}
.media-preview__details {
min-width: 0;
}
.media-preview__name {
font-size: 15px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #212121;
}
.body--dark .media-preview__name {
color: #e8ecf8;
}
.media-preview__name--editable {
cursor: pointer;
}
.media-preview__name-edit {
opacity: 0.45;
margin-left: 4px;
}
.media-preview__name-input {
font-size: 15px;
font-weight: 500;
width: 100%;
background: transparent;
color: inherit;
border: none;
border-bottom: 1px solid currentColor;
outline: none;
}
.media-preview__meta {
font-size: 12px;
color: #888;
}
.media-preview__controls {
display: flex;
align-items: center;
gap: 2px;
flex: 0 0 auto;
}
.media-preview__position {
color: #999;
font-size: 12px;
letter-spacing: 1px;
min-width: 48px;
text-align: center;
user-select: none;
}
</style>// ExtendedMediaField.ts — the class-extension showcase.
//
// Extends MediaField.$Class (the RAW class — the namespace convention's
// whole point) and adds: sort-by-name/date toggle, image dimensions read
// via createImageBitmap and shown as a badge, a caption editor, copy-URL
// with feedback, and a total-size summary.
//
// Derivation budget: 7 plain getters (0 bytes/instance) and 1 computed() —
// `sortedFiles`. That one earns its ~300 bytes: it re-sorts the WHOLE list,
// and `displayFiles` is read many times per render (once per grid item
// binding), so the cached cell suppresses the repeated O(n log n) work.
import { computed, ref } from 'vue';
import { Reactive } from '../../../ivue';
import { ServerApi } from '../server/ServerApi';
import { MediaField } from './MediaField';
import type { MediaItem } from './MediaFieldProps';
export type MediaSortMode = 'newest' | 'name';
export class $ExtendedMediaField extends MediaField.$Class {
// --- state ---
get sortMode() {
return ref<MediaSortMode>('newest');
}
get captionId() {
return ref<string | null>(null);
}
get captionDraft() {
return ref('');
}
get copiedId() {
return ref<string | null>(null);
}
get imageDimensions() {
return ref<Record<string, string>>({});
}
// --- derived ---
get sortedFiles() {
return computed(() => this.sortFiles());
}
override get displayFiles(): MediaItem[] {
return this.sortedFiles.value;
}
get sortModeLabel() {
return this.sortMode.value === 'newest' ? 'Newest first' : 'By name';
}
get sortModeIcon() {
return this.sortMode.value === 'newest' ? 'schedule' : 'sort_by_alpha';
}
get totalSize() {
return this.files.value.reduce((sum, row) => sum + row.size, 0);
}
get totalSizeLabel() {
return this.formatBytes(this.totalSize);
}
// --- sorting ---
sortFiles(): MediaItem[] {
const rows = [...this.files.value];
if (this.sortMode.value === 'name') {
return rows.sort((left, right) => left.name.localeCompare(right.name));
}
return rows.sort((left, right) =>
right.createdAt.localeCompare(left.createdAt),
);
}
toggleSortMode() {
this.sortMode.value = this.sortMode.value === 'newest' ? 'name' : 'newest';
}
// --- image dimensions (read on upload AND on hydration) ---
override onUploaded(rows: MediaItem[]) {
super.onUploaded(rows);
rows.forEach((row) => void this.measureImage(row));
}
override onFilesLoaded(rows: MediaItem[]) {
super.onFilesLoaded(rows);
rows.forEach((row) => void this.measureImage(row));
}
async measureImage(row: MediaItem) {
if (!this.isImage(row) || this.imageDimensions.value[row.id]) return;
try {
const blob = await (await fetch(row.url)).blob();
const bitmap = await createImageBitmap(blob);
this.imageDimensions.value[row.id] = `${bitmap.width}×${bitmap.height}`;
bitmap.close();
} catch {
/* Non-decodable image — no badge for this row. */
}
}
dimensionsOf(row: MediaItem): string {
return this.imageDimensions.value[row.id] ?? '';
}
// --- copy URL ---
async copyUrl(row: MediaItem) {
await navigator.clipboard.writeText(row.url);
this.copiedId.value = row.id;
window.setTimeout(() => this.clearCopied(row.id), 1500);
}
clearCopied(rowId: string) {
if (this.copiedId.value === rowId) this.copiedId.value = null;
}
// --- caption editor ---
startCaption(row: MediaItem) {
if (!this.canRenameCaption) return;
this.captionId.value = row.id;
this.captionDraft.value = row.caption ?? '';
}
async commitCaption() {
const captionId = this.captionId.value;
const row = this.files.value.find((existing) => existing.id === captionId);
if (!row) return this.cancelCaption();
const caption = this.captionDraft.value.trim();
try {
await ServerApi.media.update({ id: row.id, caption });
row.caption = caption;
this.emitModel();
} catch {
this.setError(`Failed to update caption for ${row.name}.`);
}
this.captionId.value = null;
}
cancelCaption() {
this.captionId.value = null;
}
}
export namespace ExtendedMediaField {
export const $Class = $ExtendedMediaField;
export let Class = Reactive($Class);
export type Instance = typeof Class.Instance;
}<script lang="ts" setup>
// ExtendedMediaField.vue — demonstrates BOTH extension mechanisms at once:
// 1. CLASS extension: `:runner="ExtendedMediaField.Class"` makes the base
// MediaField.vue construct the subclass — every slot receives it as
// `field`, so injected templates call the subclass's extra members.
// 2. TEMPLATE injection: the `item` slot REPLACES the base's per-file list
// row with a square thumbnail tile — the base renders list rows, this
// component pulls in a completely different item template while the
// same class hierarchy drives both. `before--`/`after--` slot variants
// (ExtendSlots) add the sort toolbar without copying the base template.
import { QBadge, QBtn, QIcon, QTooltip } from 'quasar';
import { ExtendedMediaField } from './ExtendedMediaField';
import MediaField from './MediaField.vue';
import {
mediaFieldEmits,
mediaFieldProps,
type MediaFieldEmits,
} from './MediaFieldProps';
const props = defineProps(mediaFieldProps);
/** Object-declared emits — ExtractEmitTypes derives the callable type. */
const emit = defineEmits(mediaFieldEmits) as MediaFieldEmits;
/** Square tile edge — v-bound into the grid styles below. */
const tileSize = `${props.thumbnailSize ?? 132}px`;
/** Autofocus the rename input the moment it renders (used as v-focus). */
const vFocus = {
mounted: (element: HTMLInputElement) => element.focus(),
};
</script>
<template>
<MediaField
v-bind="props"
class="extended-media"
:runner="ExtendedMediaField.Class"
@update:model-value="(value) => emit('update:modelValue', value)"
@uploaded="(rows) => emit('uploaded', rows)"
@removed="(row) => emit('removed', row)"
@error="(message) => emit('error', message)"
>
<!-- SORT TOGGLE + TOTAL SIZE — injected after the base header -->
<template #after--header="{ field }">
<div v-if="field.hasFiles" class="extended-media__toolbar">
<q-btn
flat
size="11px"
class="extended-media__sort-btn"
:icon="field.sortModeIcon"
:label="field.sortModeLabel"
@click="field.toggleSortMode()"
>
<q-tooltip class="bg-grey-9">Toggle sort order</q-tooltip>
</q-btn>
<q-badge
color="grey-3"
text-color="black"
:label="'Total ' + field.totalSizeLabel"
/>
</div>
</template>
<!-- SQUARE TILE — full replacement of the base's per-file list row -->
<template #item="{ row, index, field }">
<div class="extended-media__tile">
<!-- SQUARE THUMBNAIL -->
<div
class="extended-media__thumb"
:class="{ 'extended-media__thumb--clickable': field.canPreview }"
@click="field.openPreview(index)"
>
<img
v-if="field.isImage(row)"
:src="row.thumbnailUrl"
:alt="row.name"
class="extended-media__thumb-image"
/>
<div v-else class="extended-media__thumb-placeholder">
<q-icon :name="field.fileIcon(row)" size="30px" color="grey-6" />
<span class="extended-media__thumb-extension">
{{ field.fileExtension(row) }}
</span>
</div>
<!-- DIMENSIONS BADGE -->
<q-badge
v-if="field.dimensionsOf(row)"
color="grey-3"
text-color="black"
class="extended-media__dimensions"
:label="field.dimensionsOf(row)"
/>
<!-- HOVER ACTIONS — incl. the subclass's copy-URL -->
<div class="extended-media__actions" @click.stop>
<q-btn
v-if="field.canPreview"
dense
flat
round
size="10px"
icon="visibility"
color="white"
@click="field.openPreview(index)"
>
<q-tooltip class="bg-grey-9">Preview</q-tooltip>
</q-btn>
<q-btn
v-if="field.canDownload"
dense
flat
round
size="10px"
icon="download"
color="white"
@click="field.downloadFile(row)"
>
<q-tooltip class="bg-grey-9">Download</q-tooltip>
</q-btn>
<q-btn
v-if="field.canRename"
dense
flat
round
size="10px"
icon="edit"
color="white"
@click="field.startRename(row)"
>
<q-tooltip class="bg-grey-9">Rename</q-tooltip>
</q-btn>
<q-btn
dense
flat
round
size="10px"
:icon="field.copiedId.value === row.id ? 'check' : 'link'"
color="white"
@click="field.copyUrl(row)"
>
<q-tooltip class="bg-grey-9">
{{ field.copiedId.value === row.id ? 'Copied!' : 'Copy URL' }}
</q-tooltip>
</q-btn>
<q-btn
v-if="field.canRemove"
dense
flat
round
size="10px"
icon="close"
color="white"
@click="field.removeFile(row)"
>
<q-tooltip class="bg-grey-9">Remove</q-tooltip>
</q-btn>
</div>
</div>
<!-- NAME / RENAME / SIZE -->
<div class="extended-media__tile-footer">
<input
v-if="field.renameId.value === row.id"
v-model="field.renameDraft.value"
v-focus
class="extended-media__rename-input"
placeholder="File name"
@keydown.enter.prevent="field.commitRename()"
@keydown.esc="field.cancelRename()"
@blur="field.commitRename()"
/>
<div v-else class="extended-media__tile-name" :title="row.name">
{{ row.name }}
</div>
<div class="extended-media__tile-size">{{ field.sizeLabel(row) }}</div>
</div>
<!-- CAPTION EDITOR -->
<input
v-if="field.captionId.value === row.id"
v-model="field.captionDraft.value"
class="extended-media__caption-input"
placeholder="Caption"
@keydown.enter.prevent="field.commitCaption()"
@keydown.esc="field.cancelCaption()"
@blur="field.commitCaption()"
/>
<div
v-else-if="field.canRenameCaption"
class="extended-media__caption"
:class="{ 'extended-media__caption--empty': !row.caption }"
@click="field.startCaption(row)"
>
{{ row.caption || 'Add caption…' }}
</div>
<div v-else-if="row.caption" class="extended-media__caption">
{{ row.caption }}
</div>
</div>
</template>
</MediaField>
</template>
<style scoped>
.extended-media__toolbar {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 6px;
}
/* The base lays files out as full-width rows; the tile variant turns the
same list container into a wrapping grid of fixed-width square tiles. */
.extended-media :deep(.media-field__list) {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
}
.extended-media :deep(.media-field__item) {
display: block;
width: auto;
flex: 0 0 auto;
padding: 0;
}
.extended-media :deep(.media-field__item:hover) {
background: none;
}
/* The add affordance becomes a square tile alongside the thumbnails. */
.extended-media :deep(.media-field__add) {
width: v-bind(tileSize);
aspect-ratio: 1 / 1;
flex-direction: column;
gap: 2px;
padding: 0;
}
.extended-media__tile {
width: v-bind(tileSize);
}
.extended-media__thumb {
position: relative;
aspect-ratio: 1 / 1;
border-radius: 4px;
overflow: hidden;
background: color-mix(in srgb, currentColor 8%, transparent);
box-shadow: inset 0 0 10px 2px color-mix(in srgb, currentColor 6%, transparent);
display: flex;
align-items: center;
justify-content: center;
}
.extended-media__thumb--clickable {
cursor: pointer;
}
.extended-media__thumb-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.extended-media__thumb-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.extended-media__thumb-extension {
font-size: 11px;
letter-spacing: 1px;
text-transform: uppercase;
color: color-mix(in srgb, currentColor 55%, transparent);
}
.extended-media__dimensions {
position: absolute;
top: 4px;
left: 4px;
font-size: 10px;
}
.extended-media__actions {
position: absolute;
inset: auto 0 0 0;
display: flex;
justify-content: center;
gap: 2px;
padding: 3px 2px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.55));
opacity: 0;
transition: opacity 0.2s;
}
.extended-media__tile:hover .extended-media__actions {
opacity: 1;
}
.extended-media__tile-footer {
padding: 4px 2px 0;
}
.extended-media__tile-name {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.extended-media__tile-size {
font-size: 11px;
color: color-mix(in srgb, currentColor 50%, transparent);
}
.extended-media__rename-input {
width: 100%;
font-size: 12px;
color: inherit;
background: none;
outline: none;
border: 0;
border-bottom: 1px solid var(--q-primary, #1976d2);
padding: 0 0 1px;
}
.extended-media__caption {
padding: 2px 2px 0;
font-size: 11px;
color: color-mix(in srgb, currentColor 60%, transparent);
cursor: text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.extended-media__caption--empty {
color: color-mix(in srgb, currentColor 35%, transparent);
font-style: italic;
}
.extended-media__caption-input {
width: 100%;
margin-top: 2px;
font-size: 11px;
color: inherit;
background: none;
outline: none;
border: 0;
border-bottom: 1px solid var(--q-primary, #1976d2);
padding: 0 0 1px;
}
.extended-media__sort-btn {
padding: 2px 12px;
}
.extended-media__sort-btn .q-icon {
font-size: 15px;
margin-right: 5px;
}
</style><script setup lang="ts">
import MediaField from './MediaField.vue';
import ExtendedMediaField from './ExtendedMediaField.vue';
import { MediaFieldExample } from './MediaFieldExample';
const example = new MediaFieldExample.Class();
// the state destructure
const {
// state refs
preloadedMedia,
avatarMedia,
galleryMedia,
documentMedia,
extendedMedia,
resetting,
} = example;
</script>
<template>
<div class="pane pane-fields">
<p class="note">
A production-grade uploader: drag-drop, thumbnails, lightbox preview,
rename, download, delete — every byte stored in YOUR browser
(IndexedDB) by the mock backend. Point ServerApi at
server-node/server.ts and the same component uploads to disk or S3
with sharp-generated thumbnails.
</p>
<div class="field-grid">
<section>
<h3>Preexisting media — hydrated from the server</h3>
<MediaField
v-model="preloadedMedia"
label="Attached earlier"
hint="The model starts as bare IDs; the field fetches the rows itself — stock QUploader has no such path"
accept="image/*"
multiple
/>
</section>
<section>
<h3>Single image</h3>
<MediaField
v-model="avatarMedia"
label="Cover image"
hint="One image; replace by uploading again"
accept="image/*"
:max-files="1"
/>
</section>
<section>
<h3>Gallery — multiple images</h3>
<MediaField
v-model="galleryMedia"
label="Gallery"
hint="Drop several images at once; click a tile for the lightbox"
accept="image/*"
multiple
:thumbnail-size="96"
/>
</section>
<section>
<h3>Documents — any file type</h3>
<MediaField
v-model="documentMedia"
label="Attachments"
hint="Non-images render a file-type icon; rename and download from the tile"
accept=".pdf, .txt, .md, image/*"
multiple
dense
:thumbnail-size="72"
/>
</section>
<section>
<h3>Extended — class extension + template injection</h3>
<ExtendedMediaField
v-model="extendedMedia"
label="Extended uploader"
hint="A child class adds sorting, image dimensions, captions, copy-URL and a size summary — the base template is reused, not copied"
accept="image/*"
multiple
/>
</section>
</div>
<div class="row" style="margin-top: 20px">
<button
class="btn"
type="button"
:disabled="resetting"
@click="example.resetSandbox()"
>
{{ resetting ? 'Resetting…' : 'Reset sandbox data' }}
</button>
<span class="mono">
files live in IndexedDB — private to this browser
</span>
</div>
</div>
</template>
<style scoped src="../../example-pane.css"></style>
<style scoped>
.pane-fields {
max-width: 920px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr));
gap: 24px 26px;
align-items: start;
}
.field-grid h3 {
margin: 0 0 10px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.02em;
color: #f1f5ff;
}
</style>The backend path
Uploads flow through ServerApi.media.* — mock in the browser, or the TypeScript Express reference server with two storage drivers: storage-disk.ts (plain files under ./uploads, zero external services) and storage-s3.ts (private bucket, presigned GET redirects, server-side sharp thumbnails). Both implement the same contract, so the component never knows which one it's talking to.