447 lines
24 KiB
HTML
447 lines
24 KiB
HTML
{{template "base" .}}
|
|
|
|
{{define "content"}}
|
|
<div class="space-y-8">
|
|
<!-- Header -->
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
|
<p class="mt-1 text-gray-500 dark:text-gray-400">Your document management overview</p>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<a href="/api/export"
|
|
class="inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all">
|
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
</svg>
|
|
Export CSV
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Live Processing Status -->
|
|
<div id="processing-panel" class="hidden">
|
|
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-2xl p-5">
|
|
<div class="flex items-center gap-3 mb-3">
|
|
<div class="animate-spin rounded-full h-5 w-5 border-2 border-amber-600 border-t-transparent"></div>
|
|
<h3 class="font-semibold text-amber-800 dark:text-amber-200">Processing Documents</h3>
|
|
</div>
|
|
<div id="processing-jobs" class="space-y-3"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Cards -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700 card-hover">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Documents</p>
|
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{.Stats.TotalDocs}}</p>
|
|
</div>
|
|
<div class="p-3 bg-brand-100 dark:bg-brand-900/30 rounded-xl">
|
|
<svg class="w-6 h-6 text-brand-600 dark:text-brand-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700 card-hover">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">This Week</p>
|
|
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{{.Stats.RecentDocs}}</p>
|
|
</div>
|
|
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-xl">
|
|
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700 card-hover">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Categories</p>
|
|
<p class="text-3xl font-bold text-purple-600 dark:text-purple-400 mt-1">{{len .Categories}}</p>
|
|
</div>
|
|
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-xl">
|
|
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700 card-hover">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Storage</p>
|
|
<p class="text-3xl font-bold text-orange-600 dark:text-orange-400 mt-1">—</p>
|
|
</div>
|
|
<div class="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-xl">
|
|
<svg class="w-6 h-6 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Two columns: Categories + Recent -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
|
<!-- Categories -->
|
|
<div class="lg:col-span-1">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Categories</h2>
|
|
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
|
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
{{range .Categories}}
|
|
{{$count := index $.Stats.ByCategory .}}
|
|
<a href="/browse/{{.}}" class="flex items-center justify-between px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
|
<div class="flex items-center space-x-3">
|
|
<span class="text-xl">{{categoryIcon .}}</span>
|
|
<span class="font-medium text-gray-700 dark:text-gray-200">{{title .}}</span>
|
|
</div>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2.5 py-0.5 rounded-full">{{$count}}</span>
|
|
</a>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Documents -->
|
|
<div class="lg:col-span-3">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Documents</h2>
|
|
<a href="/browse" class="text-sm text-brand-600 dark:text-brand-400 hover:underline">View all →</a>
|
|
</div>
|
|
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
|
|
{{if .Stats.RecentUploads}}
|
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
{{range .Stats.RecentUploads}}
|
|
<div class="flex items-center px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
|
|
<a href="/document/{{.ID}}" class="flex-shrink-0 p-2 bg-gray-100 dark:bg-gray-700 rounded-lg mr-4">
|
|
<svg class="w-6 h-6 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
</svg>
|
|
</a>
|
|
<a href="/document/{{.ID}}" class="flex-1 min-w-0">
|
|
<p class="font-medium text-gray-900 dark:text-white truncate group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">{{.Title}}</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 truncate">{{truncate .Summary 100}}</p>
|
|
</a>
|
|
<div class="flex-shrink-0 ml-4 text-right">
|
|
{{if eq .Status "processing"}}
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300" data-processing="{{.ID}}">
|
|
<svg class="animate-spin -ml-0.5 mr-1.5 h-3 w-3" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
Processing
|
|
</span>
|
|
{{else if eq .Status "error"}}
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
|
|
Error
|
|
</span>
|
|
{{else}}
|
|
<select onchange="updateCategory('{{.ID}}', '{{.Title}}', this.value)"
|
|
data-category="{{.Category}}"
|
|
class="text-xs font-medium bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 rounded-full px-2.5 py-0.5 border-0 cursor-pointer hover:bg-brand-200 dark:hover:bg-brand-900/50">
|
|
{{$cat := .Category}}
|
|
{{range $.Categories}}
|
|
<option value="{{.}}" {{if eq . $cat}}selected{{end}}>{{title .}}</option>
|
|
{{end}}
|
|
</select>
|
|
{{end}}
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">{{formatDateTime .ProcessedAt}}</p>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{else}}
|
|
<div class="p-12 text-center">
|
|
<svg class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
|
</svg>
|
|
<p class="mt-4 text-gray-500 dark:text-gray-400">No documents yet</p>
|
|
<p class="mt-2 text-sm text-brand-600 dark:text-brand-400">
|
|
Drag & drop files anywhere to upload
|
|
</p>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Quick Actions</h2>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div class="flex flex-col items-center p-6 bg-white dark:bg-gray-800 rounded-2xl shadow-sm border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-brand-400 dark:hover:border-brand-500 transition-all">
|
|
<div class="p-3 bg-brand-100 dark:bg-brand-900/30 rounded-xl mb-3">
|
|
<svg class="w-6 h-6 text-brand-600 dark:text-brand-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
|
</svg>
|
|
</div>
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Drop files anywhere</span>
|
|
</div>
|
|
|
|
<a href="/search" class="flex flex-col items-center p-6 bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-600 transition-all card-hover">
|
|
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-xl mb-3">
|
|
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
</svg>
|
|
</div>
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Search Documents</span>
|
|
</a>
|
|
|
|
<a href="/browse" class="flex flex-col items-center p-6 bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-600 transition-all card-hover">
|
|
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-xl mb-3">
|
|
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
|
</svg>
|
|
</div>
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Browse Categories</span>
|
|
</a>
|
|
|
|
<button hx-post="/api/reindex" hx-swap="none" hx-confirm="Rebuild the document index?"
|
|
class="flex flex-col items-center p-6 bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-600 transition-all card-hover">
|
|
<div class="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-xl mb-3">
|
|
<svg class="w-6 h-6 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
</svg>
|
|
</div>
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Reindex</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload Modal -->
|
|
<!-- Full-page drop overlay -->
|
|
<div id="drop-overlay" class="hidden fixed inset-0 z-50 bg-brand-600/90 flex items-center justify-center pointer-events-none">
|
|
<div class="text-center text-white">
|
|
<svg class="w-20 h-20 mx-auto mb-4 animate-bounce" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
|
</svg>
|
|
<p class="text-2xl font-bold">Drop to upload</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload progress indicator -->
|
|
<div id="upload-progress" class="hidden fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-white dark:bg-gray-800 rounded-xl shadow-2xl px-6 py-4 flex items-center gap-4">
|
|
<div class="animate-spin rounded-full h-6 w-6 border-2 border-brand-600 border-t-transparent"></div>
|
|
<span class="text-gray-700 dark:text-gray-200 font-medium">Processing with AI...</span>
|
|
</div>
|
|
|
|
<script>
|
|
// Full-page drag & drop
|
|
let dragCounter = 0;
|
|
const overlay = document.getElementById('drop-overlay');
|
|
const progress = document.getElementById('upload-progress');
|
|
|
|
document.addEventListener('dragenter', (e) => {
|
|
e.preventDefault();
|
|
dragCounter++;
|
|
overlay.classList.remove('hidden');
|
|
});
|
|
|
|
document.addEventListener('dragleave', (e) => {
|
|
e.preventDefault();
|
|
dragCounter--;
|
|
if (dragCounter === 0) overlay.classList.add('hidden');
|
|
});
|
|
|
|
document.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
dragCounter = 0;
|
|
overlay.classList.add('hidden');
|
|
|
|
const files = Array.from(e.dataTransfer.files);
|
|
if (!files.length) return;
|
|
|
|
progress.classList.remove('hidden');
|
|
const pendingIds = [];
|
|
|
|
// Upload all files
|
|
for (const file of files) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
const data = await res.json();
|
|
|
|
if (data.status === 'success') {
|
|
showToast('✓ ' + file.name, 'success');
|
|
pendingIds.push(data.id);
|
|
} else if (data.status === 'duplicate') {
|
|
showToast('📄 "' + file.name + '" exists', 'warning');
|
|
} else {
|
|
showToast('Failed: ' + file.name, 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast('Upload failed: ' + file.name, 'error');
|
|
}
|
|
}
|
|
|
|
if (pendingIds.length > 0) {
|
|
// Store all pending IDs and refresh
|
|
sessionStorage.setItem('pendingDocs', JSON.stringify(pendingIds));
|
|
window.location.reload();
|
|
} else {
|
|
progress.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Check for pending docs on page load (from web upload)
|
|
const pendingDocsJson = sessionStorage.getItem('pendingDocs');
|
|
if (pendingDocsJson) {
|
|
const pendingIds = JSON.parse(pendingDocsJson);
|
|
if (pendingIds.length > 0) {
|
|
pollUntilAllReady(pendingIds);
|
|
}
|
|
}
|
|
|
|
// Live processing status polling
|
|
const panel = document.getElementById('processing-panel');
|
|
const jobsDiv = document.getElementById('processing-jobs');
|
|
let wasProcessing = false;
|
|
|
|
const stepLabels = {
|
|
'starting': '🔄 Starting...',
|
|
'converting': '📄 Converting PDF to image...',
|
|
'ocr': '🔍 OCR',
|
|
'classifying': '🏷️ Classifying...',
|
|
'embedding': '📊 Generating search index...',
|
|
'error': '❌ Error'
|
|
};
|
|
|
|
(function pollProcessing() {
|
|
fetch('/api/processing')
|
|
.then(r => r.json())
|
|
.then(jobs => {
|
|
if (jobs && jobs.length > 0) {
|
|
wasProcessing = true;
|
|
panel.classList.remove('hidden');
|
|
jobsDiv.innerHTML = jobs.map(j => {
|
|
const elapsed = (j.elapsed_ms / 1000).toFixed(1);
|
|
const label = stepLabels[j.step] || j.step;
|
|
const detail = j.detail ? ` — ${j.detail}` : '';
|
|
return `<div class="flex items-center justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-3 shadow-sm">
|
|
<div>
|
|
<span class="font-medium text-gray-900 dark:text-white">${j.filename}</span>
|
|
<span class="ml-2 text-sm text-amber-700 dark:text-amber-300">${label}${detail}</span>
|
|
</div>
|
|
<span class="text-sm text-gray-500 dark:text-gray-400 font-mono">${elapsed}s</span>
|
|
</div>`;
|
|
}).join('');
|
|
setTimeout(pollProcessing, 1000);
|
|
} else {
|
|
panel.classList.add('hidden');
|
|
if (wasProcessing) {
|
|
wasProcessing = false;
|
|
showToast('✓ Document processing complete!', 'success');
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} else {
|
|
setTimeout(pollProcessing, 3000);
|
|
}
|
|
}
|
|
})
|
|
.catch(() => setTimeout(pollProcessing, 5000));
|
|
})();
|
|
|
|
// Poll until all documents are ready
|
|
function pollUntilAllReady(docIds) {
|
|
progress.classList.remove('hidden');
|
|
let attempts = 0;
|
|
const poll = setInterval(async () => {
|
|
attempts++;
|
|
try {
|
|
const res = await fetch('/api/documents');
|
|
const docs = await res.json();
|
|
if (!docs) return;
|
|
|
|
let allReady = true;
|
|
let anyError = false;
|
|
|
|
for (const id of docIds) {
|
|
const doc = docs.find(d => d.ID === id);
|
|
if (!doc || doc.Status === 'processing') {
|
|
allReady = false;
|
|
} else if (doc.Status === 'error') {
|
|
anyError = true;
|
|
}
|
|
}
|
|
|
|
if (allReady) {
|
|
clearInterval(poll);
|
|
sessionStorage.removeItem('pendingDocs');
|
|
progress.classList.add('hidden');
|
|
if (anyError) {
|
|
showToast('Some documents failed', 'warning');
|
|
} else {
|
|
showToast('✓ All ' + docIds.length + ' documents ready!', 'success');
|
|
}
|
|
window.location.reload();
|
|
} else if (attempts > 90) { // 3 min timeout for multiple
|
|
clearInterval(poll);
|
|
sessionStorage.removeItem('pendingDocs');
|
|
progress.classList.add('hidden');
|
|
showToast('Taking too long — refresh manually', 'warning');
|
|
}
|
|
} catch {}
|
|
}, 2000);
|
|
}
|
|
|
|
async function updateCategory(id, title, newCategory) {
|
|
try {
|
|
const res = await fetch('/api/document/' + id, {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({title: title, category: newCategory, notes: ''})
|
|
});
|
|
if (res.ok) {
|
|
showToast('Moved to ' + newCategory, 'success');
|
|
} else {
|
|
showToast('Failed to update', 'error');
|
|
}
|
|
} catch {
|
|
showToast('Failed to update', 'error');
|
|
}
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const colors = {
|
|
success: 'bg-green-500',
|
|
error: 'bg-red-500',
|
|
info: 'bg-blue-500',
|
|
warning: 'bg-amber-500'
|
|
};
|
|
|
|
let container = document.getElementById('toast-container');
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.id = 'toast-container';
|
|
container.className = 'fixed top-4 right-4 z-50 flex flex-col gap-2';
|
|
document.body.appendChild(container);
|
|
}
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `${colors[type] || colors.info} text-white px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 translate-x-full`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
|
|
requestAnimationFrame(() => toast.classList.remove('translate-x-full'));
|
|
|
|
setTimeout(() => {
|
|
toast.classList.add('translate-x-full', 'opacity-0');
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 4000);
|
|
}
|
|
</script>
|
|
{{end}}
|