Radius/add network structure

This commit is contained in:
Luca Haid
2025-12-09 05:34:24 +00:00
parent 60556e5d63
commit 167b038c20
37 changed files with 6833 additions and 2920 deletions
@@ -0,0 +1,110 @@
/**
* TtDataTable - Enhanced data table with loading states (Vue 3)
* Modern, reusable table component with placeholders and skeletons
*/
const TtDataTable = {
name: 'TtDataTable',
props: {
items: {
type: Array,
default: () => []
},
isLoading: {
type: Boolean,
default: false
},
hasSearched: {
type: Boolean,
default: false
},
density: {
type: String,
default: 'compact',
validator: (value) => ['compact', 'ultra-compact', 'normal'].includes(value)
},
tableClass: {
type: String,
default: ''
},
tableStyle: {
type: Object,
default: () => ({})
},
tableMinHeight: {
type: String,
default: 'auto'
},
initialPlaceholderIcon: {
type: String,
default: 'fa-duotone fa-keyboard'
},
initialPlaceholderText: {
type: String,
default: 'Beginnen Sie Ihre Suche.'
},
noResultsPlaceholderIcon: {
type: String,
default: 'fa-duotone fa-database'
},
noResultsPlaceholderText: {
type: String,
default: 'Keine Ergebnisse gefunden.'
},
skeletonRowCount: {
type: Number,
default: 6
}
},
template: `
<div class="tt-scope table-view-wrapper">
<!-- Initial state: Not yet searched -->
<div v-if="!hasSearched" class="table-placeholder" :style="{minHeight: tableMinHeight}">
<i :class="initialPlaceholderIcon"></i>
<div>{{ initialPlaceholderText }}</div>
</div>
<!-- Loading state -->
<div v-else-if="isLoading">
<slot name="loading-placeholder">
<div class="table-wrap" :style="{maxHeight: '65vh', ...tableStyle}">
<table class="tt-table" :class="[density, tableClass]">
<slot name="head"></slot>
<tbody>
<tr v-for="n in skeletonRowCount" :key="'skel'+n">
<slot name="skeleton-row"></slot>
</tr>
</tbody>
</table>
</div>
</slot>
</div>
<!-- No results state -->
<div v-else-if="!items.length" class="table-placeholder" :style="{minHeight: tableMinHeight}">
<i :class="noResultsPlaceholderIcon"></i>
<div>{{ noResultsPlaceholderText }}</div>
</div>
<!-- Data state -->
<template v-else>
<div class="table-wrap" :style="{maxHeight: '65vh', ...tableStyle}">
<table class="tt-table" :class="[density, tableClass]">
<slot name="head"></slot>
<tbody>
<tr v-for="(item, index) in items" :key="index" class="row-fade-in">
<slot name="row" :item="item" :index="index"></slot>
</tr>
</tbody>
</table>
<slot name="observer"></slot>
</div>
</template>
</div>
`
};
// Register component globally if Vue 3 app instance is available
if (window.VueApp) {
window.VueApp.component('tt-data-table', TtDataTable);
}
@@ -0,0 +1,182 @@
/**
* TtStatusChip - Smart online status chip with lazy loading (Vue 3)
* Displays online/offline status with IP address and copy functionality
*/
const TtStatusChip = {
name: 'TtStatusChip',
props: {
username: {
type: String,
required: true
},
apiEndpoint: {
type: String,
default: ''
}
},
emits: ['scan-ip'],
setup(props, { emit }) {
const { ref, onMounted, onBeforeUnmount, watch } = Vue;
const data = ref(null);
const observed = ref(false);
const observer = ref(null);
const isHovering = ref(false);
const ctrlPressed = ref(false);
const tooltipText = ref('IP-Adresse kopieren');
const root = ref(null);
watch(data, (newData) => {
if (newData && newData.ip) {
tooltipText.value = 'IP-Adresse kopieren';
} else {
tooltipText.value = null;
}
});
const fetchState = async () => {
try {
const endpoint = props.apiEndpoint || `${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(props.username)}`;
const response = await fetch(endpoint);
data.value = response.ok ? await response.json() : { online: false, ip: null };
} catch {
data.value = { online: false, ip: null };
}
};
const copyIp = async (event) => {
if (!data.value?.ip) return;
const element = event.currentTarget;
if (!element || element.classList.contains('is-copied')) return;
// Copy to clipboard
if (window.TT_CORE && window.TT_CORE.copyToClipboard) {
await window.TT_CORE.copyToClipboard(data.value.ip);
}
// Visual feedback
element.classList.add('is-copied');
const originalTooltip = tooltipText.value;
tooltipText.value = 'Kopiert!';
setTimeout(() => {
element.classList.remove('is-copied');
tooltipText.value = originalTooltip;
updateTooltip();
}, 1500);
};
const handleKey = (event) => {
const newCtrlPressed = event.ctrlKey || event.metaKey;
if (newCtrlPressed !== ctrlPressed.value) {
ctrlPressed.value = newCtrlPressed;
if (isHovering.value) {
updateTooltip();
}
}
};
const onIpMouseOver = (event) => {
isHovering.value = true;
ctrlPressed.value = event.ctrlKey || event.metaKey;
updateTooltip();
};
const onIpMouseOut = () => {
isHovering.value = false;
ctrlPressed.value = false;
updateTooltip();
};
const updateTooltip = () => {
if (!data.value?.ip) {
tooltipText.value = null;
} else if (isHovering.value && ctrlPressed.value) {
tooltipText.value = 'Scan starten & verbinden';
} else {
tooltipText.value = 'IP-Adresse kopieren';
}
};
const onClickIp = (event) => {
if (!data.value?.ip) return;
if (event.ctrlKey || event.metaKey) {
// Ctrl+Click: emit scan event
event.preventDefault();
emit('scan-ip', { ip: data.value.ip });
} else {
// Normal click: copy IP
copyIp(event);
}
};
onMounted(() => {
// Setup intersection observer for lazy loading
observer.value = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !observed.value) {
observed.value = true;
fetchState();
}
},
{ threshold: 0.1 }
);
if (root.value) {
observer.value.observe(root.value);
}
// Listen for Ctrl/Meta key
document.addEventListener('keydown', handleKey);
document.addEventListener('keyup', handleKey);
});
onBeforeUnmount(() => {
if (observer.value) {
observer.value.disconnect();
}
document.removeEventListener('keydown', handleKey);
document.removeEventListener('keyup', handleKey);
});
return {
data,
tooltipText,
root,
onClickIp,
onIpMouseOver,
onIpMouseOut
};
},
template: `
<div class="tt-scope status-chip-wrap" ref="root">
<!-- Loading skeleton -->
<span v-if="data === null" class="status-chip skeleton">
<span class="dot"></span>
<span class="skeleton-line" style="width: 80px; height: 18px; margin: auto;"></span>
</span>
<!-- Loaded state -->
<span
v-else
class="status-chip"
:class="[data.online ? 'on' : 'off', {'is-clickable': data.ip}]"
:data-tooltip="tooltipText"
@click="onClickIp"
@mouseover="onIpMouseOver"
@mouseout="onIpMouseOut"
>
<span class="dot"></span>
<span class="ip">{{ data.ip || '—' }}</span>
</span>
</div>
`
};
// Register component globally if Vue 3 app instance is available
if (window.VueApp) {
window.VueApp.component('tt-status-chip', TtStatusChip);
}
@@ -0,0 +1,51 @@
/**
* TtInfoCard Component
*
* A reusable info card component for displaying key-value pairs with optional copy button.
* Commonly used in router management and other information displays.
*
* @prop {String} icon - Font Awesome icon class (e.g., 'fa-microchip')
* @prop {String} label - The label text
* @prop {String|Number} value - The value to display (null/undefined shows loading state)
* @prop {Boolean} loading - Explicit loading state (default: false)
* @prop {Boolean} copyable - Whether to show copy button when value exists (default: true)
* @prop {String} skeletonHeight - Height of skeleton loader (default: '29px')
*/
const TtInfoCard = {
name: 'TtInfoCard',
props: {
icon: { type: String, required: true },
label: { type: String, required: true },
value: { type: [String, Number], default: null },
loading: { type: Boolean, default: false },
copyable: { type: Boolean, default: true },
skeletonHeight: { type: String, default: '29px' }
},
template: `
<div class="router-info-card">
<div class="info-card-label">
<i :class="['fa-duotone', icon]"></i>
<span>{{ label }}</span>
</div>
<div class="info-card-value">
<code v-if="!loading && !isValueEmpty">{{ value }}</code>
<code v-else-if="!loading">—</code>
<tt-skeleton v-else :height="skeletonHeight" />
<tt-copy-button
v-if="!loading && !isValueEmpty && copyable"
:text="String(value)"
/>
</div>
</div>
`,
computed: {
isValueEmpty() {
return this.value === null || this.value === undefined || this.value === '';
}
}
};
if (window.VueApp) {
window.VueApp.component('tt-info-card', TtInfoCard);
}
@@ -0,0 +1,63 @@
/**
* TtLoadingIndicator - Processing indicator with progress (Vue 3)
* Displays loading state with animated icon and progress bar
*/
const TtLoadingIndicator = {
name: 'TtLoadingIndicator',
props: {
progress: {
type: Number,
default: 0,
validator: (value) => value >= 0 && value <= 100
},
currentRow: {
type: Number,
default: 0
},
totalRows: {
type: Number,
default: 0
},
currentItem: {
type: String,
default: ''
},
title: {
type: String,
default: 'Verarbeitung läuft...'
},
icon: {
type: String,
default: 'fa-duotone fa-hourglass-half'
}
},
template: `
<div class="tt-scope table-placeholder">
<i
:class="[icon, 'animated-hourglass']"
style="font-size: 36px; margin-bottom: 10px; color: var(--tt-brand-blue);"
></i>
<div class="h5">{{ title }}</div>
<slot name="description">
<p v-if="currentItem" class="muted small">
Aktuell: {{ currentItem }}
</p>
</slot>
<div
class="progress-bar mt-3"
style="width: 250px; margin-left: auto; margin-right: auto;"
>
<div class="bar" :style="{width: progress + '%'}"></div>
</div>
<div v-if="totalRows > 0" class="muted small mt-2">
Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}
</div>
</div>
`
};
// Register component globally if Vue 3 app instance is available
if (window.VueApp) {
window.VueApp.component('tt-loading-indicator', TtLoadingIndicator);
}
@@ -0,0 +1,50 @@
/**
* TtSkeleton - Skeleton loader component (Vue 3)
* Displays animated loading skeleton
*/
const TtSkeleton = {
name: 'TtSkeleton',
props: {
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '12px'
},
borderRadius: {
type: String,
default: '8px'
},
count: {
type: Number,
default: 1
},
spacing: {
type: String,
default: '8px'
}
},
template: `
<div class="tt-scope">
<div
v-for="n in count"
:key="n"
class="skeleton-line"
:style="{
width: width,
'--h': height,
borderRadius: borderRadius,
marginBottom: n < count ? spacing : '0'
}"
></div>
</div>
`
};
// Register component globally if Vue 3 app instance is available
if (window.VueApp) {
window.VueApp.component('tt-skeleton', TtSkeleton);
}
@@ -0,0 +1,46 @@
const TtCopyButton = {
name: 'TtCopyButton',
props: {
text: { type: String, required: true },
size: { type: String, default: 'sm' }, // 'sm' or 'md'
tooltip: { type: String, default: 'Kopieren' },
tooltipAlign: { type: String, default: 'bottom' }
},
template: `
<button
class="icon-btn"
:class="[size, { 'is-copied': isCopied }]"
:data-tooltip="isCopied ? 'Kopiert!' : tooltip"
:data-tooltip-align="tooltipAlign"
@click="copy"
:disabled="isCopied"
>
<i class="fa-duotone fa-copy copy-icon"></i>
<i class="fa-duotone fa-check check-icon"></i>
</button>
`,
data: () => ({
isCopied: false
}),
methods: {
async copy() {
if (this.isCopied) return;
try {
await window.TT_CORE.copyToClipboard(this.text);
this.isCopied = true;
setTimeout(() => {
this.isCopied = false;
}, 1500);
} catch (error) {
console.error('Copy failed:', error);
window.notify?.('error', 'Kopieren fehlgeschlagen');
}
}
}
};
if (window.VueApp) {
window.VueApp.component('tt-copy-button', TtCopyButton);
}
@@ -0,0 +1,105 @@
/**
* TtFileDropzone - Drag & drop file upload (Vue 3)
* Modern file upload component with drag-and-drop support
*/
const TtFileDropzone = {
name: 'TtFileDropzone',
props: {
accept: {
type: String,
default: '.xlsx'
},
multiple: {
type: Boolean,
default: false
},
buttonText: {
type: String,
default: 'Datei auswählen'
},
dropText: {
type: String,
default: 'Hierhin ziehen oder'
},
icon: {
type: String,
default: 'fa-duotone fa-cloud-arrow-up'
}
},
emits: ['file-selected'],
setup(props, { emit }) {
const { ref, computed } = Vue;
const dragCounter = ref(0);
const fileInput = ref(null);
const isDragging = computed(() => dragCounter.value > 0);
const onDrop = (event) => {
dragCounter.value = 0;
const files = event.dataTransfer.files;
if (files && files.length > 0) {
const payload = props.multiple ? files : files[0];
emit('file-selected', payload);
}
};
const onFileChange = (event) => {
const files = event.target.files;
const payload = props.multiple ? files : files[0];
emit('file-selected', payload);
};
const openFilePicker = () => {
fileInput.value?.click();
};
return {
dragCounter,
fileInput,
isDragging,
onDrop,
onFileChange,
openFilePicker
};
},
template: `
<label
class="tt-scope file-drop"
:class="{'is-dragover': isDragging}"
@dragover.prevent
@dragenter.prevent="dragCounter++"
@dragleave.prevent="dragCounter--"
@drop.prevent="onDrop"
>
<input
type="file"
:accept="accept"
:multiple="multiple"
@change="onFileChange"
hidden
ref="fileInput"
>
<div class="file-cta">
<i :class="icon"></i>
<div>
{{ dropText }}
<button
type="button"
class="link-btn"
@click.prevent="openFilePicker"
>
{{ buttonText }}
</button>
</div>
</div>
</label>
`
};
// Register component globally if Vue 3 app instance is available
if (window.VueApp) {
window.VueApp.component('tt-file-dropzone', TtFileDropzone);
}
@@ -0,0 +1,328 @@
/**
* TtSmartAutocomplete - Smart autocomplete with mode switching (Vue 3)
* Advanced autocomplete component with XINON/ESTMK mode switching
*/
const TtSmartAutocomplete = {
name: 'TtSmartAutocomplete',
props: {
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: 'Rechnungsadresse suchen'
},
wide: {
type: Boolean,
default: true
},
apiEndpoint: {
type: String,
default: ''
}
},
emits: ['update:modelValue', 'select', 'change', 'enter', 'mode-change'],
setup(props, { emit }) {
const { ref, computed, watch, onMounted, nextTick } = Vue;
const q = ref(props.modelValue || '');
const open = ref(false);
const items = ref({});
const highlighted = ref(-1);
const busy = ref(false);
const mode = ref('autocomplete');
const logoDropdownOpen = ref(false);
const hasMoreResults = ref(false);
const mainInput = ref(null);
const resultsList = ref(null);
let debouncedFetch = null;
const highlightedId = computed(() => {
const keys = Object.keys(items.value);
return keys[highlighted.value] || null;
});
const placeholderText = computed(() => {
return mode.value === 'autocomplete'
? (props.placeholder || 'Rechnungsadresse suchen')
: 'Partner-Kundennummer eingeben';
});
watch(() => props.modelValue, (val) => {
if (val !== q.value) {
q.value = val;
if (mode.value === 'autocomplete') {
debouncedFetch();
}
}
});
const debounce = (fn, ms) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), ms);
};
};
const fetchItems = async () => {
if (mode.value !== 'autocomplete' || !q.value || q.value.length < 2) {
items.value = {};
hasMoreResults.value = false;
return;
}
busy.value = true;
try {
const endpoint = props.apiEndpoint || `${window.TT_CONFIG.BASE_PATH}/Address/Api?do=findAddress&fibu_primary_account=1&q=${encodeURIComponent(q.value)}`;
const response = await fetch(endpoint);
if (response.ok) {
const json = await response.json();
const addresses = json?.result?.addresses || {};
if (addresses.more) {
hasMoreResults.value = true;
delete addresses.more;
} else {
hasMoreResults.value = false;
}
items.value = addresses;
highlighted.value = 0;
} else {
items.value = {};
hasMoreResults.value = false;
}
} catch {
items.value = {};
hasMoreResults.value = false;
}
busy.value = false;
};
const toggleLogoDropdown = () => {
logoDropdownOpen.value = !logoDropdownOpen.value;
if (logoDropdownOpen.value) open.value = false;
};
const selectMode = (m) => {
if (mode.value !== m) {
mode.value = m;
emit('mode-change', m);
clear();
}
logoDropdownOpen.value = false;
nextTick(() => mainInput.value?.focus());
};
const onInput = () => {
emit('update:modelValue', q.value);
if (mode.value === 'autocomplete') {
debouncedFetch();
}
};
const onEnter = () => {
if (mode.value === 'autocomplete') {
chooseHighlighted(true);
} else {
emit('enter');
}
};
const maybeOpen = () => {
open.value = true;
if (q.value) debouncedFetch();
};
const deferClose = () => {
setTimeout(() => {
open.value = false;
logoDropdownOpen.value = false;
}, 150);
};
const clear = () => {
q.value = '';
items.value = {};
highlighted.value = -1;
emitSelection('', '');
if (mode.value === 'autocomplete') {
open.value = true;
debouncedFetch();
}
};
const move = (direction) => {
const keys = Object.keys(items.value);
if (!keys.length) return;
highlighted.value = (highlighted.value + direction + keys.length) % keys.length;
nextTick(() => {
const active = resultsList.value?.querySelector('.is-active');
if (active) active.scrollIntoView({ block: 'center', behavior: 'smooth' });
});
};
const chooseHighlighted = (enterPressed) => {
const id = highlightedId.value;
if (id) {
choose(id, items.value[id], enterPressed);
} else if (enterPressed) {
emit('enter');
}
};
const choose = (id, display, emitEnter) => {
const custnum = (display.match(/\[(\d+)\]/) || [])[1] || '';
emitSelection(custnum, display);
open.value = false;
if (emitEnter) emit('enter');
};
const emitSelection = (custnum, display) => {
emit('select', { custnum, display });
emit('update:modelValue', display);
emit('change', display);
};
onMounted(() => {
debouncedFetch = debounce(fetchItems, 220);
});
return {
q,
open,
items,
highlighted,
busy,
mode,
logoDropdownOpen,
hasMoreResults,
mainInput,
resultsList,
highlightedId,
placeholderText,
toggleLogoDropdown,
selectMode,
onInput,
onEnter,
maybeOpen,
deferClose,
clear,
move,
choose
};
},
template: `
<div
class="tt-scope ac-root"
:data-wide="wide ? '1' : null"
@keydown.down.prevent="mode === 'autocomplete' && move(1)"
@keydown.up.prevent="mode === 'autocomplete' && move(-1)"
@keydown.enter.prevent="onEnter"
>
<span class="ac-focus-tooltip">Klicken Sie auf das Logo, um die Kundenbasis zu wechseln</span>
<div class="input-wrap">
<!-- Logo switcher -->
<div
class="logo-switcher"
@mousedown.prevent.stop="toggleLogoDropdown"
:class="{'is-open': logoDropdownOpen}"
>
<img
v-if="mode === 'autocomplete'"
src="/img/xinon-logo.png"
class="input-icon-logo"
alt="Xinon Logo"
>
<img
v-else
src="/img/estmk_logo.png"
class="input-icon-logo"
alt="ESTMK Logo"
>
<i class="fa-solid fa-chevron-down switcher-caret"></i>
</div>
<!-- Input -->
<input
ref="mainInput"
:placeholder="placeholderText"
class="ri"
v-model="q"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
@input="onInput"
@focus="mode === 'autocomplete' && maybeOpen()"
@blur="deferClose"
/>
<!-- Clear button -->
<button
v-if="q"
class="btn-clear"
@mousedown.prevent="clear"
title="Feld leeren"
>
<i class="fa-duotone fa-xmark"></i>
</button>
</div>
<!-- Logo dropdown -->
<transition name="ac-pop">
<div v-if="logoDropdownOpen" class="logo-dropdown">
<div class="logo-option" @mousedown.prevent="selectMode('autocomplete')">
<img src="/img/xinon-logo.png" alt="Xinon Logo">
<span>XINON (Suche)</span>
</div>
<div class="logo-option" @mousedown.prevent="selectMode('text')">
<img src="/img/estmk_logo.png" alt="ESTMK Logo">
<span>ESTMK (Eingabe)</span>
</div>
</div>
</transition>
<!-- Autocomplete panel -->
<transition name="ac-pop">
<div v-if="open && mode === 'autocomplete'" class="ac-panel" :class="{'wide': wide}">
<div v-if="busy" class="ac-skel">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
<template v-else>
<div v-if="!Object.keys(items).length && !hasMoreResults" class="ac-empty muted">
Keine Treffer
</div>
<ul ref="resultsList" class="ac-list" role="listbox">
<li
v-for="(disp, id) in items"
:key="id"
:class="['ac-item', highlightedId === id ? 'is-active' : '']"
@mousedown.prevent="choose(id, disp)"
>
<i class="fa-duotone fa-address-card"></i>
<span class="txt">{{ disp }}</span>
</li>
<li v-if="hasMoreResults" class="ac-more-info muted">
<i class="fa-duotone fa-ellipsis"></i>
<span class="txt">Mehr Ergebnisse verfügbar</span>
</li>
</ul>
</template>
</div>
</transition>
</div>
`
};
// Register component globally if Vue 3 app instance is available
if (window.VueApp) {
window.VueApp.component('tt-smart-autocomplete', TtSmartAutocomplete);
}
@@ -0,0 +1,71 @@
/**
* TtViewSwitcher - Tab-based view switcher (Vue 3)
* Navigation component for switching between views
*/
const TtViewSwitcher = {
name: 'TtViewSwitcher',
props: {
modelValue: {
type: String,
required: true
},
options: {
type: Array,
required: true,
// Format: [{ id: 'view1', name: 'View 1', icon: 'fa-icon' }]
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const { computed } = Vue;
const currentView = computed({
get() {
return props.modelValue;
},
set(val) {
emit('update:modelValue', val);
}
});
return {
currentView
};
},
template: `
<div class="tt-scope">
<!-- Desktop tabs -->
<nav class="view-tabs">
<button
v-for="option in options"
:key="option.id"
class="tab-btn"
:class="{active: currentView === option.id}"
@click="currentView = option.id"
>
<i v-if="option.icon" :class="option.icon"></i>
{{ option.name }}
</button>
</nav>
<!-- Mobile select -->
<div class="view-select-wrap select">
<select v-model="currentView">
<option
v-for="option in options"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
</div>
</div>
`
};
// Register component globally if Vue 3 app instance is available
if (window.VueApp) {
window.VueApp.component('tt-view-switcher', TtViewSwitcher);
}
@@ -0,0 +1,109 @@
/**
* TtDialog - Modern modal dialog (Vue 3)
* Flexible dialog component with portal rendering
*/
const TtDialog = {
name: 'TtDialog',
props: {
show: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
modalClass: {
type: String,
default: ''
},
size: {
type: String,
default: 'normal', // normal | wide | full
validator: (value) => ['normal', 'wide', 'full'].includes(value)
}
},
emits: ['close'],
setup(props, { emit }) {
const { ref, computed, watch, nextTick, onBeforeUnmount } = Vue;
const el = ref(null);
const computedModalClass = computed(() => {
const classes = [props.modalClass];
if (props.size === 'wide') classes.push('modal-card-wide');
if (props.size === 'full') classes.push('modal-card-full');
return classes.join(' ');
});
watch(() => props.show, (isShown) => {
if (isShown) {
nextTick(() => {
// Move modal to body to prevent z-index issues
if (el.value && el.value.nodeType === 1 && el.value.parentNode !== document.body) {
document.body.appendChild(el.value);
}
document.body.style.overflow = 'hidden';
});
} else {
document.body.style.overflow = '';
}
});
onBeforeUnmount(() => {
if (props.show && el.value && el.value.nodeType === 1 && el.value.parentNode === document.body) {
document.body.removeChild(el.value);
}
document.body.style.overflow = '';
});
const handleClose = () => {
emit('close');
};
return {
el,
computedModalClass,
handleClose
};
},
template: `
<transition name="fade">
<div
v-if="show"
ref="el"
class="tt-scope modal-overlay"
@click.self="handleClose"
>
<div class="modal-card pop" :class="computedModalClass">
<div class="modal-head">
<div class="modal-title">
<i class="fa-duotone fa-database"></i>
{{ title }}
</div>
<button
class="icon-btn"
@click="handleClose"
aria-label="Close"
title="Schließen"
>
<i class="fa-duotone fa-xmark"></i>
</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</transition>
`
};
// Register component globally if Vue 3 app instance is available
if (window.VueApp) {
window.VueApp.component('tt-dialog', TtDialog);
}