Teren-app/resources/js/Pages/Admin/EmailTemplates/Edit.vue
Simon Pocrnjič 1b615163be email support
2025-10-11 17:20:05 +02:00

1321 lines
43 KiB
Vue

<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Head, Link, useForm, router, usePage } from "@inertiajs/vue3";
import { ref, watch, computed, onMounted, nextTick } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faArrowLeft, faEye } from "@fortawesome/free-solid-svg-icons";
// Ensure Quill is available before importing the wrapper component
import Quill from "quill";
if (typeof window !== "undefined" && !window.Quill) {
// @ts-ignore
window.Quill = Quill;
}
import "quill/dist/quill.snow.css";
const props = defineProps({
template: { type: Object, default: null },
});
const form = useForm({
name: props.template?.name ?? "",
key: props.template?.key ?? "",
subject_template: props.template?.subject_template ?? "",
html_template: props.template?.html_template ?? "",
text_template: props.template?.text_template ?? "",
entity_types: props.template?.entity_types ?? ["client", "contract"],
active: props.template?.active ?? true,
});
const preview = ref({ subject: "", html: "", text: "" });
const finalHtml = ref("");
const finalAttachments = ref([]);
const showFinal = ref(false);
const finalEmbedMode = ref("base64"); // 'base64' (default) or 'hosted' for diagnostics
const sendEmbedMode = ref("base64"); // embed mode for Send Test action
const isLocalHost = computed(() => {
if (typeof window === "undefined") return false;
const h = window.location.hostname || "";
return /^(localhost|127\.0\.0\.1)$/i.test(h);
});
// If preview.html is empty (e.g., because only head/styles were changed), show the body-safe content from the current source as a fallback.
const previewHtml = computed(() => {
const html = preview.value?.html || "";
if (html.trim() !== "") {
return containsDocScaffold(html) ? extractBody(html) : html;
}
// Fallback to local content if server preview didn't provide HTML
return sourceMode.value
? extractBody(form.html_template || "")
: stripDocScaffold(form.html_template || "");
});
const docsRaw = ref(props.template?.documents ? [...props.template.documents] : []);
const docs = computed(() =>
(docsRaw.value ?? []).map((d) => ({
...d,
url: d?.path ? `/storage/${d.path}` : null,
}))
);
function updateLocalDoc(documentId, path, name = null, size = null) {
if (!documentId) return;
const idx = docsRaw.value.findIndex((d) => d.id === documentId);
if (idx !== -1) {
const next = { ...docsRaw.value[idx] };
next.path = path ?? next.path;
if (name) next.name = name;
if (size != null) next.size = size;
docsRaw.value.splice(idx, 1, next);
}
}
function formatSize(bytes) {
if (!bytes && bytes !== 0) return "";
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(2)} MB`;
}
const debounce = (fn, ms = 500) => {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
};
// Core preview fetcher; mirrors values when creating (no ID), calls backend when editing
const fetchPreview = () => {
if (!props.template?.id) {
preview.value = {
subject: form.subject_template,
html: form.html_template,
text: form.text_template,
};
return;
}
window.axios
.post(route("admin.email-templates.preview", props.template.id), {
subject: form.subject_template,
// Always send full HTML so head/styles can be respected and inlined server-side
html: form.html_template,
text: form.text_template,
client_id: sample.value.client_id || undefined,
case_id: sample.value.case_id || undefined,
contract_id: sample.value.contract_id || undefined,
extra: safeParseExtra(sample.value.extra),
})
.then((r) => {
preview.value = r.data;
});
};
const doPreview = debounce(fetchPreview, 400);
watch(
() => [form.subject_template, form.html_template, form.text_template],
() => doPreview(),
{ deep: true, immediate: true }
);
function submit() {
if (props.template?.id) {
form.put(route("admin.email-templates.update", props.template.id));
} else {
form.post(route("admin.email-templates.store"));
}
}
async function fetchFinalHtml() {
const { data } = await window.axios.post(
route("admin.email-templates.render-final", props.template.id),
{
subject: form.subject_template,
html: form.html_template,
text: form.text_template,
client_id: sample.value.client_id || undefined,
case_id: sample.value.case_id || undefined,
contract_id: sample.value.contract_id || undefined,
extra: safeParseExtra(sample.value.extra),
embed: finalEmbedMode.value,
}
);
finalHtml.value = data?.html || "";
finalAttachments.value = Array.isArray(data?.attachments) ? data.attachments : [];
}
async function openFinalHtmlDialog() {
if (!props.template?.id) {
alert("Najprej shranite predlogo.");
return;
}
try {
await fetchFinalHtml();
showFinal.value = true;
} catch (e) {
console.error("Failed to render final HTML", e);
alert("Neuspešen prikaz končne HTML vsebine.");
}
}
function copyFinalHtml() {
try {
navigator.clipboard.writeText(finalHtml.value || "");
alert("Končni HTML je kopiran v odložišče.");
} catch (e) {
console.warn("Clipboard write failed", e);
}
}
function downloadFinalHtml() {
const blob = new Blob([finalHtml.value || ""], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${(form.name || "email-template").replace(/[^a-z0-9-_]+/gi, "-")}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
onMounted(() => {
// Show something instantly and request preview immediately
preview.value = {
subject: form.subject_template,
html: form.html_template,
text: form.text_template,
};
// Populate cascading selects immediately so the Client dropdown isn't empty
loadClients();
fetchPreview();
// Mount Quill editor directly
try {
if (quillContainer.value) {
// instantiate lazily to ensure DOM is ready
const editor = new Quill(quillContainer.value, {
theme: "snow",
modules: quillModules.value,
});
quill.value = editor;
if (form.html_template) {
const bodyOnly = stripDocScaffold(form.html_template);
if (bodyOnly) {
// Ensure Quill properly converts HTML to Delta
editor.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api");
} else {
editor.setText(
"(Body is empty. Switch to Source HTML to edit full document.)",
"api"
);
}
}
editor.on("text-change", (_delta, _old, source) => {
// Ignore programmatic changes; only persist user edits
if (source !== "user") {
return;
}
if (!sourceMode.value) {
const bodyHtml = editor.root.innerHTML;
form.html_template = containsDocScaffold(form.html_template)
? replaceBody(form.html_template, bodyHtml)
: bodyHtml;
}
});
}
} catch (e) {
console.error("Failed to mount Quill", e);
}
// Initialize iframe editor if advanced mode is on
if (advancedMode.value) {
initIframeEditor();
}
});
// --- Variable insertion and sample entity selection ---
const subjectRef = ref(null);
const quillContainer = ref(null);
const htmlSourceRef = ref(null);
const textRef = ref(null);
const activeField = ref(null); // 'subject' | 'html' | 'text'
const quill = ref(null);
const sourceMode = ref(false); // toggle for HTML source editing
// Advanced full-document editor that renders styles from <head>
const advancedMode = ref(false);
const iframeRef = ref(null);
let iframeSyncing = false;
const selectedImageSrc = ref("");
// Detect and handle full-document HTML so Quill doesn't wipe content
function containsDocScaffold(html) {
if (!html || typeof html !== "string") return false;
const h = html.toLowerCase();
return (
h.includes("<!doctype") ||
h.includes("<html") ||
h.includes("<head") ||
h.includes("<body") ||
h.includes("<style")
);
}
function extractBody(html) {
const m = /<body[^>]*>([\s\S]*?)<\/body>/i.exec(html);
return m ? m[1] : html;
}
function stripDocScaffold(html) {
if (!html) return "";
let out = html;
// Prefer body content when present
out = extractBody(out);
// Remove comments, doctype, html/head blocks, meta/title, and styles (Quill can't keep these)
out = out
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<\/?html[^>]*>/gi, "")
.replace(/<head[\s\S]*?>[\s\S]*?<\/head>/gi, "")
.replace(/<\/?meta[^>]*>/gi, "")
.replace(/<title[\s\S]*?>[\s\S]*?<\/title>/gi, "")
.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, "");
return out.trim();
}
// Replace only the inner content of <body>...</body> in a full document
function replaceBody(htmlDoc, newBody) {
if (!htmlDoc || typeof htmlDoc !== "string") return newBody || "";
const hasBody = /<body[^>]*>[\s\S]*?<\/body>/i.test(htmlDoc);
if (!hasBody) {
return newBody || "";
}
return htmlDoc.replace(/(<body[^>]*>)[\s\S]*?(<\/body>)/i, `$1${newBody || ""}$2`);
}
// Keep Quill and textarea in sync when toggling source mode
watch(
() => sourceMode.value,
(on) => {
if (on) {
// Switching to source view: if current model is NOT a full document,
// sync from Quill. Otherwise, keep the full source untouched.
if (quill.value && !containsDocScaffold(form.html_template)) {
form.html_template = quill.value.root.innerHTML;
}
} else {
// switching back to WYSIWYG: update editor html from model
if (quill.value) {
const bodyOnly = stripDocScaffold(form.html_template || "");
if (bodyOnly) {
quill.value.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api");
} else {
quill.value.setText(
"(Body is empty. Switch to Source HTML to edit full document.)",
"api"
);
}
} else if (quillContainer.value) {
// if the instance doesn't exist for any reason, re-create it
const editor = new Quill(quillContainer.value, {
theme: "snow",
modules: quillModules.value,
});
quill.value = editor;
const bodyOnly = stripDocScaffold(form.html_template || "");
if (bodyOnly) {
editor.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api");
} else {
editor.setText(
"(Body is empty. Switch to Source HTML to edit full document.)",
"api"
);
}
editor.on("text-change", (_d, _o, source) => {
if (source !== "user") {
return;
}
if (!sourceMode.value) {
const bodyHtml = editor.root.innerHTML;
form.html_template = containsDocScaffold(form.html_template)
? replaceBody(form.html_template, bodyHtml)
: bodyHtml;
}
});
}
}
}
);
// Keep advanced editor in a stable state with source/quill
watch(
() => advancedMode.value,
async (on) => {
if (on) {
sourceMode.value = false;
await nextTick();
initIframeEditor();
}
}
);
// When HTML changes externally, reflect it into iframe (unless we're the ones syncing)
watch(
() => form.html_template,
async () => {
if (!advancedMode.value || iframeSyncing) return;
await nextTick();
writeIframeDocument();
}
);
function ensureFullDoc(html) {
if (!html)
return '<!doctype html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /></head><body></body></html>';
if (/<html[\s\S]*<\/html>/i.test(html)) return html;
return `<!doctype html><html><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /></head><body>${html}</body></html>`;
}
function writeIframeDocument() {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
const html = ensureFullDoc(form.html_template || "");
doc.open();
doc.write(html);
doc.close();
try {
doc.body.setAttribute("spellcheck", "false");
} catch {}
}
function initIframeEditor() {
writeIframeDocument();
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.designMode = "on";
} catch {}
// track selected image src on click
doc.addEventListener("click", (e) => {
const t = e.target;
if (t && t.tagName === "IMG") {
selectedImageSrc.value = t.getAttribute("src") || "";
} else {
selectedImageSrc.value = "";
}
});
const handler = debounce(() => {
if (!advancedMode.value) return;
try {
iframeSyncing = true;
const full = doc.documentElement.outerHTML;
form.html_template = full;
} finally {
iframeSyncing = false;
}
}, 250);
doc.addEventListener("input", handler);
doc.addEventListener("keyup", handler);
}
function iframeExec(command, value = null) {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.body.focus();
} catch {}
try {
doc.execCommand(command, false, value);
} catch (e) {
console.warn("execCommand failed", command, e);
}
}
async function iframeInsertImage() {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const data = new FormData();
data.append("file", file);
try {
const { data: res } = await window.axios.post(
route("admin.email-templates.upload-image"),
data,
{ headers: { "Content-Type": "multipart/form-data" } }
);
const url = res?.url;
const documentId = res?.document_id;
if (url) {
iframeExec("insertImage", url);
}
} catch (e) {
console.error("Image upload failed", e);
}
};
input.click();
}
async function iframeReplaceSelectedImage() {
if (!props.template?.id) {
alert("Najprej shranite predlogo, nato zamenjajte sliko.");
return;
}
if (!selectedImageSrc.value) {
alert("Najprej kliknite na sliko v dokumentu, ki jo želite zamenjati.");
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const data = new FormData();
data.append("file", file);
data.append("current_src", selectedImageSrc.value);
try {
const { data: res } = await window.axios.post(
route("admin.email-templates.replace-image", props.template.id),
data,
{ headers: { "Content-Type": "multipart/form-data" } }
);
const url = res?.url;
if (url) {
// Replace src in the iframe document
const iframe = iframeRef.value;
const doc = iframe?.contentDocument;
if (doc) {
// Update only the selected image if possible, else do a global replace
const imgs = Array.from(doc.querySelectorAll("img"));
const img = imgs.find(
(im) => (im.getAttribute("src") || "") === selectedImageSrc.value
);
if (img) {
img.setAttribute("src", url);
} else {
doc.body.innerHTML = doc.body.innerHTML.replaceAll(
selectedImageSrc.value,
url
);
}
// Sync back to the model
iframeSyncing = true;
form.html_template = doc.documentElement.outerHTML;
iframeSyncing = false;
selectedImageSrc.value = url;
}
if (documentId && res?.path) {
updateLocalDoc(documentId, res.path, null, res?.size ?? null);
}
}
} catch (e) {
console.error("Replace image failed", e);
}
};
input.click();
}
function setActive(field) {
activeField.value = field;
}
function insertAtCursor(el, value, modelGetter, modelSetter) {
if (!el) return;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const current = modelGetter();
const next = current.slice(0, start) + value + current.slice(end);
modelSetter(next);
// restore caret after inserted value
requestAnimationFrame(() => {
el.focus();
const pos = start + value.length;
try {
el.setSelectionRange(pos, pos);
} catch {}
});
}
function insertPlaceholder(token) {
const content = `{{ ${token} }}`;
if (activeField.value === "subject" && subjectRef.value) {
insertAtCursor(
subjectRef.value,
content,
() => form.subject_template,
(v) => (form.subject_template = v)
);
} else if (activeField.value === "html") {
// If in source mode, treat HTML as textarea
if (sourceMode.value && htmlSourceRef.value) {
insertAtCursor(
htmlSourceRef.value,
content,
() => form.html_template,
(v) => (form.html_template = v)
);
return;
}
// Insert into Quill editor at current selection
if (quill.value) {
let range = quill.value.getSelection(true);
const index = range ? range.index : quill.value.getLength() - 1;
quill.value.insertText(index, content, "user");
quill.value.setSelection(index + content.length, 0, "user");
// Sync back to form model as HTML
form.html_template = quill.value.root.innerHTML;
} else {
// Fallback: append to the end of the model
form.html_template = (form.html_template || "") + content;
}
} else if (activeField.value === "text" && textRef.value) {
insertAtCursor(
textRef.value,
content,
() => form.text_template,
(v) => (form.text_template = v)
);
}
}
// Quill toolbar & image upload handler
const quillModules = computed(() => ({
toolbar: {
container: [
["bold", "italic", "underline", "strike"],
[{ header: [1, 2, 3, false] }],
[{ list: "ordered" }, { list: "bullet" }],
["link", "image"],
[{ align: [] }],
["clean"],
],
handlers: {
image: () => onQuillImageUpload(),
},
},
}));
function onQuillImageUpload() {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const data = new FormData();
data.append("file", file);
try {
const { data: res } = await window.axios.post(
route("admin.email-templates.upload-image"),
data,
{ headers: { "Content-Type": "multipart/form-data" } }
);
const url = res?.url;
if (url && quill.value) {
const range = quill.value.getSelection(true);
const index = range ? range.index : quill.value.getLength();
quill.value.insertEmbed(index, "image", url, "user");
quill.value.setSelection(index + 1, 0, "user");
form.html_template = quill.value.root.innerHTML;
}
} catch (e) {
// optional: show toast
console.error("Image upload failed", e);
}
};
input.click();
}
const placeholderGroups = computed(() => {
const groups = [];
const want = new Set(form.entity_types || []);
const add = (key, label, tokens) => groups.push({ key, label, tokens });
if (want.has("person")) {
add("person", "Person", [
"person.first_name",
"person.last_name",
"person.full_name",
"person.email",
"person.phone",
]);
}
if (want.has("client")) {
add("client", "Client", ["client.id", "client.uuid"]);
}
if (want.has("client_case")) {
add("case", "Case", ["case.id", "case.uuid", "case.reference"]);
}
if (want.has("contract")) {
add("contract", "Contract", [
"contract.id",
"contract.uuid",
"contract.reference",
"contract.amount",
"contract.meta.some_key",
]);
}
// Extra is always useful for ad-hoc data
add("extra", "Extra", ["extra.some_key"]);
return groups;
});
// Sample entity selection for preview
const sample = ref({ client_id: "", case_id: "", contract_id: "", extra: "" });
// Cascading select options
const clients = ref([]);
const cases = ref([]);
const contracts = ref([]);
async function loadClients() {
const { data } = await window.axios.get(route("admin.email-templates.data.clients"));
clients.value = data;
}
async function loadCases(clientId) {
if (!clientId) {
cases.value = [];
return;
}
const { data } = await window.axios.get(
route("admin.email-templates.data.cases", clientId)
);
cases.value = data;
}
async function loadContracts(caseId) {
if (!caseId) {
contracts.value = [];
return;
}
const { data } = await window.axios.get(
route("admin.email-templates.data.contracts", caseId)
);
contracts.value = data;
}
watch(
() => sample.value.client_id,
(v) => {
sample.value.case_id = "";
sample.value.contract_id = "";
loadCases(v);
doPreview();
}
);
watch(
() => sample.value.case_id,
(v) => {
sample.value.contract_id = "";
loadContracts(v);
doPreview();
}
);
watch(
() => sample.value.contract_id,
() => doPreview()
);
function applySample() {
fetchPreview();
loadClients();
}
function safeParseExtra(input) {
if (!input || typeof input !== "string") return undefined;
try {
const parsed = JSON.parse(input);
return typeof parsed === "object" && parsed !== null ? parsed : undefined;
} catch {
return undefined;
}
}
function sendTest() {
if (!props.template?.id) {
alert("Najprej shranite predlogo, nato pošljite test.");
return;
}
if (!sample.value.to) {
alert("Vnesite ciljni email naslov.");
return;
}
const payload = {
subject: form.subject_template,
html: form.html_template,
text: form.text_template,
client_id: sample.value.client_id || undefined,
person_id: sample.value.person_id || undefined,
case_id: sample.value.case_id || undefined,
contract_id: sample.value.contract_id || undefined,
extra: safeParseExtra(sample.value.extra),
to: sample.value.to,
embed: sendEmbedMode.value,
};
// Small helper for simple notifications without reloading
const notify = (message, _type = "info") => {
if (!message) {
return;
}
// Replace with your app's toast if available
alert(message);
};
router.post(route("admin.email-templates.send-test", props.template.id), payload, {
preserveScroll: true,
preserveState: true,
onSuccess: () => {
try {
const page = usePage();
const flash = page?.props?.flash ?? {};
if (flash.success) {
notify(flash.success, "success");
return;
}
if (flash.error) {
notify(flash.error, "error");
return;
}
} catch {}
notify("Testni e-poštni naslov je bil poslan.", "success");
},
onError: () => {
// Validation errors trigger onError; controller may also set flash('error') on redirect
try {
const page = usePage();
const flash = page?.props?.flash ?? {};
if (flash.error) {
notify(flash.error, "error");
return;
}
} catch {}
notify(
"Pri pošiljanju testnega e-poštnega sporočila je prišlo do napake.",
"error"
);
},
});
}
</script>
<template>
<AdminLayout :title="props.template ? 'Uredi predlogo' : 'Nova predloga'">
<Head :title="props.template ? 'Uredi predlogo' : 'Nova predloga'" />
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<Link
:href="route('admin.email-templates.index')"
class="text-sm text-gray-600 hover:text-gray-800 inline-flex items-center gap-2"
>
<FontAwesomeIcon :icon="faArrowLeft" class="w-4 h-4" /> Nazaj
</Link>
<h1 class="text-xl font-semibold text-gray-800">
{{ props.template ? "Uredi predlogo" : "Nova predloga" }}
</h1>
</div>
<button
@click="submit"
:disabled="form.processing"
class="px-4 py-2 text-sm rounded-md bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50"
>
Shrani
</button>
</div>
<div class="space-y-6">
<!-- Editor card -->
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-5 space-y-5">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-900">Urejevalnik</div>
</div>
<!-- Variable insertion toolbar -->
<div class="mb-2">
<div class="text-xs uppercase tracking-wider text-gray-500 font-semibold mb-2">
Vstavitev spremenljivk
</div>
<div class="flex flex-wrap gap-2">
<template v-for="grp in placeholderGroups" :key="grp.key">
<div class="flex items-center gap-2">
<div class="text-[11px] text-gray-500 font-semibold w-16">
{{ grp.label }}
</div>
<div class="flex flex-wrap gap-1">
<button
v-for="t in grp.tokens"
:key="t"
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100 text-gray-700 transition-colors"
@click="insertPlaceholder(t)"
>
{{ t }}
</button>
</div>
</div>
</template>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">Ime</label>
<input v-model="form.name" type="text" class="input" />
</div>
<div>
<label class="label">Ključ</label>
<input
v-model="form.key"
type="text"
class="input"
placeholder="e.g. welcome_email"
/>
</div>
<div class="md:col-span-2">
<label class="label">Subject</label>
<input
v-model="form.subject_template"
type="text"
class="input"
placeholder="Welcome, {{ person.full_name }}"
ref="subjectRef"
@focus="setActive('subject')"
/>
</div>
<div class="md:col-span-2">
<div class="flex items-center justify-between">
<label class="label">HTML vsebina</label>
<div class="flex items-center gap-4 text-xs text-gray-600">
<label class="inline-flex items-center gap-2">
<input type="checkbox" v-model="advancedMode" /> Napredni (poln
dokument)
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" v-model="sourceMode" :disabled="advancedMode" />
Source HTML
</label>
</div>
</div>
<!-- Quill body editor (hidden when advanced editor is active) -->
<div v-show="!sourceMode && !advancedMode">
<div
ref="quillContainer"
class="bg-white border rounded-lg min-h-[260px] p-2 focus-within:ring-2 ring-indigo-500/40"
@focusin="setActive('html')"
></div>
</div>
<!-- Source HTML textarea -->
<div v-show="sourceMode && !advancedMode">
<textarea
v-model="form.html_template"
rows="12"
class="input font-mono"
ref="htmlSourceRef"
@focus="setActive('html')"
></textarea>
</div>
<!-- Advanced full-document editor (iframe) -->
<div v-show="advancedMode" class="space-y-2">
<div class="flex flex-wrap gap-2">
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="() => iframeExec('bold')"
>
B
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="() => iframeExec('italic')"
>
<i>I</i>
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="() => iframeExec('underline')"
>
<u>U</u>
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="() => iframeExec('insertUnorderedList')"
>
• List
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="() => iframeExec('insertOrderedList')"
>
1. List
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="iframeInsertImage"
>
Slika
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100 disabled:opacity-50"
:disabled="!selectedImageSrc"
@click="iframeReplaceSelectedImage"
>
Zamenjaj izbrano sliko
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="() => iframeExec('justifyLeft')"
>
Levo
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="() => iframeExec('justifyCenter')"
>
Center
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="() => iframeExec('justifyRight')"
>
Desno
</button>
</div>
<iframe ref="iframeRef" class="w-full h-[360px] border rounded"></iframe>
<div class="text-[11px] text-gray-500">
Opomba: Napredni urejevalnik prikazuje celoten dokument v iframe, zato
boste videli učinek stilov iz &lt;head&gt;. Pri pošiljanju se CSS še vedno
pretvori v inline stile za email.
</div>
<div class="text-[11px] text-gray-500">
Namig: če oznaka <code>&lt;img&gt;</code> nima atributa <code>src</code>,
bo ob predogledu in pošiljanju sistem poskušal samodejno nastaviti sliko
na podlagi pripetih slik te predloge. Najprej se išče ujemanje po
<code>alt</code> (npr. <code>alt="Logo"</code> → dokument z imenom
»logo«); če je pripeta le ena slika, bo uporabljena ta.
</div>
</div>
</div>
<div class="md:col-span-2">
<label class="label">Text vsebina</label>
<textarea
v-model="form.text_template"
rows="6"
class="input font-mono"
ref="textRef"
@focus="setActive('text')"
></textarea>
</div>
<div>
<label class="label">Entity types</label>
<div class="flex flex-wrap gap-3 text-sm">
<label class="inline-flex items-center gap-2"
><input type="checkbox" value="client" v-model="form.entity_types" />
client</label
>
<label class="inline-flex items-center gap-2"
><input type="checkbox" value="client_case" v-model="form.entity_types" />
client_case</label
>
<label class="inline-flex items-center gap-2"
><input type="checkbox" value="contract" v-model="form.entity_types" />
contract</label
>
<label class="inline-flex items-center gap-2"
><input type="checkbox" value="person" v-model="form.entity_types" />
person</label
>
</div>
</div>
<div class="flex items-center gap-2">
<input id="active" type="checkbox" v-model="form.active" />
<label for="active" class="text-sm text-gray-700">Aktivno</label>
</div>
</div>
</div>
<!-- Preview card -->
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-5 space-y-5">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-900">Predogled</div>
<button
@click="doPreview"
class="text-xs inline-flex items-center gap-2 px-2.5 py-1.5 rounded-md border text-gray-700 bg-gray-50 hover:bg-gray-100 transition-colors"
>
<FontAwesomeIcon :icon="faEye" class="w-3.5 h-3.5" /> Osveži
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="label">Sample entities</div>
<div class="grid grid-cols-2 gap-2">
<label class="text-xs text-gray-600">
Client
<select v-model="sample.client_id" class="input h-9">
<option value="">—</option>
<option v-for="c in clients" :key="c.id" :value="c.id">
{{ c.label }}
</option>
</select>
</label>
<label class="text-xs text-gray-600">
Case
<select
v-model="sample.case_id"
class="input h-9"
:disabled="!sample.client_id"
>
<option value="">—</option>
<option v-for="cs in cases" :key="cs.id" :value="cs.id">
{{ cs.label }}
</option>
</select>
</label>
<label class="text-xs text-gray-600">
Contract
<select
v-model="sample.contract_id"
class="input h-9"
:disabled="!sample.case_id"
>
<option value="">—</option>
<option v-for="ct in contracts" :key="ct.id" :value="ct.id">
{{ ct.label }}
</option>
</select>
</label>
<div class="col-span-2">
<label class="text-xs text-gray-600"
>Extra (JSON)
<textarea
rows="3"
v-model="sample.extra"
class="input font-mono"
placeholder='{"note":"hello"}'
></textarea>
</label>
</div>
</div>
<div class="mt-2">
<button
@click="applySample"
type="button"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
>
Uporabi sample
</button>
</div>
</div>
<div>
<div class="label">Subject</div>
<div class="p-2 rounded bg-gray-50 text-sm">{{ preview.subject }}</div>
</div>
</div>
<div>
<div class="label">HTML</div>
<div
class="p-3 rounded-lg bg-gray-50 border ql-editor max-h-[480px] overflow-auto"
v-html="previewHtml"
></div>
</div>
<div>
<div class="label">Text</div>
<pre class="p-3 rounded-lg bg-gray-50 border text-xs whitespace-pre-wrap">{{
preview.text
}}</pre>
</div>
<div class="text-xs text-gray-500" v-pre>
Available placeholders example: {{ person.full_name }}, {{ client.uuid }},
{{ case.reference }}, {{ contract.reference }}, {{ contract.meta.some_key }}
</div>
<div class="mt-4 flex flex-col sm:flex-row sm:items-end gap-2">
<div class="w-full sm:w-auto">
<label class="label">Pošlji test na</label>
<input
type="email"
v-model="sample.to"
class="input"
placeholder="name@example.com"
/>
</div>
<div class="w-full sm:w-auto">
<label class="label">Način slik</label>
<select v-model="sendEmbedMode" class="input">
<option value="base64">Vdelano (base64)</option>
<option value="hosted">Gostovano (absolutni URL)</option>
</select>
<div
v-if="sendEmbedMode === 'hosted' && isLocalHost"
class="text-[11px] text-amber-600 mt-1"
>
Pozor: Uporabljate "localhost". Gmail/Outlook ne moreta pridobiti slik z
lokalnega strežnika. Nastavite APP_URL/ASSET_URL na javni HTTPS domeni ali
uporabite tunel (npr. ngrok) za test.
</div>
</div>
<button
type="button"
class="h-9 inline-flex items-center px-3 rounded-md border border-indigo-600 bg-indigo-600 text-white text-xs hover:bg-indigo-500 transition-colors"
@click="sendTest"
>
Pošlji test
</button>
<button
type="button"
class="h-9 inline-flex items-center px-3 rounded-md border text-xs bg-gray-50 hover:bg-gray-100"
@click="openFinalHtmlDialog"
>
Pokaži končni HTML
</button>
</div>
</div>
<!-- Manage images panel -->
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-5 space-y-3">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-800">Pripete slike</div>
<div class="text-xs text-gray-500">
Slike se shranijo ob shranjevanju predloge ali pošiljanju testa.
</div>
</div>
<div v-if="props.template && docs.length" class="divide-y border rounded">
<div
v-for="d in docs"
:key="d.id"
class="flex items-center justify-between p-2 text-sm"
>
<div class="flex items-center gap-3 min-w-0">
<a
v-if="d.url"
:href="d.url"
target="_blank"
class="text-indigo-600 hover:underline truncate"
>
{{ d.name || d.file_name || d.path }}
</a>
<span v-else class="text-gray-700 truncate">{{
d.name || d.file_name || d.path
}}</span>
<span class="text-xs text-gray-500">{{ formatSize(d.size) }}</span>
</div>
<div class="flex items-center gap-2">
<a
v-if="d.url"
:href="d.url"
target="_blank"
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
>Odpri</a
>
</div>
</div>
</div>
<div v-else class="text-sm text-gray-500">Ni pripetih slik.</div>
<div class="text-xs text-gray-500">
Če se slike v urejevalniku ne prikazujejo, preverite ali imate vzpostavljeno
povezavo do javnega diska (ukaz: storage:link).
</div>
</div>
</div>
</AdminLayout>
<!-- Final HTML dialog -->
<div
v-if="showFinal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="showFinal = false"
>
<div
class="bg-white rounded-lg shadow-xl max-w-5xl w-[92vw] max-h-[84vh] flex flex-col"
>
<div class="px-4 py-2 border-b flex items-center justify-between gap-2">
<div class="font-semibold text-gray-800 text-sm">
Končni HTML (točno to se pošlje)
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1 text-[11px] text-gray-700">
Način slik:
<select
v-model="finalEmbedMode"
@change="fetchFinalHtml"
class="border rounded px-2 py-1 text-[11px] bg-white hover:bg-gray-50"
>
<option value="base64">Vdelano (base64)</option>
<option value="hosted">Gostovano (absolutni URL)</option>
</select>
</div>
<div
v-if="finalEmbedMode === 'hosted' && isLocalHost"
class="text-[11px] text-amber-600"
>
Pozor: Uporabljate "localhost". Gmail/Outlook ne moreta pridobiti slik z
lokalnega strežnika.
</div>
<button
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="copyFinalHtml"
>
Kopiraj
</button>
<button
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="downloadFinalHtml"
>
Prenesi .html
</button>
<button
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
@click="showFinal = false"
>
Zapri
</button>
</div>
</div>
<div class="p-3 overflow-auto space-y-3">
<div v-if="finalAttachments.length" class="text-[11px] text-gray-600">
<div class="font-semibold mb-1">CID priponke:</div>
<ul class="list-disc pl-5">
<li v-for="a in finalAttachments" :key="a.cid">
<code>{{ a.src }}</code> → <code>cid:{{ a.cid }}</code>
</li>
</ul>
</div>
<pre class="text-xs whitespace-pre-wrap break-words">{{ finalHtml }}</pre>
</div>
</div>
</div>
</template>
<style scoped>
.input {
width: 100%;
border-radius: 0.375rem;
border: 1px solid #d1d5db;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.input:focus {
outline: 2px solid transparent;
outline-offset: 2px;
border-color: #6366f1;
box-shadow: 0 0 0 1px #6366f1;
}
.label {
display: block;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.25rem;
}
</style>