1518 lines
46 KiB
Vue
1518 lines
46 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 {
|
||
ArrowLeftIcon,
|
||
EyeIcon,
|
||
SaveIcon,
|
||
BoldIcon,
|
||
ItalicIcon,
|
||
UnderlineIcon,
|
||
AlignLeftIcon,
|
||
AlignCenterIcon,
|
||
AlignRightIcon,
|
||
ListIcon,
|
||
ListOrderedIcon,
|
||
ImageIcon,
|
||
Maximize2Icon,
|
||
CodeIcon,
|
||
SendIcon,
|
||
CopyIcon,
|
||
DownloadIcon,
|
||
Trash2Icon,
|
||
FileTextIcon,
|
||
SettingsIcon,
|
||
UserIcon,
|
||
BriefcaseIcon,
|
||
FolderIcon,
|
||
ActivityIcon,
|
||
PlusCircleIcon,
|
||
} from "lucide-vue-next";
|
||
import { VueMonacoEditor } from "@guolao/vue-monaco-editor";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/Components/ui/card";
|
||
import { Button } from "@/Components/ui/button";
|
||
import { Input } from "@/Components/ui/input";
|
||
import { Label } from "@/Components/ui/label";
|
||
import { Textarea } from "@/Components/ui/textarea";
|
||
import { Checkbox } from "@/Components/ui/checkbox";
|
||
import { Switch } from "@/Components/ui/switch";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/Components/ui/select";
|
||
import { Separator } from "@/Components/ui/separator";
|
||
import { Badge } from "@/Components/ui/badge";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/Components/ui/dialog";
|
||
// 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 monacoEditor = 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 Monaco editor
|
||
if (rawMode.value && monacoEditor.value) {
|
||
const editor = monacoEditor.value;
|
||
const position = editor.getPosition();
|
||
const range = {
|
||
startLineNumber: position.lineNumber,
|
||
startColumn: position.column,
|
||
endLineNumber: position.lineNumber,
|
||
endColumn: position.column,
|
||
};
|
||
editor.executeEdits("", [
|
||
{
|
||
range: range,
|
||
text: content,
|
||
forceMoveMarkers: true,
|
||
},
|
||
]);
|
||
editor.focus();
|
||
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'" />
|
||
|
||
<Card class="mb-6">
|
||
<CardHeader>
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex items-start gap-3">
|
||
<div
|
||
class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"
|
||
>
|
||
<FileTextIcon class="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<CardTitle>{{
|
||
props.template ? "Uredi predlogo" : "Nova predloga"
|
||
}}</CardTitle>
|
||
<CardDescription> Konfiguracija e-poštne predloge </CardDescription>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" as-child>
|
||
<Link :href="route('admin.email-templates.index')">
|
||
<ArrowLeftIcon class="h-4 w-4" />
|
||
Nazaj
|
||
</Link>
|
||
</Button>
|
||
<Button @click="submit" :disabled="form.processing" size="sm">
|
||
<SaveIcon class="h-4 w-4" />
|
||
Shrani
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
</Card>
|
||
|
||
<div class="space-y-6">
|
||
<!-- Editor card -->
|
||
<Card>
|
||
<CardHeader>
|
||
<div class="flex items-center gap-2">
|
||
<SettingsIcon class="h-5 w-5" />
|
||
<CardTitle class="text-base">Osnovna nastavitve</CardTitle>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<Label for="name">Ime</Label>
|
||
<Input
|
||
id="name"
|
||
v-model="form.name"
|
||
:class="{ 'border-destructive': form.errors.name }"
|
||
/>
|
||
<span v-if="form.errors.name" class="text-sm text-destructive">{{
|
||
form.errors.name
|
||
}}</span>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label for="key">Ključ</Label>
|
||
<Input
|
||
id="key"
|
||
v-model="form.key"
|
||
:class="{ 'border-destructive': form.errors.key }"
|
||
/>
|
||
<span v-if="form.errors.key" class="text-sm text-destructive">{{
|
||
form.errors.key
|
||
}}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<Label for="entity_types">Tipi entitet</Label>
|
||
<div class="flex flex-wrap gap-3">
|
||
<div
|
||
v-for="et in ['person', 'client', 'client_case', 'contract']"
|
||
:key="et"
|
||
class="flex items-center gap-2"
|
||
>
|
||
<Checkbox
|
||
:id="'entity-' + et"
|
||
:default-value="form.entity_types.includes(et)"
|
||
@update:model-value="
|
||
(val) => {
|
||
if (val) {
|
||
if (!form.entity_types.includes(et)) form.entity_types.push(et);
|
||
} else {
|
||
form.entity_types = form.entity_types.filter((x) => x !== et);
|
||
}
|
||
}
|
||
"
|
||
/>
|
||
<Label :for="'entity-' + et" class="font-normal cursor-pointer">
|
||
{{ et }}
|
||
</Label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="flex items-center gap-2">
|
||
<Switch
|
||
id="allow_attachments"
|
||
:default-value="form.allow_attachments"
|
||
@update:model-value="(val) => (form.allow_attachments = val)"
|
||
/>
|
||
<Label for="allow_attachments" class="font-normal cursor-pointer"
|
||
>Allow attachments</Label
|
||
>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<Switch
|
||
id="active"
|
||
:default-value="form.active"
|
||
@update:model-value="(val) => (form.active = val)"
|
||
/>
|
||
<Label for="active" class="font-normal cursor-pointer">Aktivno</Label>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div class="space-y-2">
|
||
<Label for="subject">Zadeva</Label>
|
||
<Input
|
||
id="subject"
|
||
ref="subjectRef"
|
||
v-model="form.subject_template"
|
||
@focus="setActive('subject')"
|
||
:class="{ 'border-destructive': form.errors.subject_template }"
|
||
/>
|
||
<span v-if="form.errors.subject_template" class="text-sm text-destructive">{{
|
||
form.errors.subject_template
|
||
}}</span>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<Label>HTML vsebina</Label>
|
||
<Button size="sm" variant="outline" @click="rawMode = !rawMode">
|
||
<CodeIcon class="h-4 w-4 mr-2" />
|
||
{{ rawMode ? "Vizualni urednik" : "HTML izvorna koda" }}
|
||
</Button>
|
||
</div>
|
||
|
||
<!-- Raw HTML source mode with Monaco Editor -->
|
||
<div
|
||
v-if="rawMode"
|
||
class="border rounded-md overflow-hidden"
|
||
style="min-height: 600px"
|
||
>
|
||
<VueMonacoEditor
|
||
v-model:value="form.html_template"
|
||
language="html"
|
||
:options="{
|
||
theme: 'vs-dark',
|
||
minimap: { enabled: false },
|
||
fontSize: 16,
|
||
lineHeight: 24,
|
||
lineNumbers: 'on',
|
||
wordWrap: 'on',
|
||
automaticLayout: true,
|
||
scrollBeyondLastLine: false,
|
||
formatOnPaste: true,
|
||
formatOnType: true,
|
||
padding: { top: 16, bottom: 16 },
|
||
}"
|
||
height="600px"
|
||
@mount="
|
||
(editor) => {
|
||
monacoEditor = editor;
|
||
setActive('html');
|
||
}
|
||
"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Visual iframe editor -->
|
||
<div v-else class="space-y-2">
|
||
<div
|
||
class="flex flex-wrap items-center gap-1 p-2 bg-muted rounded-md border"
|
||
>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="iframeExec('bold')"
|
||
title="Bold"
|
||
>
|
||
<BoldIcon class="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="iframeExec('italic')"
|
||
title="Italic"
|
||
>
|
||
<ItalicIcon class="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="iframeExec('underline')"
|
||
title="Underline"
|
||
>
|
||
<UnderlineIcon class="h-4 w-4" />
|
||
</Button>
|
||
<Separator orientation="vertical" class="h-6" />
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="iframeExec('justifyLeft')"
|
||
title="Align Left"
|
||
>
|
||
<AlignLeftIcon class="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="iframeExec('justifyCenter')"
|
||
title="Align Center"
|
||
>
|
||
<AlignCenterIcon class="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="iframeExec('justifyRight')"
|
||
title="Align Right"
|
||
>
|
||
<AlignRightIcon class="h-4 w-4" />
|
||
</Button>
|
||
<Separator orientation="vertical" class="h-6" />
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="iframeExec('insertUnorderedList')"
|
||
title="Bullet List"
|
||
>
|
||
<ListIcon class="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="iframeExec('insertOrderedList')"
|
||
title="Numbered List"
|
||
>
|
||
<ListOrderedIcon class="h-4 w-4" />
|
||
</Button>
|
||
<Separator orientation="vertical" class="h-6" />
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="iframeInsertImage"
|
||
title="Insert Image"
|
||
>
|
||
<ImageIcon class="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
v-if="selectedImageSrc"
|
||
size="sm"
|
||
variant="ghost"
|
||
@click="iframeReplaceSelectedImage"
|
||
title="Replace selected image"
|
||
>
|
||
<ImageIcon class="h-4 w-4" />
|
||
<span class="ml-1 text-xs">Zamenjaj</span>
|
||
</Button>
|
||
</div>
|
||
<iframe
|
||
ref="iframeRef"
|
||
class="w-full border rounded-md bg-white"
|
||
style="min-height: 400px"
|
||
@click="setActive('html')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<Label for="text">Besedilna vsebina</Label>
|
||
<Textarea
|
||
id="text"
|
||
ref="textRef"
|
||
v-model="form.text_template"
|
||
@focus="setActive('text')"
|
||
rows="6"
|
||
/>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div class="space-y-3">
|
||
<Label>Spremenljivke</Label>
|
||
<div class="space-y-2">
|
||
<div v-for="group in placeholderGroups" :key="group.key" class="space-y-2">
|
||
<div class="text-sm font-medium text-muted-foreground">
|
||
{{ group.label }}
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<Button
|
||
v-for="token in group.tokens"
|
||
:key="token"
|
||
size="sm"
|
||
variant="outline"
|
||
@click="insertPlaceholder(token)"
|
||
class="font-mono text-xs"
|
||
>
|
||
<PlusCircleIcon class="h-3 w-3 mr-1" />
|
||
{{ token }}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- Preview card -->
|
||
<Card>
|
||
<CardHeader>
|
||
<div class="flex items-center gap-2">
|
||
<EyeIcon class="h-5 w-5" />
|
||
<CardTitle class="text-base">Predogled</CardTitle>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<div class="space-y-2">
|
||
<Label for="sample_client" class="flex items-center gap-1">
|
||
<UserIcon class="h-3 w-3" />
|
||
Stranka
|
||
</Label>
|
||
<Select v-model="sample.client_id">
|
||
<SelectTrigger id="sample_client">
|
||
<SelectValue placeholder="Izberi stranko" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem :value="null">Brez</SelectItem>
|
||
<SelectItem v-for="c in clients" :key="c.id" :value="c.id">
|
||
{{ c.person?.first_name }} {{ c.person?.last_name }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<Label for="sample_case" class="flex items-center gap-1">
|
||
<FolderIcon class="h-3 w-3" />
|
||
Zadeva
|
||
</Label>
|
||
<Select v-model="sample.case_id" :disabled="!sample.client_id">
|
||
<SelectTrigger id="sample_case">
|
||
<SelectValue placeholder="Izberi zadevo" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem :value="null">Brez</SelectItem>
|
||
<SelectItem v-for="cs in cases" :key="cs.id" :value="cs.id">
|
||
{{ cs.reference || cs.id }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<Label for="sample_contract" class="flex items-center gap-1">
|
||
<BriefcaseIcon class="h-3 w-3" />
|
||
Pogodba
|
||
</Label>
|
||
<Select v-model="sample.contract_id" :disabled="!sample.case_id">
|
||
<SelectTrigger id="sample_contract">
|
||
<SelectValue placeholder="Izberi pogodbo" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem :value="null">Brez</SelectItem>
|
||
<SelectItem v-for="ct in contracts" :key="ct.id" :value="ct.id">
|
||
{{ ct.reference || ct.id }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<Label for="sample_activity" class="flex items-center gap-1">
|
||
<ActivityIcon class="h-3 w-3" />
|
||
Aktivnost
|
||
</Label>
|
||
<Input id="sample_activity" v-model="sample.activity_id" placeholder="ID" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<Label for="sample_extra">Dodatni podatki (JSON)</Label>
|
||
<Textarea
|
||
id="sample_extra"
|
||
v-model="sample.extra"
|
||
placeholder='{ "key": "value" }'
|
||
rows="2"
|
||
class="font-mono text-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<Button size="sm" @click="applySample">
|
||
<EyeIcon class="h-4 w-4 mr-2" />
|
||
Osveži predogled
|
||
</Button>
|
||
<Button
|
||
v-if="props.template?.id"
|
||
size="sm"
|
||
variant="outline"
|
||
@click="openFinalHtmlDialog"
|
||
>
|
||
<Maximize2Icon class="h-4 w-4 mr-2" />
|
||
Končni HTML
|
||
</Button>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div class="space-y-3">
|
||
<div class="space-y-1">
|
||
<Label class="text-sm">Zadeva</Label>
|
||
<div class="text-sm p-2 bg-muted rounded">
|
||
{{ preview.subject || "(prazno)" }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-1">
|
||
<Label class="text-sm">HTML</Label>
|
||
<iframe
|
||
ref="previewIframeRef"
|
||
class="w-full border rounded-md bg-white"
|
||
style="min-height: 320px"
|
||
/>
|
||
</div>
|
||
|
||
<div class="space-y-1">
|
||
<Label class="text-sm">Besedilo</Label>
|
||
<pre class="text-xs whitespace-pre-wrap bg-muted p-3 rounded-md">{{
|
||
preview.text || "(prazno)"
|
||
}}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div class="space-y-3">
|
||
<Label>Pošlji testno sporočilo</Label>
|
||
<div class="flex gap-2">
|
||
<Input
|
||
v-model="sample.to"
|
||
placeholder="Email naslov prejemnika"
|
||
type="email"
|
||
class="flex-1"
|
||
/>
|
||
<Button @click="sendTest" :disabled="!props.template?.id || !sample.to">
|
||
<SendIcon class="h-4 w-4 mr-2" />
|
||
Pošlji
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- Manage images panel -->
|
||
<Card>
|
||
<CardHeader>
|
||
<div class="flex items-center gap-2">
|
||
<ImageIcon class="h-5 w-5" />
|
||
<CardTitle class="text-base">Upravljanje slik</CardTitle>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div v-if="docs.length === 0" class="text-sm text-muted-foreground py-4">
|
||
Še ni naloženih slik.
|
||
</div>
|
||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div
|
||
v-for="doc in docs"
|
||
:key="doc.id"
|
||
class="relative group border rounded-lg p-2 hover:bg-muted/50 transition-colors"
|
||
>
|
||
<div class="aspect-video bg-muted rounded overflow-hidden mb-2">
|
||
<img
|
||
v-if="doc.url"
|
||
:src="doc.url"
|
||
:alt="doc.name || 'Image'"
|
||
class="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
<div class="text-xs truncate mb-1" :title="doc.name">{{ doc.name }}</div>
|
||
<div class="text-xs text-muted-foreground">{{ formatSize(doc.size) }}</div>
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
@click="deleteAttachedImage(doc)"
|
||
>
|
||
<Trash2Icon class="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</AdminLayout>
|
||
|
||
<!-- Final HTML dialog -->
|
||
<Dialog :open="showFinal" @update:open="(val) => (showFinal = val)">
|
||
<DialogContent class="max-w-5xl max-h-[84vh] flex flex-col">
|
||
<DialogHeader>
|
||
<DialogTitle>Končni HTML</DialogTitle>
|
||
<DialogDescription>
|
||
Popolnoma upodobljena e-poštna vsebina z vdelanimi slikami
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div class="flex-1 overflow-auto space-y-3 p-4">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<Label for="embed-mode" class="text-sm">Način vdelanih slik:</Label>
|
||
<Select v-model="finalEmbedMode" @update:model-value="fetchFinalHtml">
|
||
<SelectTrigger id="embed-mode" class="w-[160px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="base64">base64 (privzeto)</SelectItem>
|
||
<SelectItem value="hosted">hosted (povezava)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<Button size="sm" variant="outline" @click="copyFinalHtml">
|
||
<CopyIcon class="h-4 w-4 mr-2" />
|
||
Kopiraj
|
||
</Button>
|
||
<Button size="sm" variant="outline" @click="downloadFinalHtml">
|
||
<DownloadIcon class="h-4 w-4 mr-2" />
|
||
Prenesi
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="finalAttachments.length > 0" class="space-y-2">
|
||
<Label class="text-sm">Priponke</Label>
|
||
<div class="space-y-1">
|
||
<div
|
||
v-for="(att, idx) in finalAttachments"
|
||
:key="idx"
|
||
class="text-xs p-2 bg-muted rounded flex items-center justify-between"
|
||
>
|
||
<span class="truncate">{{ att.filename }}</span>
|
||
<Badge variant="outline" class="ml-2">{{ formatSize(att.size) }}</Badge>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<Label class="text-sm">Predogled</Label>
|
||
<iframe
|
||
:srcdoc="finalHtml"
|
||
class="w-full border rounded-md bg-white"
|
||
style="min-height: 400px"
|
||
/>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<Label class="text-sm">Izvorna koda</Label>
|
||
<Textarea
|
||
:model-value="finalHtml"
|
||
readonly
|
||
rows="12"
|
||
class="font-mono text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</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>
|