A43 (les 4 items sont clos) :
- `__CSRF_PLACEHOLDER__` supprimé : ContextVar CSRF_TOKEN posée par
CSRFMiddleware avant call_next (même mécanique que le nonce CSP),
global `{{ csrf_token() }}` dans templating, base.html rend
`{"X-CSRF-Token":{{ csrf_token()|tojson }}}` — vide si cookie absent,
`htmx:configRequest` re-lit le cookie à chaque appel → plus jamais de
jeton factice servi.
- Palette : `fetch('/api/search…')` SANS header (GET ∈ SAFE_METHODS →
CSRF inapplicable) — le JSON.parse du body-attr par frappe disparaît.
- utcnow déprécié = 0 dans app/**.py ; health loggé (A25) + booléens db/
gitea = raison ; probe réseau = voulu (test de connectivité).
A38 phase 1 (CSRF unifié) :
- `window.getCsrf()` unique dans le <head> de base.html.
- 76 lectures brutes du cookie → getCsrf() dans 13 fichiers (47 formes
`(…||[])[1]||''`, 25 déclarations `const X = match(…)` avec conversion
de leurs usages `X?X[1]:''` → `X` (noms collectés PAR FICHIER, jamais
de règle globale), 4 formes espacées).
- Définitions dupliquées supprimées : card_detail.html (multi-lignes),
database_table.js (1 ligne dans IIFE → chute sur le global).
- Les 3 variantes de base.html (IIFE getCsrf + 2 getCsrfToken) →
`return getCsrf()`.
- welcome.html garde sa lecture locale (page autonome sans base) ;
reste exactement 1 raw dans base.html (la déf head) + 2 dans welcome.
Test : test_csrf_server_rendered_no_placeholder (pas de placeholder +
token du hx-headers == cookie csrf_token).
suite **1092/1092** · ruff OK · node --check vert · docs à jour
134 lines
6.4 KiB
HTML
134 lines
6.4 KiB
HTML
{# ── Notification bell + dropdown (v4.9.0) ──
|
|
Self-contained Alpine component. Polls unread count, opens a panel of
|
|
notifications, marks as read. Only rendered for authenticated users. #}
|
|
{% if user and user.get('id') %}
|
|
<span class="topbar-btn fd-notif-bell" x-data="fdNotifications()" x-init="init()"
|
|
@click.outside="open=false" style="position:relative;display:inline-flex;">
|
|
<button type="button" class="topbar-btn" @click="toggle()" title="Notifications"
|
|
style="padding:6px;position:relative;border:none;background:none;cursor:pointer;color:var(--text);">
|
|
{{ fd_icon("bell", 16) }}
|
|
<span x-show="unread > 0" x-cloak
|
|
class="fd-notif-badge"
|
|
x-text="unread > 99 ? '99+' : unread"
|
|
style="position:absolute;top:0;right:0;background:#E03E3E;color:#fff;
|
|
border-radius:10px;font-size:10px;line-height:1;padding:3px 5px;
|
|
font-weight:700;min-width:16px;text-align:center;transform:translate(30%,-30%);"></span>
|
|
</button>
|
|
|
|
<div x-show="open" x-cloak x-transition
|
|
class="fd-notif-panel"
|
|
style="position:absolute;top:calc(100% + 6px);right:0;width:340px;max-width:92vw;
|
|
background:var(--bg-primary,#1f1f1f);border:1px solid var(--border,#333);
|
|
border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.45);overflow:hidden;z-index:2000;">
|
|
<div class="fd-notif-header"
|
|
style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
|
|
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
|
|
<span>Notifications</span>
|
|
<button type="button" class="btn-sm" @click="markAllRead()" x-show="unread > 0"
|
|
style="font-size:12px;cursor:pointer;">Mark all read</button>
|
|
</div>
|
|
<div class="fd-notif-list" style="max-height:360px;overflow-y:auto;">
|
|
<template x-for="n in items" :key="n.id">
|
|
<a :href="n.url || '#'" @click.prevent="openItem(n)"
|
|
class="fd-notif-item"
|
|
style="display:flex;gap:10px;padding:10px 14px;text-decoration:none;color:var(--text);
|
|
border-bottom:1px solid var(--border,#2a2a2a);cursor:pointer;"
|
|
:style="{ background: n.is_read ? 'transparent' : 'rgba(35,131,226,.10)' }">
|
|
<div style="width:30px;height:30px;border-radius:50%;flex-shrink:0;
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-weight:700;font-size:14px;color:#fff;"
|
|
:style="{ background: n.actor_color || '#3A3A3A' }">
|
|
<template x-if="n.actor_avatar">
|
|
<img :src="n.actor_avatar" style="width:30px;height:30px;border-radius:50%;object-fit:cover;">
|
|
</template>
|
|
<span x-show="!n.actor_avatar" x-text="(n.actor_name || n.actor_login || '?').charAt(0).toUpperCase()"></span>
|
|
</div>
|
|
<div style="flex:1;min-width:0;">
|
|
<div style="font-size:13px;font-weight:600;color:var(--text);" x-text="n.title"></div>
|
|
<div style="font-size:12px;color:var(--text-dim,#999);margin-top:2px;white-space:normal;
|
|
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;"
|
|
x-text="n.message"></div>
|
|
<div style="font-size:11px;color:var(--text-tertiary,#777);margin-top:4px;" x-text="timeAgo(n.created_at)"></div>
|
|
</div>
|
|
</a>
|
|
</template>
|
|
<div x-show="!loading && items.length === 0"
|
|
style="padding:24px;text-align:center;color:var(--text-dim,#999);font-size:13px;">
|
|
You're all caught up 🎉
|
|
</div>
|
|
<div x-show="loading" style="padding:24px;text-align:center;color:var(--text-dim,#999);">Loading…</div>
|
|
</div>
|
|
</div>
|
|
</span>
|
|
|
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
|
document.addEventListener('alpine:init', function () {
|
|
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
|
|
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
|
|
Alpine.data('fdNotifications', function () {
|
|
return {
|
|
open: false, items: [], unread: 0, loading: false, _timer: null,
|
|
init() {
|
|
this.load();
|
|
var self = this;
|
|
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
|
|
},
|
|
destroy() {
|
|
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
|
},
|
|
toggle() { this.open = !this.open; if (this.open) this.load(); },
|
|
timeAgo(s) {
|
|
if (!s) return '';
|
|
var t = new Date((String(s).includes('Z') || String(s).includes('T') ? s : s + 'Z'));
|
|
if (isNaN(t.getTime())) t = new Date(s);
|
|
var diff = Math.floor((Date.now() - t.getTime()) / 1000);
|
|
if (diff < 60) return 'just now';
|
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
|
return Math.floor(diff / 86400) + 'd ago';
|
|
},
|
|
csrf() {
|
|
return getCsrf();
|
|
},
|
|
refreshCount() {
|
|
var self = this;
|
|
fetch('/api/notifications/unread-count', { credentials: 'same-origin' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) { self.unread = d.unread || 0; })
|
|
.catch(function () {});
|
|
},
|
|
load() {
|
|
var self = this;
|
|
this.loading = true;
|
|
fetch('/api/notifications?limit=50', { credentials: 'same-origin' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
self.items = d.notifications || [];
|
|
self.unread = d.unread || 0;
|
|
self.loading = false;
|
|
})
|
|
.catch(function () { self.loading = false; });
|
|
},
|
|
openItem(n) {
|
|
if (!n.is_read) {
|
|
var self = this;
|
|
fetch('/api/notifications/read', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
|
|
body: JSON.stringify({ id: n.id })
|
|
}).then(function () { self.refreshCount(); self.load(); });
|
|
}
|
|
if (n.url) window.location.href = n.url;
|
|
this.open = false;
|
|
},
|
|
markAllRead() {
|
|
var self = this;
|
|
fetch('/api/notifications/read', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
|
|
body: JSON.stringify({})
|
|
}).then(function () { self.refreshCount(); self.load(); });
|
|
}
|
|
};
|
|
});
|
|
});
|
|
</script>
|
|
{% endif %} |