Add a news review workflow and a staff notification area
News gains a PENDING_REVIEW status between draft and published. A non-editor author (can_add_news but not can_publish_news -- a coach_manager, not an ADMIN/EDITOR) gets a "Send for review" button instead of Publish; an editor/admin always sees Publish directly, no review step. Submitting notifies every ADMIN/EDITOR in-app only (see notify_members' new send_email=False) -- a review queue that emailed on every submission would get noisy fast. The notification area itself: a topbar bell (badge + dropdown, same <details>/<summary> convention as the sidebar's user-menu, generalised to a shared .dismissable-details close handler) visible on every page, plus a fuller "Notifications" card on the dashboard, both fed by a new notification_bell context processor. "Mark all read" clears the signed-in staff member's own unread notifications for this club. This is the reusable notification system's first consumer beyond news publishing itself -- validates that notify_members()/Notification generalise the way they were meant to. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -16,6 +16,8 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{% if news_item.status == "draft" %}
|
||||
<span class="badge">{% trans "Draft" %}</span>
|
||||
{% elif news_item.status == "pending_review" %}
|
||||
<span class="badge badge-warning">{% trans "Pending review" %}</span>
|
||||
{% elif news_item.is_scheduled %}
|
||||
<span class="badge badge-info">{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}</span>
|
||||
{% else %}
|
||||
@@ -29,11 +31,17 @@
|
||||
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('delete_news_modal').showModal()">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
||||
{% endif %}
|
||||
{% if can_publish %}
|
||||
{% if news_item.status == "draft" %}
|
||||
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('publish_modal').showModal()">{% lucide "upload" size=14 %} {% trans "Publish" %}</button>
|
||||
{% else %}
|
||||
{% if news_item.status == "published" %}
|
||||
<button class="btn btn-outline btn-warning btn-sm gap-2" type="button" onclick="document.getElementById('unpublish_modal').showModal()">{% lucide "eye-off" size=14 %} {% trans "Unpublish" %}</button>
|
||||
{% else %}
|
||||
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('publish_modal').showModal()">{% lucide "upload" size=14 %} {% trans "Publish" %}</button>
|
||||
{% endif %}
|
||||
{% elif can_edit and news_item.status == "draft" %}
|
||||
{# Whoever can edit but not publish (a coach_manager, not an editor/admin -- see club.services.access.can_add_news vs can_publish_news) hands it off instead. #}
|
||||
<form method="post" action="{% url 'management:news_submit_for_review' news_item.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "send" size=14 %} {% trans "Send for review" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
Opens upward (bottom-full): the trigger sits at the very bottom of the
|
||||
sidebar, so a menu opening down would run off the viewport.
|
||||
{% endcomment %}
|
||||
<details id="user-menu" class="relative border-t border-sidebar-hairline pt-3.5">
|
||||
<details id="user-menu" class="dismissable-details relative border-t border-sidebar-hairline pt-3.5">
|
||||
<summary class="flex cursor-pointer list-none items-center gap-2.5 [&::-webkit-details-marker]:hidden">
|
||||
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-sidebar-chip font-display text-[13px] font-extrabold text-sidebar-fg">{% if user.member %}{{ user.member.first_name|slice:":1" }}{{ user.member.last_name|slice:":1" }}{% else %}?{% endif %}</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
@@ -103,6 +103,40 @@
|
||||
<span class="font-display text-2xl font-extrabold text-ink uppercase">{% block heading %}{% block topbar_title %}Management{% endblock topbar_title %}{% endblock heading %}</span>
|
||||
{% block topbar_context %}{% endblock topbar_context %}
|
||||
<div class="flex-1"></div>
|
||||
{% if unread_notification_count is not None %}
|
||||
{# Same <details>/<summary> convention as the sidebar's own user-menu below -- no JS needed to open it, only the shared outside-click/Escape listener to close it. #}
|
||||
<details id="notification-menu" class="dismissable-details relative">
|
||||
<summary class="btn btn-outline btn-square list-none [&::-webkit-details-marker]:hidden" aria-label="{% trans "Notifications" %}">
|
||||
{% lucide "bell" size=16 %}
|
||||
{% if unread_notification_count %}
|
||||
<span class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-club px-1 font-mono text-[9px] font-bold text-white">{{ unread_notification_count }}</span>
|
||||
{% endif %}
|
||||
</summary>
|
||||
<div class="absolute top-full right-0 z-20 mt-2 w-80 rounded-xl border border-line bg-white p-1.5 shadow-lg">
|
||||
<div class="flex items-center justify-between px-2.5 py-2">
|
||||
<span class="font-display text-xs font-bold tracking-[.08em] text-ink uppercase">{% trans "Notifications" %}</span>
|
||||
{% if unread_notification_count %}
|
||||
<form method="post" action="{% url 'management:notification_mark_all_read' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
<button class="text-xs text-club hover:underline" type="submit">{% trans "Mark all read" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex max-h-96 flex-col overflow-y-auto">
|
||||
{% for notification in recent_notifications %}
|
||||
<div class="flex flex-col gap-0.5 rounded-lg px-2.5 py-2 {% if not notification.read_at %}bg-subhead{% endif %}">
|
||||
<span class="text-[13px] font-semibold text-ink">{{ notification.title }}</span>
|
||||
<span class="text-xs text-muted">{{ notification.body|truncatechars:100 }}</span>
|
||||
<span class="font-mono text-[10px] text-dim">{{ notification.created|timesince }} {% trans "ago" %}</span>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="px-2.5 py-6 text-center text-sm text-muted">{% trans "Nothing yet." %}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
<div class="flex items-center gap-2">
|
||||
{% block actions %}{% endblock actions %}
|
||||
</div>
|
||||
@@ -140,13 +174,19 @@
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const menu = document.getElementById("user-menu");
|
||||
if (!menu) return;
|
||||
// <details>, not a click-driven dropdown -- no JS needed to open either
|
||||
// the user menu or the notification bell, only this shared outside-
|
||||
// click/Escape listener to close whichever one is open (a <details>
|
||||
// stays open until its own <summary> is clicked again otherwise).
|
||||
const menus = document.querySelectorAll(".dismissable-details");
|
||||
if (!menus.length) return;
|
||||
document.addEventListener("click", (event) => {
|
||||
if (menu.open && !menu.contains(event.target)) menu.open = false;
|
||||
menus.forEach((menu) => {
|
||||
if (menu.open && !menu.contains(event.target)) menu.open = false;
|
||||
});
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") menu.open = false;
|
||||
if (event.key === "Escape") menus.forEach((menu) => { menu.open = false; });
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -168,6 +168,35 @@
|
||||
|
||||
{# --- right: this weekend + news ----------------------------------- #}
|
||||
<div class="flex flex-col gap-5">
|
||||
{% if recent_notifications is not None %}
|
||||
{# Same anatomy as "Needs attention" on the left -- see management.context_processors.notification_bell, also shown as a dropdown from the topbar bell on every page; this is the fuller list. #}
|
||||
<div class="card flex flex-col">
|
||||
<div class="flex items-center justify-between border-b border-line px-4.5 py-3.5">
|
||||
<span class="font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "Notifications" %}</span>
|
||||
{% if unread_notification_count %}
|
||||
<form method="post" action="{% url 'management:notification_mark_all_read' %}">
|
||||
{% csrf_token %}
|
||||
<button class="text-xs font-semibold text-club hover:underline" type="submit">{% trans "Mark all read" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
{% for notification in recent_notifications %}
|
||||
<div class="flex items-center gap-3.5 border-b border-rule px-4.5 py-3.5">
|
||||
<span class="h-8.5 w-1.5 shrink-0 rounded-sm {% if not notification.read_at %}bg-club{% else %}bg-line{% endif %}"></span>
|
||||
<div class="flex-1">
|
||||
<div class="text-[15px] font-semibold text-ink">{{ notification.title }}</div>
|
||||
<div class="text-[13px] text-muted">{{ notification.body|truncatechars:120 }}</div>
|
||||
</div>
|
||||
<span class="shrink-0 font-mono text-xs text-dim">{{ notification.created|timesince }} {% trans "ago" %}</span>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="px-4.5 py-6 text-center text-sm text-muted">{% trans "Nothing yet." %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-xl bg-ink p-4.5 text-white">
|
||||
<div class="mb-3.5 font-display text-base font-extrabold tracking-[.08em] uppercase">{% trans "Upcoming" %}</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -27,14 +27,16 @@
|
||||
<div class="mb-1 flex items-center gap-2.5">
|
||||
{% if item.status == "draft" %}
|
||||
<span class="badge badge-sm">{% trans "Draft" %}</span>
|
||||
{% elif item.status == "pending_review" %}
|
||||
<span class="badge badge-warning badge-sm">{% trans "Pending review" %}</span>
|
||||
{% elif item.is_scheduled %}
|
||||
<span class="badge badge-info badge-sm">{% trans "Scheduled" %}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success badge-sm">{% trans "Published" %}</span>
|
||||
{% endif %}
|
||||
<span class="font-mono text-[11px] text-dim">
|
||||
{% if item.status == "draft" %}{% blocktrans with time=item.modified|timesince %}Edited {{ time }} ago{% endblocktrans %}
|
||||
{% else %}{{ item.published_at|date:"j M Y" }}{% endif %}
|
||||
{% if item.status == "published" %}{{ item.published_at|date:"j M Y" }}
|
||||
{% else %}{% blocktrans with time=item.modified|timesince %}Edited {{ time }} ago{% endblocktrans %}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="truncate font-display text-xl leading-[1.05] font-extrabold text-ink uppercase">{{ item.title }}</div>
|
||||
|
||||
Reference in New Issue
Block a user