<!DOCTYPE html>
<html lang="{{ locale | default('en') }}" x-data="{ darkMode: false }" :class="{ 'dark': darkMode }"
class="bg-slate-100 text-sm">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="{{site_description|default('RustPBX Admin Console')}}" />
<meta name="locale" content="{{ locale | default('en') }}" />
<title>{% block title %}{{page_title|default('RustPBX Admin')}}{% endblock %}</title>
<script type="application/json" id="__currentUser">{{ current_user | json | safe }}</script>
<script type="application/json" id="__locales">{{ available_locales | json | safe }}</script>
{% block js_head %}
<script>
window.__currentUser = JSON.parse(document.getElementById('__currentUser').textContent || 'null');
window.__consoleApiPrefix = {{ api_prefix | tojson }};
window.hasPermission = function (perm) {
// If current_user is missing but user reached this authenticated page,
// default to showing items (backend has already authenticated)
if (!window.__currentUser) return true;
var perms = window.__currentUser.permissions || [];
if (perms.indexOf('*') !== -1) return true;
return perms.indexOf(perm) !== -1;
};
window.can = window.hasPermission;
</script>
{% for js in js_files %}
<script src="{{ js }}" defer></script>
{% endfor %}
{% if addon_scripts %}
{% for script in addon_scripts %}
<script src="{{ script|safe }}" defer>
</script>
{% endfor %}
{% endif %}
<script defer
src="{{alpine_js|default('https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.15.0/cdn.min.js')|safe}}"></script>
<script
src="{{tailwind_js|default('https://cdnjs.cloudflare.com/ajax/libs/tailwindcss-browser/4.1.13/index.global.min.js')|safe}}"></script>
<script>
window.RustPBX = window.RustPBX || {};
window.RustPBX.plugins = [];
window.registerConsolePlugin = (plugin) => {
window.RustPBX.plugins.push(plugin);
};
</script>
<script>
document.addEventListener('alpine:init', () => {
const STORAGE_KEYS = {
sidebarCollapsed: 'console.sidebar.collapsed',
};
Alpine.data('consoleNotifications', () => ({
notifications: [],
init() {
const params = new URLSearchParams(window.location.search);
if (params.has('success')) {
this.add(params.get('success'), 'success');
const url = new URL(window.location);
url.searchParams.delete('success');
window.history.replaceState({}, '', url);
}
if (params.has('error')) {
this.add(params.get('error'), 'error');
const url = new URL(window.location);
url.searchParams.delete('error');
window.history.replaceState({}, '', url);
}
window.addEventListener('console:notify', (event) => {
this.add(event.detail.message, event.detail.type || 'info');
});
window.addEventListener('toast', (event) => {
const detail = event.detail || {};
let type = 'info';
const title = (detail.title || '').toLowerCase();
if (title.includes('fail') || title.includes('error') || title.includes('missing') || title.includes('invalid')) {
type = 'error';
} else if (title.includes('success') || title.includes('saved')) {
type = 'success';
}
const message = detail.title ? `${detail.title}: ${detail.message}` : detail.message;
this.add(message, type);
});
},
add(message, type = 'info') {
const id = Date.now();
this.notifications.push({ id, message, type });
setTimeout(() => {
this.remove(id);
}, 5000);
},
remove(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
}
}));
Alpine.data('consoleLayoutShell', () => ({
sidebarOpen: false,
sidebarCollapsed: false,
init() {
this.sidebarCollapsed = this.readCollapsedState();
},
readCollapsedState() {
try {
const raw = window.localStorage.getItem(STORAGE_KEYS.sidebarCollapsed);
return raw === 'true';
} catch (_) {
return false;
}
},
toggleSidebarCollapse() {
this.sidebarCollapsed = !this.sidebarCollapsed;
this.persistCollapsedState();
},
persistCollapsedState() {
try {
window.localStorage.setItem(
STORAGE_KEYS.sidebarCollapsed,
this.sidebarCollapsed ? 'true' : 'false',
);
} catch (_) {
/* ignore storage errors */
}
},
}));
Alpine.data('localeSwitcher', () => ({
open: false,
current: (document.querySelector('meta[name="locale"]') || {}).content || 'en',
locales: JSON.parse(document.getElementById('__locales').textContent || '[]'),
flags: {
'en': '๐บ๐ธ', 'en-US': '๐บ๐ธ', 'en-GB': '๐ฌ๐ง',
'zh': '๐จ๐ณ', 'zh-CN': '๐จ๐ณ', 'zh-TW': '๐น๐ผ', 'zh-HK': '๐ญ๐ฐ',
'ja': '๐ฏ๐ต', 'ko': '๐ฐ๐ท',
'fr': '๐ซ๐ท', 'de': '๐ฉ๐ช', 'es': '๐ช๐ธ',
'pt': '๐ง๐ท', 'pt-BR': '๐ง๐ท', 'pt-PT': '๐ต๐น',
'it': '๐ฎ๐น', 'ru': '๐ท๐บ', 'ar': '๐ธ๐ฆ',
'tr': '๐น๐ท', 'pl': '๐ต๐ฑ', 'nl': '๐ณ๐ฑ',
'sv': '๐ธ๐ช', 'da': '๐ฉ๐ฐ', 'fi': '๐ซ๐ฎ', 'nb': '๐ณ๐ด',
'cs': '๐จ๐ฟ', 'sk': '๐ธ๐ฐ', 'hu': '๐ญ๐บ', 'ro': '๐ท๐ด',
'uk': '๐บ๐ฆ', 'vi': '๐ป๐ณ', 'th': '๐น๐ญ', 'id': '๐ฎ๐ฉ',
'ms': '๐ฒ๐พ', 'he': '๐ฎ๐ฑ', 'el': '๐ฌ๐ท', 'hr': '๐ญ๐ท',
},
flag(code) {
return this.flags[code] || '๐';
},
label(locale) {
return locale.native_name || locale.name || locale.code;
},
select(code) {
document.cookie = `locale=${code}; path=/; max-age=31536000; SameSite=Lax`;
window.location.reload();
},
}));
Alpine.data('consoleConfirmDialog', () => ({
open: false,
title: '{{ "common.confirm_title" | t }}',
message: '',
confirmLabel: '{{ "common.confirm" | t }}',
cancelLabel: '{{ "common.cancel" | t }}',
destructive: false,
onConfirm: null,
init() {
window.addEventListener('console:confirm', (event) => {
const detail = event.detail || {};
this.title = detail.title || '{{ "common.confirm_title" | t }}';
this.message = detail.message || detail.description || '';
this.confirmLabel = detail.confirmLabel || '{{ "common.confirm" | t }}';
this.cancelLabel = detail.cancelLabel || '{{ "common.cancel" | t }}';
this.destructive = Boolean(detail.destructive);
this.onConfirm = typeof detail.onConfirm === 'function' ? detail.onConfirm : null;
this.open = true;
this.$nextTick(() => {
this.$refs.confirmButton?.focus();
});
});
},
close() {
this.open = false;
this.onConfirm = null;
},
confirm() {
if (typeof this.onConfirm === 'function') {
const result = this.onConfirm();
if (result && typeof result.then === 'function') {
result.finally(() => this.close());
return;
}
}
this.close();
},
}));
});
</script>
{% endblock %}
{% block js_ext %}
{% endblock %}
<style>
[x-cloak] {
display: none !important;
}
input::placeholder,
textarea::placeholder {
color: rgba(148, 163, 184, 0.7);
opacity: 1;
}
.dark input::placeholder,
.dark textarea::placeholder {
color: rgba(148, 163, 184, 0.6);
}
</style>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900">
<div x-data="consoleLayoutShell()" x-init="init()" class="min-h-screen">
<!-- Off-canvas overlay -->
<div x-show="sidebarOpen" x-transition.opacity class="fixed inset-0 z-30 bg-black/40 lg:hidden"
@click="sidebarOpen=false"></div>
<!-- Sidebar -->
<aside
class="fixed inset-y-0 left-0 z-40 w-72 transform bg-white ring-1 ring-black/5 shadow-xl lg:translate-x-0 lg:shadow-none lg:ring-0"
:class="{ '-translate-x-full': !sidebarOpen, 'lg:w-24': sidebarCollapsed, 'lg:w-72': !sidebarCollapsed }"
x-transition>
<div class="flex h-full flex-col">
<div class="flex h-16 items-center justify-between gap-2 border-b border-slate-100 px-6">
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-lg bg-sky-600 text-white grid place-content-center font-bold">R
</div>
<div class="font-semibold" x-show="!sidebarCollapsed" x-transition.opacity x-cloak>RustPBX</div>
</div>
<button class="hidden rounded-lg p-2 hover:bg-slate-100 lg:inline-flex"
@click="toggleSidebarCollapse()" aria-label="Toggle sidebar">
<svg x-show="!sidebarCollapsed" x-transition.opacity class="h-5 w-5 text-slate-600"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 5l-7 7 7 7" />
</svg>
<svg x-show="sidebarCollapsed" x-transition.opacity class="h-5 w-5 text-slate-600"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Nav -->
<nav class="flex-1 overflow-y-auto px-3 py-4 text-sm" :class="{ 'px-2': sidebarCollapsed }">
{% set bp = base_path | safe %}
<a href="{{ bp }}/"
class="group flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active=='dashboard' else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }" title="Dashboard">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ "nav.dashboard" | t }}</span>
</a>
<a href="{{ bp }}/extensions" x-show="can('extensions:read')" x-cloak
class="group mt-1 flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active=='extensions' else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }"
title="{{ 'nav.extensions' | t }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
</svg>
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ "nav.extensions" | t }}</span>
</a>
<a href="{{ bp }}/routing" x-show="can('routes:read')" x-cloak
class="group mt-1 flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active=='routing' else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }" title="{{ 'nav.routing' | t }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ "nav.routing" | t }}</span>
</a>
<a href="{{ bp }}/sip-trunk" x-show="can('trunks:read')" x-cloak
class="group mt-1 flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active in ['sip-trunk', 'sip-trunk-detail'] else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }"
title="{{ 'nav.sip_trunk' | t }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ "nav.sip_trunk" | t }}</span>
</a>
<a href="{{ bp }}/call-records" x-show="can('cdr:read')" x-cloak
class="group mt-1 flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active=='call-records' else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }"
title="{{ 'nav.call_records' | t }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ "nav.call_records" | t
}}</span>
</a>
<a href="{{ bp }}/diagnostics" x-show="can('diagnostics:read')" x-cloak
class="group mt-1 flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active=='diagnostics' else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }"
title="{{ 'nav.diagnostics' | t }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 0-6.23-.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
</svg>
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ "nav.diagnostics" | t }}</span>
</a>
<a href="{{ bp }}/metrics/runtime" x-show="can('metrics:read')" x-cloak
class="group mt-1 flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active=='metrics' else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }" title="{{ 'nav.metrics' | t }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ "nav.metrics" | t }}</span>
</a>
<a href="{{ bp }}/addons" x-show="can('system:read')" x-cloak
class="group mt-1 flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active=='addons' else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }" title="{{ 'nav.addons' | t }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ "nav.addons" | t }}</span>
</a>
<a href="{{ bp }}/notifications" x-show="can('system:read') || can('extensions:read')" x-cloak
class="group mt-1 flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active=='notifications' else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }"
title="{{ 'nav.notifications' | t }}" x-data="{ unread: 0 }"
x-init="fetch('{{ api_prefix | safe }}/notifications/unread-count').then(r=>r.json()).then(d=>{ unread=d.count; }).catch(()=>{})">
<span class="relative">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
<span x-show="unread > 0" x-cloak
class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-rose-500 px-0.5 text-[10px] font-bold text-white leading-none"
x-text="unread > 99 ? '99+' : unread"></span>
</span>
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ "nav.notifications" | t
}}</span>
</a>
<a href="{{ bp }}/settings"
x-show="can('system:read') || can('users:read') || can('departments:read')" x-cloak
class="group mt-1 flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active=='settings' else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }"
title="{{ 'nav.settings' | t }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
</svg>
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ "nav.settings" | t }}</span>
</a>
{% if addon_sidebar_items %}
<div class="my-2 border-t border-slate-100"></div>
{% for item in addon_sidebar_items %}
{% set item_display_name = item.name_key | default(value=item.name) | t %}
<a href="{{ item.url }}" {% if item.permission %}x-show="can('{{ item.permission }}')" x-cloak{%
endif %}
class="group mt-1 flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-slate-50 transition-all duration-200 {{ 'bg-sky-50 text-sky-700' if nav_active==item.name else 'text-slate-700' }}"
:class="{ 'justify-center gap-0 px-2 py-3': sidebarCollapsed }" title="{{ item_display_name }}">
{{ item.icon | safe }}
<span x-show="!sidebarCollapsed" x-transition.opacity x-cloak>{{ item_display_name }}</span>
</a>
{% endfor %}
{% endif %}
</nav>
<div class="border-t border-slate-100 p-4 text-xs text-slate-500" x-show="!sidebarCollapsed"
x-transition.opacity x-cloak>
<a :href='"https://rustpbx.com?version={{site_version}}&edition={{edition}}&utm_source="+location.origin'
target="_blank" class="underline hover:text-slate-700">{{site_name|default('RustPBX')}} -
{{site_version}}</a> / <span>{{edition}}</span>
</div>
</div>
</aside>
<!-- Main area -->
<div class="" x-bind:class="sidebarCollapsed ? 'lg:pl-24' : 'lg:pl-72'">
<!-- Top bar -->
<header
class="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-slate-200 bg-white/80 px-6 backdrop-blur">
<div class="flex items-center gap-2">
<button class="rounded-lg p-2 hover:bg-slate-100 lg:hidden" @click="sidebarOpen = true"
aria-label="Open sidebar">
<svg class="h-5 w-5 text-slate-700" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<div class="flex items-center gap-3">
{% if demo_mode %}
<div
class="hidden items-center gap-2 rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-200 sm:flex">
<span class="flex h-2 w-2">
<span
class="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-amber-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
</span>
<span>{{ "common.demo_mode" | t }}: {{ "common.demo_data_resets" | t }}</span>
<span class="mx-1 text-amber-300">|</span>
<a href="https://miuda.ai/?from=demo" target="_blank"
class="font-semibold text-sky-600 hover:text-sky-500">Commercial Edition →</a>
</div>
{% endif %}
<a href="{{ base_path | safe }}/notifications" class="relative rounded-lg p-2 hover:bg-slate-100"
title="Notifications" x-data="{ unread: 0 }" x-init="
const refresh = () => fetch('{{ api_prefix | safe }}/notifications/unread-count').then(r=>r.json()).then(d=>{ unread=d.count; }).catch(()=>{});
refresh();
window.addEventListener('notifications:refresh', refresh);
">
<svg class="h-5 w-5 text-slate-600" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span x-show="unread > 0" x-cloak
class="absolute top-0.5 right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-rose-500 px-0.5 text-[10px] font-bold text-white leading-none"
x-text="unread > 99 ? '99+' : unread"></span>
</a>
{% if logout_url %}
<!-- Language switcher -->
{% if available_locales and available_locales | length > 1 %}
<div x-data="localeSwitcher()" class="relative" @keydown.escape.window="open=false">
<button @click="open=!open" @click.outside="open=false" type="button"
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm hover:bg-slate-100 transition-colors"
:aria-expanded="open" aria-haspopup="listbox">
<span x-text="flag(current)" class="text-base leading-none"></span>
<span x-text="current.toUpperCase()"
class="hidden sm:inline text-xs font-medium text-slate-600 uppercase tracking-wide"></span>
<svg class="h-3.5 w-3.5 text-slate-400 transition-transform duration-150"
:class="{'rotate-180': open}" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd" />
</svg>
</button>
<div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-1 w-44 origin-top-right rounded-xl bg-white py-1 shadow-lg ring-1 ring-black/8 focus:outline-none z-50"
role="listbox">
<template x-for="loc in locales" :key="loc.code">
<button @click="select(loc.code)" type="button" role="option"
:aria-selected="loc.code === current"
class="flex w-full items-center gap-2.5 px-3 py-2 text-sm text-left transition-colors"
:class="loc.code === current
? 'bg-sky-50 text-sky-700 font-medium'
: 'text-slate-700 hover:bg-slate-50'">
<span x-text="flag(loc.code)" class="text-base leading-none w-6 text-center"></span>
<span x-text="label(loc)"></span>
<svg x-show="loc.code === current" class="ml-auto h-4 w-4 text-sky-500 shrink-0"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414L8.414 15l-4.121-4.121a1 1 0 011.414-1.414L8.414 12.172l6.879-6.879a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
</button>
</template>
</div>
</div>
{% endif %}
<a href="{{ logout_url }}"
class="rounded-lg bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-400">{{
"common.sign_out" | t }}</a>
{% endif %}
</div>
</header>
<main class="min-h-[calc(100vh-64px)] bg-slate-50">
<!-- Issue #175: pending-reload banner -->
<div x-data="{
show: false,
dismissed: false,
init() {
fetch('{{ api_prefix | safe }}/pending-reloads')
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(d) {
if (!d) return;
this.show = !!d.pending;
}.bind(this))
.catch(function() {});
}
}" x-init="init()" x-show="show" x-cloak x-transition
class="mx-4 mt-4 flex items-start justify-between gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 shadow-sm">
<div class="flex items-center gap-2">
<svg class="h-4 w-4 shrink-0 text-amber-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd" />
</svg>
<span>{{ "common.pending_reload_notice" | t }}</span>
</div>
<button type="button" @click="dismissed=true; show=false"
class="shrink-0 rounded p-0.5 hover:bg-amber-100" aria-label="Dismiss">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
{% block content %}{% endblock %}
</main>
<footer class="py-6 text-center text-xs text-slate-500">
{{site_name|default('RustPBX')}} ยท {{site_footer}}
</footer>
</div>
</div>
<div x-data="consoleConfirmDialog()" x-show="open" x-cloak x-transition.opacity.duration.150ms
class="fixed inset-0 z-[100] flex items-center justify-center">
<div class="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" @click="close" aria-hidden="true"></div>
<div class="relative z-10 w-full max-w-sm origin-center scale-95 transform rounded-xl bg-white p-6 shadow-2xl ring-1 ring-black/10"
x-transition.scale.duration.150ms @keydown.escape.window="close">
<div class="flex items-start gap-3">
<div class="mt-1 flex h-10 w-10 items-center justify-center rounded-full"
:class="destructive ? 'bg-rose-100 text-rose-600 ring-1 ring-rose-200' : 'bg-sky-100 text-sky-600 ring-1 ring-sky-200'">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v4m0 4h.01M5.25 7.5h13.5L18 19.5H6l-.75-12z" />
</svg>
</div>
<div class="flex-1 space-y-2">
<h2 class="text-base font-semibold text-slate-900" x-text="title"></h2>
<p class="text-sm text-slate-600" x-show="message" x-text="message"></p>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<button type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:ring-offset-2"
@click="close" x-text="cancelLabel"></button>
<button type="button" x-ref="confirmButton"
class="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold text-white shadow-sm transition focus:outline-none focus:ring-2 focus:ring-offset-2"
:class="destructive ? 'bg-rose-600 hover:bg-rose-500 focus:ring-rose-500' : 'bg-sky-600 hover:bg-sky-500 focus:ring-sky-500'"
@click="confirm" x-text="confirmLabel"></button>
</div>
</div>
</div>
<div x-data="consoleNotifications()"
class="fixed top-4 right-4 z-[110] flex flex-col gap-2 w-full max-w-sm pointer-events-none">
<template x-for="notification in notifications" :key="notification.id">
<div class="pointer-events-auto flex items-start gap-3 rounded-lg p-4 shadow-lg ring-1 transition-all duration-300"
:class="{
'bg-white text-slate-700 ring-slate-200': notification.type === 'info',
'bg-emerald-50 text-emerald-800 ring-emerald-200': notification.type === 'success',
'bg-rose-50 text-rose-800 ring-rose-200': notification.type === 'error'
}" x-transition:enter="transform ease-out duration-300 transition"
x-transition:enter-start="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
x-transition:enter-end="translate-y-0 opacity-100 sm:translate-x-0"
x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="shrink-0">
<template x-if="notification.type === 'success'">
<svg class="h-5 w-5 text-emerald-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
</template>
<template x-if="notification.type === 'error'">
<svg class="h-5 w-5 text-rose-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd" />
</svg>
</template>
<template x-if="notification.type === 'info'">
<svg class="h-5 w-5 text-sky-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd" />
</svg>
</template>
</div>
<div class="flex-1 pt-0.5">
<p class="text-sm font-medium" x-text="notification.message"></p>
</div>
<button type="button" @click="remove(notification.id)"
class="ml-4 inline-flex shrink-0 rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2"
:class="{
'text-slate-500 hover:bg-slate-100 focus:ring-slate-500': notification.type === 'info',
'text-emerald-500 hover:bg-emerald-100 focus:ring-emerald-500': notification.type === 'success',
'text-rose-500 hover:bg-rose-100 focus:ring-rose-500': notification.type === 'error'
}">
<span class="sr-only">Close</span>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
</button>
</div>
</template>
</div>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id={{ga|default('G-6YYK5T2TWT')}}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', "{{ga|default('G-6YYK5T2TWT')}}");
</script>
{% block scripts %}{% endblock %}
</body>
</html>