1368 lines
44 KiB
Vue
1368 lines
44 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";
|
||
// 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"],
|
||
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 <head>. 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><img></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">
|
||
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>
|