admin-chat.html
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Workspace{{end}} - {{dashboard.SiteName}}</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" crossorigin="anonymous" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2/sse.min.js" integrity="sha384-BkCCd4DbvhfvRLfThp+5RN2KiB1FBROoAtwZFfceJishUnpFW1eF/aqG14M5TwA2" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js" integrity="sha384-948ahk4ZmxYVYOc+rxN1H2gM1EJ2Duhp7uHtZ4WSLkV4Vtx5MUqnV+l7u9B+jFv+" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" integrity="sha384-vdScihEZCfbPnBQf+lc7LgXUdJVYyhC3yWHUW5C5P5GpHRqVnaM6HJELJxT6IqwM" crossorigin="anonymous"></script>
<style>
/* Custom scrollbar for chat messages */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.2);
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.3);
}
/* Chat send button loading state */
.chat-send-loading { display: none; }
.chat-form.htmx-request .chat-send-icon { display: none; }
.chat-form.htmx-request .chat-send-loading { display: inline-flex; }
/* Prevent body scroll */
body {
overflow: hidden;
}
/* Code preview styling */
.code-preview {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
tab-size: 2;
}
/* AI response prose styling */
.prose p {
margin-bottom: 0.75em;
}
.prose p:last-child {
margin-bottom: 0;
}
.prose ul, .prose ol {
margin-top: 0.5em;
margin-bottom: 0.75em;
}
.prose li {
margin-bottom: 0.25em;
}
.prose code {
background: oklch(var(--b3));
padding: 0.125em 0.25em;
border-radius: 0.25em;
font-size: 0.875em;
}
.prose pre {
background: oklch(var(--b3));
padding: 0.75em;
border-radius: 0.5em;
overflow-x: auto;
margin: 0.75em 0;
}
.prose pre code {
background: none;
padding: 0;
}
/* Desktop (lg+): inline panel, hidden via display:none */
@media (min-width: 1024px) {
#chat-panel[data-state="closed"] {
display: none;
}
}
/* Mobile (<lg): fixed drawer with slide animation */
@media (max-width: 1023px) {
#chat-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 40;
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
}
#chat-panel[data-state="open"] {
transform: translateX(0);
}
}
</style>
</head>
<body class="h-screen bg-base-200">
<div class="flex h-screen">
<!-- Slim Sidebar -->
{{template "admin-nav-slim.html" .}}
<!-- Main Content Area -->
<div class="flex flex-1 min-w-0">
<!-- Preview Panel (flex-1, targeted swap destination) -->
<div id="preview-content" class="flex-1 flex flex-col min-w-0 bg-base-100"
hx-boost="true" hx-target="#preview-content" hx-select="#preview-content" hx-swap="outerHTML">
{{block "preview" .}}{{end}}
</div>
<!-- Mobile overlay -->
<div id="chat-overlay" class="fixed inset-0 bg-black/50 z-30 hidden" onclick="toggleChat()"></div>
<!-- Chat Panel (inline on desktop, drawer on mobile) -->
<div id="chat-panel" data-state="closed" hx-preserve="true"
class="w-[320px] lg:w-[420px] flex-shrink-0 flex flex-col border-l border-base-300 bg-base-100">
{{block "chat" .}}{{end}}
</div>
</div>
</div>
<!-- Modal Container -->
<div id="modal-container"></div>
<!-- Toast Notifications -->
<div id="toast-container" class="toast toast-end toast-bottom z-[100]"></div>
<!-- Mobile FAB for chat -->
<button id="chat-fab" onclick="toggleChat()" class="lg:hidden fixed bottom-6 right-6 btn btn-primary btn-circle shadow-xl z-50">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</button>
<script>
// Apply saved chat state on page load
function applyChatState() {
var panel = document.getElementById('chat-panel');
if (!panel) return;
var saved = localStorage.getItem('readysite-chat-open');
panel.dataset.state = saved === '1' ? 'open' : 'closed';
updateMobileUI(saved === '1');
}
// Toggle chat panel open/closed
function toggleChat() {
var panel = document.getElementById('chat-panel');
if (!panel) return;
var isOpen = panel.dataset.state === 'open';
panel.dataset.state = isOpen ? 'closed' : 'open';
localStorage.setItem('readysite-chat-open', isOpen ? '' : '1');
updateMobileUI(!isOpen);
if (!isOpen) {
setTimeout(function() {
var input = panel.querySelector('textarea[name=content]');
if (input) input.focus();
}, 300);
}
}
// Update mobile-specific UI (overlay and FAB)
function updateMobileUI(isOpen) {
var overlay = document.getElementById('chat-overlay');
var fab = document.getElementById('chat-fab');
if (window.innerWidth < 1024) {
if (overlay) overlay.classList.toggle('hidden', !isOpen);
if (fab) fab.classList.toggle('hidden', isOpen);
}
}
// Focus chat input (opens panel if closed)
function focusChatInput() {
var panel = document.getElementById('chat-panel');
if (!panel) return;
if (panel.dataset.state !== 'open') {
toggleChat();
}
setTimeout(function() {
var input = panel.querySelector('textarea[name=content]');
if (input) input.focus();
}, 100);
}
// Navigate preview panel (used by AI navigate_user tool)
function navigatePreview(url) {
htmx.ajax('GET', url, {
target: '#preview-content',
swap: 'outerHTML',
select: '#preview-content'
});
history.pushState({}, '', url);
updateActiveNav(url);
}
// Update sidebar active nav after targeted swap
function updateActiveNav(url) {
var path = url || window.location.pathname;
document.querySelectorAll('aside nav a, aside .flex a').forEach(function(a) {
var href = a.getAttribute('href');
if (!href) return;
var isActive = (href === '/admin' && path === '/admin') ||
(href !== '/admin' && path.startsWith(href));
var btn = a;
btn.className = btn.className
.replace(/bg-primary\/20 text-primary/g, '')
.replace(/hover:bg-base-200\/80/g, '');
if (isActive) {
btn.className += ' bg-primary/20 text-primary';
} else {
btn.className += ' hover:bg-base-200/80';
}
});
}
// Handle tab switching for preview
function switchTab(tabName, container) {
if (!container) return;
// Update active tab buttons
container.querySelectorAll('[data-tab]').forEach(function(t) {
t.classList.toggle('tab-active', t.dataset.tab === tabName);
});
// Show/hide content
container.querySelectorAll('[data-tab-content]').forEach(function(content) {
content.classList.toggle('hidden', content.dataset.tabContent !== tabName);
});
// Update URL query param
var url = new URL(window.location);
if (tabName && tabName !== 'preview') {
url.searchParams.set('tab', tabName);
} else {
url.searchParams.delete('tab');
}
history.replaceState({}, '', url);
}
// Restore tab from URL on page load
function restoreTabFromURL() {
var url = new URL(window.location);
var tabName = url.searchParams.get('tab');
if (!tabName) return;
var tabBtn = document.querySelector('[data-tab="' + tabName + '"]');
if (!tabBtn) return;
var container = tabBtn.parentElement;
while (container && !container.querySelector('[data-tab-content]')) {
container = container.parentElement;
}
if (container) {
switchTab(tabName, container);
}
}
document.addEventListener('click', function(e) {
const tab = e.target.closest('[data-tab]');
if (!tab) return;
const tabName = tab.dataset.tab;
// Find the nearest ancestor that contains tab-content elements
let container = tab.parentElement;
while (container && !container.querySelector('[data-tab-content]')) {
container = container.parentElement;
}
switchTab(tabName, container);
});
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Cmd/Ctrl + K to focus chat
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
focusChatInput();
}
// Escape to close chat on mobile
if (e.key === 'Escape') {
var panel = document.getElementById('chat-panel');
if (panel && panel.dataset.state === 'open' && window.innerWidth < 1024) {
toggleChat();
}
}
});
// Auto-scroll chat to bottom
function scrollChatToBottom() {
const messages = document.querySelector('.chat-messages');
if (messages) {
messages.scrollTop = messages.scrollHeight;
}
}
// Render markdown in AI response (sanitized)
function renderMarkdown(el) {
if (!el || !window.marked) return;
var text = el.textContent || '';
if (text.trim()) {
var html = marked.parse(text);
el.innerHTML = window.DOMPurify ? DOMPurify.sanitize(html) : html;
}
}
// Render all markdown in chat messages on load
function renderAllMarkdown() {
document.querySelectorAll('.ai-markdown').forEach(renderMarkdown);
}
// Apply state on load
document.addEventListener('DOMContentLoaded', function() {
applyChatState();
renderAllMarkdown();
scrollChatToBottom();
restoreTabFromURL();
});
// After HTMX settles: update nav + scroll chat + restore tab
document.addEventListener('htmx:afterSettle', function(e) {
var target = e.detail.target;
if (target && target.id === 'preview-content') {
updateActiveNav();
restoreTabFromURL();
}
if (target === document.body || target.closest('.chat-messages') || target.classList.contains('chat-messages')) {
scrollChatToBottom();
}
});
// Auto-scroll during SSE streaming
document.addEventListener('htmx:sseMessage', function() {
scrollChatToBottom();
});
// Toast notifications via HX-Trigger
document.addEventListener('showToast', function(e) {
var container = document.getElementById('toast-container');
var toast = document.createElement('div');
toast.className = 'alert alert-success shadow-lg';
toast.innerHTML = '<span>' + e.detail.value + '</span>';
container.appendChild(toast);
setTimeout(function() {
toast.style.transition = 'opacity 0.3s';
toast.style.opacity = '0';
setTimeout(function() { toast.remove(); }, 300);
}, 3000);
});
</script>
</body>
</html>
{{define "ai-panel-trigger"}}
<button onclick="toggleChat()" class="btn btn-ghost btn-sm btn-square" title="AI Assistant (Ctrl+K)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</button>
{{end}}