Merge branch 'main' into copilot/add-document-sharing-feature
This commit is contained in:
@@ -194,6 +194,19 @@
|
||||
<i class="fas fa-circle-question mr-1 text-gray-400" aria-hidden="true"></i>Help
|
||||
</a>
|
||||
|
||||
<!-- Bell notification icon -->
|
||||
<a href="/notifications"
|
||||
id="notificationBell"
|
||||
class="relative p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
aria-label="Notifications"
|
||||
title="Notifications"
|
||||
{% if request and request.url.path == '/notifications' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-bell" aria-hidden="true"></i>
|
||||
<span id="notificationBadge"
|
||||
class="hidden absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold"
|
||||
aria-live="polite"></span>
|
||||
</a>
|
||||
|
||||
<!-- Dark mode toggle -->
|
||||
<button
|
||||
id="darkModeToggle"
|
||||
@@ -283,6 +296,11 @@
|
||||
{% if request and request.url.path == '/integrations' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-plug mr-2 text-gray-400" aria-hidden="true"></i>Integrations
|
||||
</a>
|
||||
<a href="/notifications"
|
||||
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
||||
{% if request and request.url.path == '/notifications' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-bell mr-2 text-gray-400" aria-hidden="true"></i>Notifications
|
||||
</a>
|
||||
|
||||
<!-- Admin section in mobile menu – shown only for admin users via JS -->
|
||||
<div id="mobileAdminSection" class="hidden">
|
||||
@@ -427,6 +445,30 @@
|
||||
<!-- Common JS (shared) -->
|
||||
<script src="/static/js/common.js"></script>
|
||||
|
||||
<!-- Notification badge updater -->
|
||||
<script>
|
||||
(function() {
|
||||
async function updateNotificationBadge() {
|
||||
try {
|
||||
const resp = await fetch('/api/user-notifications/inbox/unread-count');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const badge = document.getElementById('notificationBadge');
|
||||
if (!badge) return;
|
||||
if (data.count > 0) {
|
||||
badge.textContent = data.count > 99 ? '99+' : data.count;
|
||||
badge.classList.remove('hidden');
|
||||
badge.setAttribute('aria-label', data.count + ' unread notifications');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', updateNotificationBadge);
|
||||
setInterval(updateNotificationBadge, 60000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Let child pages define extra scripts if needed -->
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,979 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Notifications – DocuElevate{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
class="container mx-auto px-4 py-8"
|
||||
x-data="notificationsDashboard()"
|
||||
x-init="init()"
|
||||
>
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<i class="fas fa-bell text-blue-500" aria-hidden="true"></i>
|
||||
Notifications
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||
Manage your notification inbox, targets, and event preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab nav ────────────────────────────────────────────────────────── -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 mb-6" role="tablist" aria-label="Notification sections">
|
||||
<nav class="-mb-px flex space-x-6">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'inbox'"
|
||||
:tabindex="activeTab === 'inbox' ? 0 : -1"
|
||||
@click="activeTab = 'inbox'"
|
||||
:class="activeTab === 'inbox'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
|
||||
class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-controls="panel-inbox"
|
||||
id="tab-inbox"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-inbox mr-1" aria-hidden="true"></i>
|
||||
Inbox
|
||||
<template x-if="unreadCount > 0">
|
||||
<span
|
||||
class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700"
|
||||
aria-label="unread notifications"
|
||||
x-text="unreadCount > 99 ? '99+' : unreadCount"
|
||||
></span>
|
||||
</template>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'settings'"
|
||||
:tabindex="activeTab === 'settings' ? 0 : -1"
|
||||
@click="activeTab = 'settings'"
|
||||
:class="activeTab === 'settings'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
|
||||
class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-controls="panel-settings"
|
||||
id="tab-settings"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-sliders-h mr-1" aria-hidden="true"></i>
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
INBOX PANEL
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="panel-inbox"
|
||||
aria-labelledby="tab-inbox"
|
||||
x-show="activeTab === 'inbox'"
|
||||
>
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="inboxFilter" class="text-sm font-medium text-gray-700 dark:text-gray-300">Show:</label>
|
||||
<select
|
||||
id="inboxFilter"
|
||||
x-model="inboxFilter"
|
||||
@change="loadInbox()"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-label="Filter notifications"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="unread">Unread only</option>
|
||||
<option value="read">Read only</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="markAllRead()"
|
||||
:disabled="unreadCount === 0"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
style="min-height:44px;"
|
||||
aria-label="Mark all notifications as read"
|
||||
>
|
||||
<i class="fas fa-check-double mr-1" aria-hidden="true"></i> Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template x-if="inboxLoading">
|
||||
<div class="text-center py-12 text-gray-400" role="status" aria-live="polite">
|
||||
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
|
||||
<p class="mt-2 text-sm">Loading notifications…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="!inboxLoading && filteredNotifications().length === 0">
|
||||
<div class="text-center py-12 text-gray-400" role="status">
|
||||
<i class="fas fa-bell-slash text-4xl mb-3" aria-hidden="true"></i>
|
||||
<p class="text-sm">No notifications found.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Notifications list -->
|
||||
<template x-if="!inboxLoading && filteredNotifications().length > 0">
|
||||
<ul class="space-y-2" role="list" aria-label="Notification items">
|
||||
<template x-for="notif in filteredNotifications()" :key="notif.id">
|
||||
<li
|
||||
class="flex items-start gap-3 p-4 rounded-lg border transition-colors"
|
||||
:class="notif.is_read
|
||||
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'"
|
||||
>
|
||||
<!-- Event icon -->
|
||||
<div
|
||||
class="flex-shrink-0 h-9 w-9 rounded-full flex items-center justify-center"
|
||||
:class="notif.event_type === 'document.failed'
|
||||
? 'bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400'
|
||||
: 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i :class="notif.event_type === 'document.failed' ? 'fas fa-times-circle' : 'fas fa-check-circle'"></i>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
:class="notif.is_read ? '' : 'font-semibold'"
|
||||
x-text="notif.title"
|
||||
></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5" x-text="notif.message"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1" x-text="formatDate(notif.created_at)"></p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<template x-if="notif.file_id">
|
||||
<a
|
||||
:href="`/files/${notif.file_id}`"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
:aria-label="`View file for: ${notif.title}`"
|
||||
>
|
||||
<i class="fas fa-external-link-alt" aria-hidden="true"></i>
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="!notif.is_read">
|
||||
<button
|
||||
type="button"
|
||||
@click="markRead(notif)"
|
||||
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
|
||||
:aria-label="`Mark as read: ${notif.title}`"
|
||||
style="min-height:44px;min-width:44px;"
|
||||
title="Mark as read"
|
||||
>
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
SETTINGS PANEL
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="panel-settings"
|
||||
aria-labelledby="tab-settings"
|
||||
x-show="activeTab === 'settings'"
|
||||
>
|
||||
|
||||
<!-- ── Notification Targets ──────────────────────────────────────────── -->
|
||||
<section aria-labelledby="targets-heading" class="mb-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="targets-heading" class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-satellite-dish mr-2 text-indigo-500" aria-hidden="true"></i>Notification Targets
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="openAddTarget()"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-indigo-600 hover:bg-indigo-700 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
style="min-height:44px;"
|
||||
aria-label="Add notification target"
|
||||
>
|
||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i> Add Target
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-if="targetsLoading">
|
||||
<div class="text-center py-8 text-gray-400" role="status" aria-live="polite">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span class="sr-only">Loading targets…</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!targetsLoading && targets.length === 0">
|
||||
<div class="text-center py-8 text-gray-400 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<i class="fas fa-satellite-dish text-3xl mb-2" aria-hidden="true"></i>
|
||||
<p class="text-sm">No notification targets configured.</p>
|
||||
<p class="text-xs mt-1">Add an email or webhook target to receive notifications outside the app.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!targetsLoading && targets.length > 0">
|
||||
<div class="space-y-3" role="list" aria-label="Notification targets">
|
||||
<template x-for="target in targets" :key="target.id">
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
role="listitem"
|
||||
>
|
||||
<!-- Type icon -->
|
||||
<div
|
||||
class="flex-shrink-0 h-10 w-10 rounded-full flex items-center justify-center"
|
||||
:class="target.channel_type === 'email'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-400'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i :class="target.channel_type === 'email' ? 'fas fa-envelope' : 'fas fa-globe'"></i>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="target.name"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 capitalize" x-text="target.channel_type"></p>
|
||||
</div>
|
||||
|
||||
<!-- Active badge -->
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="target.is_active
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="target.is_active ? 'Active' : 'Inactive'"
|
||||
></span>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="testTarget(target)"
|
||||
class="p-2 text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded"
|
||||
:aria-label="`Test target: ${target.name}`"
|
||||
style="min-height:44px;min-width:44px;"
|
||||
title="Send test notification"
|
||||
>
|
||||
<i class="fas fa-paper-plane" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openEditTarget(target)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
:aria-label="`Edit target: ${target.name}`"
|
||||
style="min-height:44px;min-width:44px;"
|
||||
title="Edit target"
|
||||
>
|
||||
<i class="fas fa-pencil-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="deleteTarget(target)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-red-500 rounded"
|
||||
:aria-label="`Delete target: ${target.name}`"
|
||||
style="min-height:44px;min-width:44px;"
|
||||
title="Delete target"
|
||||
>
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── Event Preferences ─────────────────────────────────────────────── -->
|
||||
<section aria-labelledby="prefs-heading">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="prefs-heading" class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-sliders-h mr-2 text-green-500" aria-hidden="true"></i>Event Preferences
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="savePreferences()"
|
||||
:disabled="prefsSaving"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-green-600 hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
style="min-height:44px;"
|
||||
aria-label="Save notification preferences"
|
||||
>
|
||||
<template x-if="prefsSaving">
|
||||
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
|
||||
</template>
|
||||
<template x-if="!prefsSaving">
|
||||
<i class="fas fa-save mr-1" aria-hidden="true"></i>
|
||||
</template>
|
||||
Save Preferences
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700" aria-label="Notification event preferences">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Event
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
In-App
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Webhook
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="eventType in eventTypes" :key="eventType">
|
||||
<tr>
|
||||
<!-- Event label -->
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white" x-text="eventLabels[eventType] || eventType"></td>
|
||||
|
||||
<!-- In-App (always enabled, non-editable) -->
|
||||
<td class="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
disabled
|
||||
class="h-4 w-4 text-blue-600 rounded opacity-60 cursor-not-allowed"
|
||||
:aria-label="`In-app notification for ${eventLabels[eventType] || eventType} (always enabled)`"
|
||||
title="In-app notifications are always enabled"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Email channel -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<template x-for="target in emailTargets()" :key="target.id">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPrefEnabled(eventType, 'email', target.id)"
|
||||
@change="togglePref(eventType, 'email', target.id, $event.target.checked)"
|
||||
class="h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
:aria-label="`Email via ${target.name} for ${eventLabels[eventType] || eventType}`"
|
||||
/>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300" x-text="target.name"></span>
|
||||
</label>
|
||||
</template>
|
||||
<template x-if="emailTargets().length === 0">
|
||||
<span class="text-xs text-gray-400 italic">No email targets</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Webhook channel -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<template x-for="target in webhookTargets()" :key="target.id">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPrefEnabled(eventType, 'webhook', target.id)"
|
||||
@change="togglePref(eventType, 'webhook', target.id, $event.target.checked)"
|
||||
class="h-4 w-4 text-purple-600 rounded focus:ring-purple-500"
|
||||
:aria-label="`Webhook via ${target.name} for ${eventLabels[eventType] || eventType}`"
|
||||
/>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300" x-text="target.name"></span>
|
||||
</label>
|
||||
</template>
|
||||
<template x-if="webhookTargets().length === 0">
|
||||
<span class="text-xs text-gray-400 italic">No webhook targets</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
TARGET MODAL (Add / Edit)
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
x-show="targetModalOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="targetModalMode === 'edit' ? 'modal-edit-title' : 'modal-add-title'"
|
||||
@keydown.escape.window="targetModalOpen = false"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3
|
||||
:id="targetModalMode === 'edit' ? 'modal-edit-title' : 'modal-add-title'"
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white"
|
||||
x-text="targetModalMode === 'edit' ? 'Edit Notification Target' : 'Add Notification Target'"
|
||||
></h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="targetModalOpen = false"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
aria-label="Close modal"
|
||||
style="min-height:44px;min-width:44px;"
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal body -->
|
||||
<form @submit.prevent="submitTargetForm()" class="px-6 py-5 space-y-4">
|
||||
<!-- Channel type (only for new targets) -->
|
||||
<template x-if="targetModalMode === 'add'">
|
||||
<div>
|
||||
<label for="modal-channel-type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Channel Type <span class="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="modal-channel-type"
|
||||
x-model="targetForm.channel_type"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
required
|
||||
aria-required="true"
|
||||
>
|
||||
<option value="email">Email (SMTP)</option>
|
||||
<option value="webhook">Webhook (HTTP POST)</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="modal-target-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name <span class="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="modal-target-name"
|
||||
x-model="targetForm.name"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="e.g. My Gmail, Production Webhook"
|
||||
required
|
||||
aria-required="true"
|
||||
maxlength="255"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email-specific fields -->
|
||||
<template x-if="targetForm.channel_type === 'email'">
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="modal-smtp-host" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Host</label>
|
||||
<input
|
||||
type="text"
|
||||
id="modal-smtp-host"
|
||||
x-model="targetForm.config.smtp_host"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-smtp-port" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
id="modal-smtp-port"
|
||||
x-model.number="targetForm.config.smtp_port"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="587"
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-smtp-username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="modal-smtp-username"
|
||||
x-model="targetForm.config.smtp_username"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="user@example.com"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-smtp-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="modal-smtp-password"
|
||||
x-model="targetForm.config.smtp_password"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Leave blank to keep existing"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-recipient-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recipient Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="modal-recipient-email"
|
||||
x-model="targetForm.config.recipient_email"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-sender-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Sender Email (optional)</label>
|
||||
<input
|
||||
type="email"
|
||||
id="modal-sender-email"
|
||||
x-model="targetForm.config.sender_email"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="noreply@docuelevate.local"
|
||||
/>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="targetForm.config.smtp_use_tls"
|
||||
class="h-4 w-4 text-indigo-600 rounded focus:ring-indigo-500"
|
||||
aria-label="Use STARTTLS"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Use STARTTLS</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Webhook-specific fields -->
|
||||
<template x-if="targetForm.channel_type === 'webhook'">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="modal-webhook-url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="modal-webhook-url"
|
||||
x-model="targetForm.config.url"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="https://hooks.example.com/..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-webhook-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Secret (optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="modal-webhook-secret"
|
||||
x-model="targetForm.config.secret"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Sent as X-DocuElevate-Secret header"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Active toggle (edit mode) -->
|
||||
<template x-if="targetModalMode === 'edit'">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="targetForm.is_active"
|
||||
class="h-4 w-4 text-indigo-600 rounded focus:ring-indigo-500"
|
||||
aria-label="Target is active"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<template x-if="targetFormError">
|
||||
<p class="text-sm text-red-600 dark:text-red-400" role="alert" x-text="targetFormError"></p>
|
||||
</template>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="targetModalOpen = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="targetFormSaving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<template x-if="targetFormSaving">
|
||||
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
|
||||
</template>
|
||||
<span x-text="targetModalMode === 'edit' ? 'Save Changes' : 'Add Target'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Global toast ───────────────────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="toast.visible"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-lg shadow-lg px-4 py-3 text-white text-sm font-medium"
|
||||
:class="toast.type === 'error' ? 'bg-red-600' : 'bg-green-600'"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
x-text="toast.message"
|
||||
></div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function notificationsDashboard() {
|
||||
return {
|
||||
// Tab state
|
||||
activeTab: 'inbox',
|
||||
|
||||
// Inbox
|
||||
notifications: [],
|
||||
inboxLoading: false,
|
||||
inboxFilter: 'all',
|
||||
unreadCount: 0,
|
||||
|
||||
// Targets
|
||||
targets: [],
|
||||
targetsLoading: false,
|
||||
|
||||
// Preferences
|
||||
eventTypes: [],
|
||||
eventLabels: {},
|
||||
prefs: {}, // event_type -> channel_type -> {is_enabled, target_id}
|
||||
prefsSaving: false,
|
||||
|
||||
// Target modal
|
||||
targetModalOpen: false,
|
||||
targetModalMode: 'add',
|
||||
editingTargetId: null,
|
||||
targetForm: {
|
||||
channel_type: 'email',
|
||||
name: '',
|
||||
is_active: true,
|
||||
config: { smtp_use_tls: true, smtp_port: 587 },
|
||||
},
|
||||
targetFormSaving: false,
|
||||
targetFormError: '',
|
||||
|
||||
// Toast
|
||||
toast: { visible: false, message: '', type: 'success' },
|
||||
|
||||
async init() {
|
||||
await Promise.all([this.loadInbox(), this.loadTargets(), this.loadPreferences()]);
|
||||
},
|
||||
|
||||
// ── Inbox ────────────────────────────────────────────────────────────
|
||||
|
||||
async loadInbox() {
|
||||
this.inboxLoading = true;
|
||||
try {
|
||||
const resp = await fetch('/api/user-notifications/inbox?limit=100');
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
this.notifications = await resp.json();
|
||||
this.unreadCount = this.notifications.filter(n => !n.is_read).length;
|
||||
} catch (e) {
|
||||
this.showToast('Failed to load notifications', 'error');
|
||||
} finally {
|
||||
this.inboxLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filteredNotifications() {
|
||||
if (this.inboxFilter === 'unread') return this.notifications.filter(n => !n.is_read);
|
||||
if (this.inboxFilter === 'read') return this.notifications.filter(n => n.is_read);
|
||||
return this.notifications;
|
||||
},
|
||||
|
||||
async markRead(notif) {
|
||||
try {
|
||||
const resp = await fetch(`/api/user-notifications/inbox/${notif.id}/read`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error('Failed');
|
||||
notif.is_read = true;
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
this.updateBadge();
|
||||
} catch (e) {
|
||||
this.showToast('Failed to mark as read', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
const resp = await fetch('/api/user-notifications/inbox/read-all', { method: 'POST' });
|
||||
if (!resp.ok) throw new Error('Failed');
|
||||
this.notifications.forEach(n => { n.is_read = true; });
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.showToast('All notifications marked as read');
|
||||
} catch (e) {
|
||||
this.showToast('Failed to mark all as read', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
updateBadge() {
|
||||
const badge = document.getElementById('notificationBadge');
|
||||
if (!badge) return;
|
||||
if (this.unreadCount > 0) {
|
||||
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
|
||||
badge.classList.remove('hidden');
|
||||
badge.setAttribute('aria-label', this.unreadCount + ' unread notifications');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
},
|
||||
|
||||
// ── Targets ──────────────────────────────────────────────────────────
|
||||
|
||||
async loadTargets() {
|
||||
this.targetsLoading = true;
|
||||
try {
|
||||
const resp = await fetch('/api/user-notifications/targets');
|
||||
if (!resp.ok) throw new Error('Failed to load targets');
|
||||
this.targets = await resp.json();
|
||||
} catch (e) {
|
||||
this.showToast('Failed to load notification targets', 'error');
|
||||
} finally {
|
||||
this.targetsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
emailTargets() {
|
||||
return this.targets.filter(t => t.channel_type === 'email' && t.is_active);
|
||||
},
|
||||
|
||||
webhookTargets() {
|
||||
return this.targets.filter(t => t.channel_type === 'webhook' && t.is_active);
|
||||
},
|
||||
|
||||
openAddTarget() {
|
||||
this.targetModalMode = 'add';
|
||||
this.editingTargetId = null;
|
||||
this.targetForm = {
|
||||
channel_type: 'email',
|
||||
name: '',
|
||||
is_active: true,
|
||||
config: { smtp_use_tls: true, smtp_port: 587 },
|
||||
};
|
||||
this.targetFormError = '';
|
||||
this.targetModalOpen = true;
|
||||
},
|
||||
|
||||
openEditTarget(target) {
|
||||
this.targetModalMode = 'edit';
|
||||
this.editingTargetId = target.id;
|
||||
this.targetForm = {
|
||||
channel_type: target.channel_type,
|
||||
name: target.name,
|
||||
is_active: target.is_active,
|
||||
config: Object.assign({}, target.config),
|
||||
};
|
||||
this.targetFormError = '';
|
||||
this.targetModalOpen = true;
|
||||
},
|
||||
|
||||
async submitTargetForm() {
|
||||
this.targetFormSaving = true;
|
||||
this.targetFormError = '';
|
||||
try {
|
||||
let url, method, body;
|
||||
if (this.targetModalMode === 'add') {
|
||||
url = '/api/user-notifications/targets';
|
||||
method = 'POST';
|
||||
body = {
|
||||
channel_type: this.targetForm.channel_type,
|
||||
name: this.targetForm.name,
|
||||
is_active: this.targetForm.is_active,
|
||||
config: this.targetForm.config,
|
||||
};
|
||||
} else {
|
||||
url = `/api/user-notifications/targets/${this.editingTargetId}`;
|
||||
method = 'PUT';
|
||||
body = {
|
||||
name: this.targetForm.name,
|
||||
is_active: this.targetForm.is_active,
|
||||
config: this.targetForm.config,
|
||||
};
|
||||
}
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to save target');
|
||||
}
|
||||
await this.loadTargets();
|
||||
this.targetModalOpen = false;
|
||||
this.showToast(this.targetModalMode === 'edit' ? 'Target updated' : 'Target created');
|
||||
} catch (e) {
|
||||
this.targetFormError = e.message;
|
||||
} finally {
|
||||
this.targetFormSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTarget(target) {
|
||||
if (!confirm(`Delete notification target "${target.name}"? Associated preferences will also be removed.`)) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/user-notifications/targets/${target.id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Failed to delete');
|
||||
await this.loadTargets();
|
||||
await this.loadPreferences();
|
||||
this.showToast('Target deleted');
|
||||
} catch (e) {
|
||||
this.showToast('Failed to delete target', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async testTarget(target) {
|
||||
try {
|
||||
const resp = await fetch(`/api/user-notifications/targets/${target.id}/test`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Test failed');
|
||||
}
|
||||
this.showToast('Test notification sent!');
|
||||
} catch (e) {
|
||||
this.showToast(`Test failed: ${e.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Preferences ──────────────────────────────────────────────────────
|
||||
|
||||
async loadPreferences() {
|
||||
try {
|
||||
const resp = await fetch('/api/user-notifications/preferences');
|
||||
if (!resp.ok) throw new Error('Failed to load preferences');
|
||||
const data = await resp.json();
|
||||
this.eventTypes = data.event_types || [];
|
||||
this.eventLabels = data.event_labels || {};
|
||||
this.prefs = data.preferences || {};
|
||||
// Build flat list for isPrefEnabled lookups
|
||||
this._prefsList = [];
|
||||
for (const [et, channelMap] of Object.entries(this.prefs)) {
|
||||
for (const [ct, item] of Object.entries(channelMap)) {
|
||||
this._prefsList.push({
|
||||
event_type: et,
|
||||
channel_type: ct,
|
||||
target_id: item.target_id,
|
||||
is_enabled: item.is_enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast('Failed to load preferences', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
isPrefEnabled(eventType, channelType, targetId) {
|
||||
const entry = this._prefKey(eventType, channelType, targetId);
|
||||
return entry ? entry.is_enabled : false;
|
||||
},
|
||||
|
||||
_prefKey(eventType, channelType, targetId) {
|
||||
if (!this._prefsList) return null;
|
||||
return this._prefsList.find(
|
||||
p => p.event_type === eventType && p.channel_type === channelType && p.target_id === targetId
|
||||
) || null;
|
||||
},
|
||||
|
||||
// Store pending pref changes as a dict: `${eventType}|${channelType}|${targetId}` -> is_enabled
|
||||
_pendingPrefs: {},
|
||||
_prefsList: [],
|
||||
|
||||
togglePref(eventType, channelType, targetId, enabled) {
|
||||
const k = `${eventType}|${channelType}|${targetId}`;
|
||||
this._pendingPrefs[k] = { event_type: eventType, channel_type: channelType, target_id: targetId, is_enabled: enabled };
|
||||
// Update in _prefsList for immediate UI feedback
|
||||
const idx = this._prefsList.findIndex(
|
||||
p => p.event_type === eventType && p.channel_type === channelType && p.target_id === targetId
|
||||
);
|
||||
if (idx >= 0) {
|
||||
this._prefsList[idx].is_enabled = enabled;
|
||||
} else {
|
||||
this._prefsList.push({ event_type: eventType, channel_type: channelType, target_id: targetId, is_enabled: enabled });
|
||||
}
|
||||
},
|
||||
|
||||
async savePreferences() {
|
||||
this.prefsSaving = true;
|
||||
try {
|
||||
// Build flat list from all pending changes
|
||||
const prefsList = Object.values(this._pendingPrefs).map(entry => ({
|
||||
event_type: entry.event_type,
|
||||
channel_type: entry.channel_type,
|
||||
target_id: entry.target_id,
|
||||
is_enabled: entry.is_enabled,
|
||||
}));
|
||||
const resp = await fetch('/api/user-notifications/preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preferences: prefsList }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to save');
|
||||
}
|
||||
this._pendingPrefs = {};
|
||||
await this.loadPreferences();
|
||||
this.showToast('Preferences saved');
|
||||
} catch (e) {
|
||||
this.showToast(`Failed to save preferences: ${e.message}`, 'error');
|
||||
} finally {
|
||||
this.prefsSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────
|
||||
|
||||
showToast(message, type = 'success') {
|
||||
this.toast = { visible: true, message, type };
|
||||
setTimeout(() => { this.toast.visible = false; }, 3500);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -404,6 +404,53 @@
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- ocr_language (only shown for ocr step) -->
|
||||
<template x-if="stepModal.form.step_type === 'ocr'">
|
||||
<div>
|
||||
<label for="ocrLanguage" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
OCR Language
|
||||
</label>
|
||||
<select
|
||||
id="ocrLanguage"
|
||||
x-model="stepModal.form.config.ocr_language"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="auto">Auto (use system default)</option>
|
||||
<option value="ara">Arabic</option>
|
||||
<option value="chi_sim">Chinese (Simplified)</option>
|
||||
<option value="chi_tra">Chinese (Traditional)</option>
|
||||
<option value="ces">Czech</option>
|
||||
<option value="dan">Danish</option>
|
||||
<option value="nld">Dutch</option>
|
||||
<option value="eng">English</option>
|
||||
<option value="fin">Finnish</option>
|
||||
<option value="fra">French</option>
|
||||
<option value="deu">German</option>
|
||||
<option value="ell">Greek</option>
|
||||
<option value="heb">Hebrew</option>
|
||||
<option value="hin">Hindi</option>
|
||||
<option value="hun">Hungarian</option>
|
||||
<option value="ita">Italian</option>
|
||||
<option value="jpn">Japanese</option>
|
||||
<option value="kor">Korean</option>
|
||||
<option value="nor">Norwegian</option>
|
||||
<option value="pol">Polish</option>
|
||||
<option value="por">Portuguese</option>
|
||||
<option value="ron">Romanian</option>
|
||||
<option value="rus">Russian</option>
|
||||
<option value="spa">Spanish</option>
|
||||
<option value="swe">Swedish</option>
|
||||
<option value="tha">Thai</option>
|
||||
<option value="tur">Turkish</option>
|
||||
<option value="ukr">Ukrainian</option>
|
||||
<option value="vie">Vietnamese</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Overrides the global language setting for Tesseract/EasyOCR. Azure and Mistral auto-detect the language.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Enabled -->
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user