mailwolt/resources/js/plugins/GlassToastra/toastra.glass.js

361 lines
16 KiB
JavaScript

// resources/js/plugins/GlassToastra/toastra.glass.js
(() => {
// ---- Config --------------------------------------------------------------
const MAX_PER_POSITION = 3; // wie viele Toasts pro Ecke sichtbar
const ROOT_PREFIX = 'toastra-root-'; // pro Position eigener Container
const POS_STYLES = {
'top-left' : ['top:1.5rem','left:1.5rem'],
'top-center' : ['top:1.5rem','left:50%','transform:translateX(-50%)'],
'top-right' : ['top:1.5rem','right:1.5rem'],
'bottom-left' : ['bottom:1.5rem','left:1.5rem'],
'bottom-center': ['bottom:1.5rem','left:50%','transform:translateX(-50%)'],
'bottom-right' : ['bottom:1.5rem','right:1.5rem'],
};
// ---- One-time style injection -------------------------------------------
let styleInjected = false;
function injectStyleOnce() {
if (styleInjected) return;
styleInjected = true;
const css = `
/* Mount/Unmount Animations */
.tg-card { opacity: 0; transform: translateY(6px); will-change: transform, opacity; }
.tg-in { animation: tgIn .28s ease-out forwards; }
.tg-out { animation: tgOut .28s ease-in forwards; }
@keyframes tgIn { from{opacity:0; transform:translateY(6px)} to{opacity:1; transform:none} }
@keyframes tgOut { from{opacity:1; transform:none} to{opacity:0; transform:translateY(6px)} }
/* kleine Progress-Move-Anim */
@keyframes tgProgress {
0% { transform: translateX(-60%) }
50%{ transform: translateX(10%) }
100%{ transform: translateX(120%) }
}
/* Badge im Title (Fallback, falls du kein Tailwind-Badge willst) */
.tg-badge {
display:inline-flex; align-items:center; gap:.25rem;
padding:.25rem .5rem; border-radius:.5rem;
font-size:.7rem; font-weight:700; letter-spacing:.5px;
background: rgba(255,255,255,.08); color:#cbd5e1;
border:1px solid rgba(255,255,255,.12);
}
/* Close-Hitbox */
.tg-close {
display:inline-flex; align-items:center; justify-content:center;
width:32px; height:32px; border-radius:.6rem;
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.12);
box-shadow: 0 0 10px rgba(0,0,0,.25);
transition: background .15s ease;
}
.tg-close:hover { background: rgba(255,255,255,.12) }
.tg-close svg path { stroke: rgba(229,231,235,.9) }
/* Stack-Abstand */
[data-toastra-stack] > div { margin-bottom: .75rem }
`;
const el = document.createElement('style');
el.textContent = css;
document.head.appendChild(el);
}
// ---- Helpers -------------------------------------------------------------
function ensureRoot(position) {
injectStyleOnce();
const pos = POS_STYLES[position] ? position : 'bottom-right';
const id = ROOT_PREFIX + pos;
let root = document.getElementById(id);
if (!root) {
root = document.createElement('div');
root.id = id;
root.setAttribute('data-toastra-stack', pos);
root.style.position = 'fixed';
root.style.zIndex = '99999';
root.style.pointerEvents = 'none';
root.style.width = 'clamp(260px, 92vw, 480px)';
document.body.appendChild(root);
}
root.style.cssText = [
'position:fixed','z-index:99999','pointer-events:none','width:clamp(260px,92vw,480px)'
].concat(POS_STYLES[pos]).join(';');
return root;
}
// function statusMap(state) {
// switch (state) {
// case 'done':
// return { text: 'Erledigt', pill: 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30', icon: 'ph ph-check-circle' };
// case 'failed':
// return { text: 'Fehlgeschlagen', pill: 'bg-rose-500/15 text-rose-300 ring-1 ring-rose-500/30', icon: 'ph ph-x-circle' };
// case 'forbidden':
// return { text: 'Verboten', pill: 'bg-fuchsia-500/15 text-fuchsia-300 ring-1 ring-fuchsia-500/30', icon: 'ph ph-shield-slash' };
// case 'running':
// return { text: 'Läuft…', pill: 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/30', icon: 'ph ph-arrow-clockwise animate-spin' };
// default:
// return { text: 'Wartet…', pill: 'bg-amber-500/15 text-amber-300 ring-1 ring-amber-500/30', icon: 'ph ph-pause-circle' };
// }
// }
function statusMap(state) {
switch (state) {
case 'done':
return { text: 'Erledigt', pill: 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30', icon: 'ph ph-check-circle' };
case 'failed':
return { text: 'Fehlgeschlagen', pill: 'bg-rose-500/15 text-rose-300 ring-1 ring-rose-500/30', icon: 'ph ph-x-circle' };
case 'forbidden':
return { text: 'Verboten', pill: 'bg-fuchsia-500/15 text-fuchsia-300 ring-1 ring-fuchsia-500/30', icon: 'ph ph-shield-slash' };
case 'running':
return { text: 'Läuft…', pill: 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/30', icon: 'ph ph-arrow-clockwise animate-spin' };
default:
return { text: 'Wartet…', pill: 'bg-amber-500/15 text-amber-300 ring-1 ring-amber-500/30', icon: 'ph ph-pause-circle' };
}
}
function buildHTML(o) {
const st = statusMap(o.state);
const progress = (o.state === 'running' || o.state === 'queued')
? `<div class="mt-3 h-1.5 w-full rounded-full bg-white/5 overflow-hidden">
<div class="h-full w-1/2 bg-cyan-400/50 tg-progress"></div>
</div>`
: '';
const closeBtn = (o.close !== false)
? `<button class="rounded-lg bg-white/10 hover:bg-white/15 border border-white/15 p-1.5 ml-2 flex-shrink-0"
style="box-shadow:0 0 10px rgba(0,0,0,.25)" data-tg-close>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M8 8l8 8M16 8l-8 8" stroke="rgba(229,231,235,.9)" stroke-linecap="round"/>
</svg>
</button>`
: '<div style="width:28px;height:28px"></div>';
return `
<div class="glass-card border border-glass-border/70 rounded-2xl p-4 shadow-[0_10px_30px_rgba(0,0,0,.35)] pointer-events-auto tg-card">
<div class="flex items-center justify-between gap-4">
<!-- Linke Seite -->
<div class="flex items-center gap-3 min-w-0">
${o.badge
? `<span class="px-2 py-0.5 rounded-md text-[11px] uppercase tracking-wide font-semibold
bg-${o.state === 'forbidden' ? 'fuchsia' : o.state === 'failed' ? 'rose' : 'emerald'}-500/15
text-${o.state === 'forbidden' ? 'fuchsia' : o.state === 'failed' ? 'rose' : 'emerald'}-300
ring-1 ring-${o.state === 'forbidden' ? 'fuchsia' : o.state === 'failed' ? 'rose' : 'emerald'}-500/30">
${String(o.badge).toUpperCase()}
</span>`
: ''}
${o.domain
? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>`
: ''}
</div>
<!-- Rechte Seite -->
<div class="flex items-center gap-2 flex-shrink-0">
<span class="text-[13px] text-gray-300/80">Status</span>
<span class="px-2.5 py-1 rounded-lg text-[12px] leading-none whitespace-nowrap ${st.pill}">
<i class="${st.icon} text-[14px] align-[-1px]"></i>
<span class="ml-1">${st.text}</span>
</span>
${closeBtn}
</div>
</div>
${o.message ? `<p class="mt-3 text-[14px] text-gray-200/90 leading-relaxed">${o.message}</p>` : ''}
${progress}
${o.finalNote ? `<p class="mt-3 text-[12px] text-gray-400">${o.finalNote}</p>` : ''}
</div>`;
}
// function buildHTML(o){
// const st = statusMap(o.state);
// const progress = (o.state === 'running' || o.state === 'queued')
// ? `<div class="mt-3 h-1.5 w-full rounded-full bg-white/5 overflow-hidden">
// <div class="h-full w-1/2 bg-cyan-400/50 tg-progress"></div>
// </div>` : '';
//
// const closeBtn = (o.close !== false)
// ? `<button class="rounded-lg bg-white/10 hover:bg-white/15 border border-white/15 p-1.5 ml-2 flex-shrink-0"
// style="box-shadow:0 0 10px rgba(0,0,0,.25)" data-tg-close>
// <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
// <path d="M8 8l8 8M16 8l-8 8" stroke="rgba(229,231,235,.9)" stroke-linecap="round"/>
// </svg>
// </button>`
// : '<div style="width:28px;height:28px"></div>';
//
// return `
// <div class="glass-card border border-glass-border/70 rounded-2xl p-4 shadow-[0_10px_30px_rgba(0,0,0,.35)] pointer-events-auto tg-card ${st.anim}"
// style="box-shadow: inset 6px 0 0 0 ${st.accentCss}, 0 10px 30px rgba(0,0,0,.35); background-image: linear-gradient(${st.tintCss}, ${st.tintCss});">
// <div class="flex items-center justify-between gap-4">
// <!-- Links -->
// <div class="flex items-center gap-3 min-w-0">
// ${o.badge ? `<span class="badge !bg-glass-light/50 !border-glass-border whitespace-nowrap">${String(o.badge).toUpperCase()}</span>` : ''}
// ${o.domain ? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>` : ''}
// </div>
//
// <!-- Rechts -->
// <div class="flex items-center gap-2 flex-shrink-0">
// <span class="text-[13px] text-gray-300/80">Status</span>
// <span class="px-2.5 py-1 rounded-lg text-[12px] leading-none whitespace-nowrap ${st.pill}">
// <i class="${st.icon} text-[14px] align-[-1px]"></i>
// <span class="ml-1">${st.text}</span>
// </span>
// ${closeBtn}
// </div>
// </div>
//
// ${o.message ? `<p class="mt-3 text-[14px] text-gray-200/90 leading-relaxed">${o.message}</p>` : ''}
//
// ${progress}
//
// ${o.finalNote ? `<p class="mt-3 text-[12px] text-gray-400">${o.finalNote}</p>` : ''}
// </div>
// `;
// // return `
// // <div class="glass-card border border-glass-border/70 rounded-2xl p-4 shadow-[0_10px_30px_rgba(0,0,0,.35)] pointer-events-auto tg-card">
// // <div class="flex items-center justify-between gap-4">
// //
// // <!-- Linke Seite -->
// // <div class="flex items-center gap-3 min-w-0">
// // ${o.badge ? `<span class="badge !bg-glass-light/50 !border-glass-border whitespace-nowrap">${String(o.badge).toUpperCase()}</span>` : ''}
// // ${o.domain ? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>` : ''}
// // </div>
// //
// // <!-- Rechte Seite -->
// // <div class="flex items-center gap-2 flex-shrink-0">
// // <span class="text-[13px] text-gray-300/80">Status</span>
// // <span class="px-2.5 py-1 rounded-lg text-[12px] leading-none whitespace-nowrap ${st.pill}">
// // <i class="${st.icon} text-[14px] align-[-1px]"></i>
// // <span class="ml-1">${st.text}</span>
// // </span>
// // ${closeBtn}
// // </div>
// // </div>
// //
// // ${o.message ? `<p class="mt-3 text-[14px] text-gray-200/90 leading-relaxed">${o.message}</p>` : ''}
// //
// // ${progress}
// //
// // ${o.finalNote ? `<p class="mt-3 text-[12px] text-gray-400">${o.finalNote}</p>` : ''}
// // </div>`;
// }
function remove(wrapper) {
if (!wrapper) return;
// wir animieren das eigentliche Card-Element (erstes Kind)
const card = wrapper.firstElementChild || wrapper;
// Wenn schon im Ausblenden, doppelt nicht starten
if (card.classList.contains('tg-out')) return;
// Ausblend-Animation starten
card.classList.remove('tg-in');
card.classList.add('tg-out');
// Sicher entfernen, wenn die Animation fertig ist
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
// kompletten Wrapper aus dem DOM
wrapper.remove();
};
// Normalfall: nach Animation
card.addEventListener('animationend', cleanup, { once: true });
// Fallback: falls animationend aus irgendeinem Grund nicht feuert
setTimeout(cleanup, 600); // > .28s; gib etwas Puffer
}
// ---- Public API ----------------------------------------------------------
function show(options) {
const o = Object.assign({
id: 'toast-' + Date.now(), // gleicher id => ersetzt
state: 'queued', // queued|running|done|failed
badge: null,
domain: '',
message: '',
finalNote: '',
position: 'bottom-right',
duration: 0, // 0 => stehenlassen; bei done/failed wird (falls 0) 6000ms genommen
close: true,
}, options || {});
const root = ensureRoot(o.position);
// Stack-Limit pro Position
const current = Array.from(root.querySelectorAll('[data-tg-id]'));
if (!current.find(n => n.dataset.tgId === o.id) && current.length >= MAX_PER_POSITION) {
remove(current[ current.length - 1 ]); // ältesten unten entfernen
}
const wrapper = document.createElement('div');
wrapper.setAttribute('data-tg-id', o.id);
wrapper.innerHTML = buildHTML(o);
const prev = root.querySelector(`[data-tg-id="${o.id}"]`);
if (prev) {
prev.replaceWith(wrapper);
} else {
root.prepend(wrapper);
}
// Progress anim
const prog = wrapper.querySelector('.tg-progress');
if (prog) {
prog.style.animation = 'tgProgress 1.6s ease-in-out infinite';
}
// Close (Maus & Keyboard)
const btn = wrapper.querySelector('[data-tg-close]');
if (btn) {
btn.addEventListener('click', () => remove(wrapper));
btn.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); remove(wrapper);
}
});
btn.tabIndex = 0;
}
// Mount anim
requestAnimationFrame(() => wrapper.firstElementChild.classList.add('tg-in'));
// Auto-Close
const autoMs = (o.state === 'done' || o.state === 'failed')
? Number(o.duration || 6000)
: Number(o.duration || 0);
if (autoMs > 0) setTimeout(() => remove(wrapper), autoMs);
return o.id;
}
function update(id, options) {
// Einfach erneut show mit gleicher id => ersetzt in-place
if (!id) return;
const root = document.body.querySelector(`[id^="${ROOT_PREFIX}"]`); // irgendeine Ecke
const found = root && document.querySelector(`[data-tg-id="${id}"]`);
const currentPos = found ? found.parentElement.getAttribute('data-toastra-stack') : 'bottom-right';
show(Object.assign({ id, position: currentPos }, options || {}));
}
function dismiss(id) {
const el = id
? document.querySelector(`[data-tg-id="${id}"]`)
: null;
if (el) remove(el);
}
function clear(position) {
const root = position
? document.getElementById(ROOT_PREFIX + position)
: null;
(root ? Array.from(root.children) : Array.from(document.querySelectorAll(`[id^="${ROOT_PREFIX}"] > div`)))
.forEach(remove);
}
window.toastraGlass = { show, update, dismiss, clear };
})();