Teren-app/resources/js/Pages/Admin/EmailTemplates/Edit.vue

1373 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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";
// Keep Quill CSS for nicer preview styling, but remove the Quill editor itself
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"],
allow_attachments: props.template?.allow_attachments ?? false,
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
// We only use the advanced editor now, so just show the <body> content when available
return extractBody(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);
}
}
async function removeLocalDoc(documentId) {
const idx = docsRaw.value.findIndex((d) => d.id === documentId);
if (idx !== -1) {
docsRaw.value.splice(idx, 1);
}
}
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,
activity_id: sample.value.activity_id || undefined,
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,
activity_id: sample.value.activity_id || undefined,
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();
// Advanced editor is the only mode
activeField.value = "html";
initIframeEditor();
});
// --- Variable insertion and sample entity selection ---
const subjectRef = ref(null);
const htmlSourceRef = ref(null);
const textRef = ref(null);
const activeField = ref("html"); // default to HTML for variable inserts
// Raw HTML editor toggle (full-document source)
const rawMode = ref(false);
// Advanced full-document editor that renders styles from <head>
const advancedMode = ref(true);
const iframeRef = ref(null);
let iframeSyncing = false;
const selectedImageSrc = ref("");
// Preview iframe ref (to render preview HTML with <head> styles applied)
const previewIframeRef = ref(null);
// 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;
}
// Retained for compatibility, but no longer used actively
function stripDocScaffold(html) {
return extractBody(html || "");
}
// 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`);
}
// Quill/source mode removed
// Advanced mode is always on
// When HTML changes externally, reflect it into iframe (unless we're the ones syncing)
watch(
() => form.html_template,
async () => {
if (iframeSyncing) return;
await nextTick();
writeIframeDocument();
}
);
// Re-initialize iframe editor when switching back from Raw HTML
watch(
() => rawMode.value,
async (on) => {
if (!on) {
await nextTick();
initIframeEditor();
}
}
);
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 = "";
}
// Ensure variable buttons target HTML editor
activeField.value = "html";
});
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 writePreviewDocument() {
const iframe = previewIframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
const html = ensureFullDoc(preview.value?.html || form.html_template || "");
doc.open();
doc.write(html);
doc.close();
}
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;
}
async function deleteAttachedImage(doc) {
if (!props.template?.id) return;
if (!doc?.id) return;
const confirmed = window.confirm(
"Odstranim to sliko iz predloge? Datoteka bo izbrisana."
);
if (!confirmed) return;
try {
await window.axios.delete(
route("admin.email-templates.images.delete", {
emailTemplate: props.template.id,
document: doc.id,
})
);
await removeLocalDoc(doc.id);
// After deletion, scrub or replace references in current HTML
tryReplaceOrRemoveDeletedImageReferences(doc);
} catch (e) {
console.error("Delete image failed", e);
alert("Brisanje slike ni uspelo.");
}
}
function tryReplaceOrRemoveDeletedImageReferences(deletedDoc) {
const deletedPath = deletedDoc?.path || "";
if (!deletedPath) return;
const targetRel = "/storage/" + deletedPath.replace(/^\/+/, "");
// Helper: does an <img> src match the deleted doc path (relative or absolute)?
const srcMatches = (src) => {
if (!src) return false;
try {
// Absolute URL: compare its pathname
const u = new URL(src, window.location.origin);
return u.pathname === targetRel;
} catch {
// Relative path string
if (src === targetRel) return true;
// Also accept raw disk path variant (unlikely in HTML)
if (src.replace(/^\/+/, "") === targetRel.replace(/^\/+/, "")) return true;
return false;
}
};
// Choose a replacement doc based on <img alt> when possible; else, if only one image remains, use that.
const pickReplacement = (altText) => {
const remaining = (docsRaw.value || []).slice();
if (!remaining.length) return null;
const norm = (s) => (s || "").toString().toLowerCase();
const stem = (name) =>
(name || "")
.toString()
.toLowerCase()
.replace(/\.[^.]+$/, "");
const simplify = (s) => norm(s).replace(/[^a-z0-9]+/g, "");
if (altText) {
const altKey = simplify(altText);
// exact name stem match (name, file_name, original_name)
const exact = remaining.find((d) => {
const candidates = [d.name, d.file_name, d.original_name].map(stem);
return candidates.some(
(c) => simplify(c) === altKey || norm(c) === norm(altText)
);
});
if (exact) return exact;
// relaxed contains on simplified stems
const relaxed = remaining.find((d) => {
const candidates = [d.name, d.file_name, d.original_name].map(stem).map(simplify);
return candidates.some((c) => c && altKey && c.includes(altKey));
});
if (relaxed) return relaxed;
}
if (remaining.length === 1) return remaining[0];
return null;
};
const replaceInDocument = (docEl) => {
if (!docEl) return false;
let changed = false;
const imgs = Array.from(docEl.querySelectorAll("img"));
imgs.forEach((img) => {
const src = img.getAttribute("src");
if (!srcMatches(src)) return;
const alt = img.getAttribute("alt") || "";
const replacement = pickReplacement(alt);
if (replacement && replacement.path) {
img.setAttribute("src", "/storage/" + replacement.path.replace(/^\/+/, ""));
} else {
// No replacement remove the image tag entirely
img.parentNode && img.parentNode.removeChild(img);
}
changed = true;
});
return changed;
};
if (!rawMode.value) {
// Advanced iframe editor
const iframe = iframeRef.value;
const doc = iframe?.contentDocument;
if (doc && doc.documentElement) {
const changed = replaceInDocument(doc);
if (changed) {
iframeSyncing = true;
form.html_template = doc.documentElement.outerHTML;
iframeSyncing = false;
}
}
} else {
// Raw mode: parse and mutate via DOMParser
const html = form.html_template || "";
const full = ensureFullDoc(html);
const parser = new DOMParser();
const parsed = parser.parseFromString(full, "text/html");
const changed = replaceInDocument(parsed);
if (changed) {
form.html_template = parsed.documentElement.outerHTML;
}
}
}
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 editing raw source, insert at caret into textarea
if (rawMode.value && htmlSourceRef.value) {
insertAtCursor(
htmlSourceRef.value,
content,
() => form.html_template,
(v) => (form.html_template = v)
);
return;
}
// Otherwise, insert into the iframe at caret position
// Insert into the iframe at caret position without rewriting the document
const iframe = iframeRef.value;
const doc = iframe?.contentDocument;
if (doc) {
const sel = doc.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
const node = doc.createTextNode(content);
range.insertNode(node);
// place caret after inserted node
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
doc.body.appendChild(doc.createTextNode(content));
}
// Sync back to model
iframeSyncing = true;
form.html_template = doc.documentElement.outerHTML;
iframeSyncing = false;
} else {
// last resort
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 handlers removed; image actions handled by iframe toolbar
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", "client.person.full_name"]);
}
if (want.has("client_case")) {
add("case", "Case", [
"case.id",
"case.uuid",
"case.reference",
"case.person.full_name",
]);
}
if (want.has("contract")) {
add("contract", "Contract", [
"contract.id",
"contract.uuid",
"contract.reference",
"contract.amount",
"contract.meta.some_key",
]);
}
// Activity placeholders (always useful if template references workflow actions/decisions)
add("activity", "Activity", [
"activity.id",
"activity.note",
"activity.due_date",
"activity.amount",
"activity.action.name",
"activity.decision.name",
]);
// 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: "",
activity_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()
);
watch(
() => sample.value.activity_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,
activity_id: sample.value.activity_id || undefined,
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");
// Slight UX tweak: controller now queues the email
// (flash success message from backend reflects 'queued')
},
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"
);
},
});
}
// Keep preview iframe in sync with server-rendered preview
watch(
() => preview.value?.html,
async () => {
await nextTick();
writePreviewDocument();
}
);
</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="rawMode" /> Raw HTML
</label>
</div>
</div>
<!-- Raw HTML textarea -->
<div v-show="rawMode">
<textarea
v-model="form.html_template"
rows="16"
class="input font-mono"
ref="htmlSourceRef"
@focus="setActive('html')"
></textarea>
</div>
<!-- Advanced full-document editor (iframe) -->
<div v-show="!rawMode" 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="allow_attachments" type="checkbox" v-model="form.allow_attachments" />
<label for="allow_attachments" class="text-sm text-gray-700">Dovoli priponke</label>
</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">
Activity ID
<input
v-model="sample.activity_id"
type="number"
class="input h-9"
placeholder="npr. 123"
/>
</label>
<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>
<iframe
ref="previewIframeRef"
class="w-full h-[480px] border rounded bg-white"
></iframe>
</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 }},
{{ activity.note }}, {{ activity.action.name }}, {{ activity.decision.name }}
</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
>
<button
type="button"
class="text-xs px-2 py-1 rounded border bg-red-50 text-red-700 hover:bg-red-100"
@click="deleteAttachedImage(d)"
>
Odstrani
</button>
</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>