430 lines
18 KiB
Svelte
430 lines
18 KiB
Svelte
|
|
<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>
|