cms/resources/js/components/admin/MediaManager.svelte

430 lines
18 KiB
Svelte
Raw Normal View History

<script>
import { onMount } from 'svelte';
let { adminPath, media = [], isPicker = false, onSelect = null, permissions = {}, availableLocales: propAvailableLocales = ['en', 'es', 'fr', 'de'] } = $props();
const normalizedAdminPath = $derived(adminPath.endsWith('/') ? adminPath.slice(0, -1) : adminPath);
const userPermissions = $derived(typeof permissions === 'string' ? JSON.parse(permissions) : permissions);
const availableLocales = $derived(Array.isArray(propAvailableLocales) ? propAvailableLocales : ['en']);
let displayMedia = $state([]);
let selectedFile = $state(null);
let uploading = $state(false);
let showFocalPoint = $state(false);
let focalX = $state(50);
let focalY = $state(50);
let focalPointRef = $state(null);
// Multi-locale metadata state
let activeLocale = $state('en');
let metadata = $state({});
$effect(() => {
const locales = availableLocales;
if (locales.length > 0 && !locales.includes(activeLocale)) {
activeLocale = locales[0];
}
});
$effect(() => {
const locales = availableLocales;
// Only update if metadata is actually missing keys from the current locale list
const missingKeys = locales.filter(l => !metadata[l]);
if (missingKeys.length > 0) {
const initialMetadata = {};
locales.forEach(l => {
initialMetadata[l] = { caption: '', alt: '' };
});
metadata = { ...initialMetadata, ...metadata };
}
});
$effect(() => {
displayMedia = media;
});
onMount(async () => {
if (displayMedia.length === 0) {
try {
const response = await fetch(`${normalizedAdminPath}/media`, {
headers: {
'Accept': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
displayMedia = data.media || [];
}
} catch (err) {
console.error("Failed to load media:", err);
}
}
});
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
function handleKeydown(event, action) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
action();
}
}
async function handleUpload(event) {
const files = event.target.files;
if (files.length === 0) return;
uploading = true;
const formData = new FormData();
formData.append('file', files[0]);
try {
const response = await fetch(`${normalizedAdminPath}/media/upload`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: formData
});
if (response.ok) {
const result = await response.json();
displayMedia = [result.media, ...displayMedia];
} else {
const error = await response.json();
alert(error.message || 'Upload failed');
}
} catch (err) {
console.error(err);
alert('An error occurred during upload');
} finally {
uploading = false;
event.target.value = '';
}
}
async function deleteFile(file) {
if (!confirm('Are you sure you want to delete this file?')) return;
try {
const response = await fetch(`${normalizedAdminPath}/media`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ id: file.id })
});
if (response.ok) {
displayMedia = displayMedia.filter(f => f.id !== file.id);
if (selectedFile?.id === file.id) selectedFile = null;
} else {
alert('Failed to delete file');
}
} catch (err) {
console.error(err);
alert('An error occurred during deletion');
}
}
function selectFile(file) {
selectedFile = file;
focalX = file.focal_x || 50;
focalY = file.focal_y || 50;
showFocalPoint = false;
// Load metadata
if (file.metadata) {
metadata = { ...metadata, ...file.metadata };
} else {
metadata = {
en: { caption: '', alt: '' },
es: { caption: '', alt: '' },
fr: { caption: '', alt: '' },
de: { caption: '', alt: '' }
};
}
}
function setFocalPoint(event) {
const rect = focalPointRef.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
focalX = Math.round(x * 100) / 100;
focalY = Math.round(y * 100) / 100;
}
async function saveFocalPoint() {
try {
const response = await fetch(`${normalizedAdminPath}/media`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
id: selectedFile.id,
focal_x: focalX,
focal_y: focalY
})
});
if (response.ok) {
const result = await response.json();
selectedFile.focal_x = focalX;
selectedFile.focal_y = focalY;
// Update in list
const index = displayMedia.findIndex(f => f.id === selectedFile.id);
if (index !== -1) {
displayMedia[index] = result.media;
}
alert('Focal point saved');
showFocalPoint = false;
} else {
alert('Failed to save focal point');
}
} catch (err) {
console.error(err);
alert('An error occurred');
}
}
async function saveMetadata() {
try {
const response = await fetch(`${normalizedAdminPath}/media`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
id: selectedFile.id,
metadata: metadata
})
});
if (response.ok) {
const result = await response.json();
selectedFile.metadata = metadata;
// Update in list
const index = displayMedia.findIndex(f => f.id === selectedFile.id);
if (index !== -1) {
displayMedia[index] = result.media;
}
alert('Metadata saved');
} else {
alert('Failed to save metadata');
}
} catch (err) {
console.error(err);
alert('An error occurred');
}
}
</script>
<div class="ui grid">
<div class="row">
{#if !isPicker}
<div class="column">
<h1 class="ui header">
<i class="images outline icon"></i>
<div class="content">
Media Manager
<div class="sub header">Manage your site's media files</div>
</div>
</h1>
</div>
{/if}
</div>
<div class="row">
<div class="{isPicker ? 'eleven' : 'twelve'} wide column">
<div class="ui segment">
<div class="ui top attached menu">
{#if userPermissions['upload-media']}
<div class="item">
<label for="media-upload" class="ui primary button {uploading ? 'loading disabled' : ''}">
<i class="upload icon"></i> Upload File
</label>
<input type="file" id="media-upload" hidden onchange={handleUpload} accept="image/*" />
</div>
{/if}
</div>
<div class="ui attached segment" style="min-height: {isPicker ? '300px' : '400px'}; max-height: {isPicker ? '400px' : '600px'}; overflow-y: auto;">
{#if displayMedia.length === 0}
<div class="ui placeholder segment">
<div class="ui icon header">
<i class="images outline icon"></i>
No media files found.
</div>
</div>
{:else}
<div class="ui {isPicker ? 'four' : 'five'} cards">
{#each displayMedia as file}
<div class="card {selectedFile?.id === file.id ? 'blue' : ''}"
role="button"
tabindex="0"
onkeydown={(e) => handleKeydown(e, () => selectFile(file))}
onclick={() => selectFile(file)}
style="cursor: pointer;">
<div class="image" style="height: {isPicker ? '100px' : '150px'}; overflow: hidden; background: #eee; display: flex; align-items: center; justify-content: center;">
{#if file.mime_type.startsWith('image/')}
<img src="/media/{file.filename}?w=200&h=200&fit=crop" alt={file.filename} style="object-fit: cover; width: 100%; height: 100%;" />
{:else}
<i class="huge file outline icon"></i>
{/if}
</div>
<div class="extra content" style="padding: 5px;">
<div class="header" style="font-size: 0.8em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{file.filename}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div class="{isPicker ? 'five' : 'four'} wide column">
<div class="ui segment">
<h3 class="ui header">Details</h3>
{#if selectedFile}
<div class="ui list">
<div class="item">
<div class="content">
<strong>Name:</strong> {selectedFile.filename}
</div>
</div>
<div class="item">
<div class="content">
<strong>Type:</strong> {selectedFile.mime_type}
</div>
</div>
<div class="item">
<div class="content">
<strong>Size:</strong> {formatBytes(selectedFile.size)}
</div>
</div>
</div>
{#if isPicker}
<div class="ui divider"></div>
<button class="ui fluid primary button" onclick={() => onSelect(selectedFile)}>
<i class="check icon"></i> Select File
</button>
{/if}
<div class="ui divider"></div>
{#if selectedFile.mime_type.startsWith('image/')}
<div class="ui segment secondary small">
<h4 class="ui header">Metadata</h4>
<div class="ui menu secondary pointing mini">
{#each availableLocales as loc}
<button type="button"
class="item {activeLocale === loc ? 'active' : ''}"
onclick={() => activeLocale = loc}>
{loc.toUpperCase()}
</button>
{/each}
</div>
<div class="field mt-2">
<label for="media-caption-{activeLocale}" class="tiny-label">Caption ({activeLocale.toUpperCase()})</label>
<input type="text"
id="media-caption-{activeLocale}"
class="ui mini input fluid"
bind:value={metadata[activeLocale].caption}
placeholder="Caption">
</div>
<div class="field mt-2">
<label for="media-alt-{activeLocale}" class="tiny-label">Alt Text ({activeLocale.toUpperCase()})</label>
<input type="text"
id="media-alt-{activeLocale}"
class="ui mini input fluid"
bind:value={metadata[activeLocale].alt}
placeholder="Alt Text">
</div>
{#if userPermissions['update-media']}
<button class="ui fluid green mini button mt-2" onclick={saveMetadata}>
Save Metadata
</button>
{/if}
</div>
{#if userPermissions['update-media']}
<button class="ui fluid button" onclick={() => showFocalPoint = !showFocalPoint}>
<i class="crosshairs icon"></i> Set Focal Point
</button>
{/if}
{#if showFocalPoint && userPermissions['update-media']}
<div class="ui segment p-0 mt-2" style="position: relative;">
<div
bind:this={focalPointRef}
role="button"
tabindex="0"
aria-label="Set focal point"
onkeydown={(e) => handleKeydown(e, (ev) => setFocalPoint(ev || e))}
onclick={setFocalPoint}
style="cursor: crosshair; position: relative;"
>
<img src="/media/{selectedFile.filename}?w=400" alt="Focal selector" class="ui fluid image" />
<div
style="position: absolute; left: {focalX}%; top: {focalY}%; width: 20px; height: 20px; border: 2px solid white; border-radius: 50%; box-shadow: 0 0 5px rgba(0,0,0,0.5); transform: translate(-50%, -50%); pointer-events: none;"
>
<div style="position: absolute; left: 50%; top: 50%; width: 4px; height: 4px; background: red; border-radius: 50%; transform: translate(-50%, -50%);"></div>
</div>
</div>
<div class="ui segment basic tiny">
X: {focalX}% Y: {focalY}%
<div class="ui two buttons mt-2">
<button class="ui green tiny button" onclick={saveFocalPoint}>Save</button>
<button class="ui tiny button" onclick={() => showFocalPoint = false}>Cancel</button>
</div>
</div>
</div>
{/if}
{/if}
<div class="ui divider"></div>
<div class="ui vertical buttons fluid">
<a href={selectedFile.url} target="_blank" class="ui button">
<i class="external alternate icon"></i> View Original
</a>
{#if !isPicker && userPermissions['delete-media']}
<button class="ui red button" onclick={() => deleteFile(selectedFile)}>
<i class="trash icon"></i> Delete
</button>
{/if}
</div>
{:else}
<div class="ui message">Select a file to see details</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.mt-2 { margin-top: 0.5rem; }
.p-0 { padding: 0 !important; }
.tiny-label { font-size: 0.8em; font-weight: bold; display: block; margin-bottom: 2px; }
</style>