This commit is contained in:
2026-05-13 17:11:09 +02:00
commit ea63897455
2785 changed files with 359868 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
import { inject, ref } from 'vue';
/**
* Shared image upload composable used by both the left-panel ImageDropZone
* and the right-side preview block drop zones.
*
* @param {() => string | null} getUploadUrl A getter (or plain string) for the upload endpoint.
* @param {(url: string) => void} onSuccess Called immediately with a blob URL, then again with the server URL.
*/
export const useImageUpload = (getUploadUrl, onSuccess) => {
const isDragOver = ref(false);
const isUploading = ref(false);
const error = ref(null);
// Plugs into the parent editor's pending-upload counter so the form submit
// can be blocked while this upload is in flight.
const pendingUploads = inject('editorPendingUploads', null);
const uploadFile = async (file) => {
if (!file || !file.type.startsWith('image/')) {
return;
}
const uploadUrl = typeof getUploadUrl === 'function' ? getUploadUrl() : getUploadUrl;
if (!uploadUrl) {
return;
}
// Emit blob URL immediately so the preview renders at once
const blobUrl = URL.createObjectURL(file);
onSuccess(blobUrl);
isUploading.value = true;
error.value = null;
if (pendingUploads) pendingUploads.value++;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
const formData = new FormData();
formData.append('image', file);
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken },
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
URL.revokeObjectURL(blobUrl);
onSuccess(data.url);
} catch {
error.value = 'Upload failed. Please try again.';
URL.revokeObjectURL(blobUrl);
onSuccess('');
} finally {
isUploading.value = false;
if (pendingUploads) pendingUploads.value--;
}
};
const onDragOver = (event) => {
event.preventDefault();
isDragOver.value = true;
};
const onDragLeave = () => {
isDragOver.value = false;
};
const onDrop = (event) => {
event.preventDefault();
isDragOver.value = false;
const file = event.dataTransfer?.files?.[0];
if (file) {
uploadFile(file);
}
};
const fileInputRef = ref(null);
const openPicker = () => {
if (!fileInputRef.value) return;
fileInputRef.value.value = '';
fileInputRef.value.click();
};
const onFileSelected = (event) => {
const file = event.target.files[0];
if (file) {
uploadFile(file);
}
};
return { isDragOver, isUploading, error, fileInputRef, onDragOver, onDragLeave, onDrop, openPicker, onFileSelected, uploadFile };
};

View File

@@ -0,0 +1,40 @@
import { onBeforeUnmount, onMounted, ref } from 'vue';
export const useIntersectionActivation = (options = { rootMargin: '180px 0px' }) => {
const target = ref(null);
const isActive = ref(false);
let observer;
onMounted(() => {
if (typeof IntersectionObserver === 'undefined') {
isActive.value = true;
return;
}
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
isActive.value = true;
observer?.disconnect();
});
}, options);
if (target.value) {
observer.observe(target.value);
} else {
isActive.value = true;
}
});
onBeforeUnmount(() => {
observer?.disconnect();
});
return {
target,
isActive,
};
};