User panel: Phase 0 shared UI foundations

- Add design-token stylesheet (wwwroot/css/user-panel.css): CSS variables
  derived from the existing p8.css palette (brand maroon, success/danger
  colors, radius, shadow), plus reusable component classes (card, button
  variants, alert, badge, empty state, pagination, list item, toast,
  confirm modal). Includes a prefers-color-scheme dark block compatible
  with the existing toggleColorScheme() mechanism in bk.js.
- Add wwwroot/js/user-panel.js: upConfirm() and upToast() helpers as
  styled, promise-based replacements for window.confirm()/alert(),
  falling back to the native dialogs if the modal/toast markup isn't
  present on a page.
- Add Areas/User/Pages/Shared/ partials: _Pagination (wraps the
  List<NameIdUrlImage> pattern already used by every list page),
  _EmptyState, _Toasts and _ConfirmModal (hosts, included once by the
  layout).
- Wire the new stylesheet/script and the toast/modal hosts into
  _UserPanelLayout.cshtml.
- Refactor Notifications.cshtml as a first demonstration: replaces the
  hand-rolled pagination loop, manual gray/lightsteelblue row
  alternation, and confirm()/alert() calls with the new components.

No backend/API changes. Rest of the User area is untouched pending
Phase 1+ (nav/dashboard, forms, remaining list pages, moderation
queues, editors).
This commit is contained in:
Claude 2026-08-05 08:59:01 +00:00 committed by Hamid Reza Mohammadi
parent f350bb9fc3
commit f09b9de84a
8 changed files with 698 additions and 140 deletions

View File

@ -1,69 +1,65 @@
@page
@page
@model GanjooRazor.Areas.User.Pages.NotificationsModel
@using DNTPersianUtils.Core
@{
Layout = "_UserPanelLayout";
ViewData["Title"] = "اعلان‌های من";
await GanjooRazor.Utils.GanjoorSessionChecker.ApplyPermissionsToViewData(Request, Response, ViewData);
string color = "gray";
}
<h1>@ViewData["Title"]</h1>
<div class="up-page-header">
<h1>@ViewData["Title"]</h1>
</div>
@if (Model.LastError != "")
{
<h2>@Model.LastError</h2>
<div class="up-alert up-alert--danger">@Model.LastError</div>
}
else
{
<script>
function deleteNotification(notificationId) {
if (!confirm('آیا از حذف این اعلان اطمینان دارید؟'))
return;
var url = '?handler=Notification';
async function deleteNotification(notificationId) {
var ok = await upConfirm('آیا از حذف این اعلان اطمینان دارید؟');
if (!ok) return;
$.ajax({
type: "DELETE",
url: url,
data: {
id: notificationId
},
url: '?handler=Notification',
data: { id: notificationId },
success: function () {
var notificationBlockId = '#notification-' + notificationId;
$(notificationBlockId).remove();
$('#notification-' + notificationId).remove();
upToast('اعلان حذف شد.', 'success');
},
error: function (e) {
upToast(e.responseText || 'حذف اعلان با خطا مواجه شد.', 'error');
}
});
}
function markReadNotification(notificationId) {
var url = '?handler=MarkRead';
$.ajax({
type: "PUT",
url: url,
data: {
id: notificationId
},
url: '?handler=MarkRead',
data: { id: notificationId },
success: function () {
var notificationBlockId = '#unread-' + notificationId;
$(notificationBlockId).remove();
var item = document.getElementById('notification-' + notificationId);
if (item) item.classList.remove('up-list-item--unread');
var btn = document.getElementById('unread-' + notificationId);
if (btn) btn.remove();
},
error: function (e) {
upToast(e.responseText || 'ثبت خوانده شدن اعلان با خطا مواجه شد.', 'error');
}
});
}
function markAllRead() {
if (!confirm('آیا از علامتگذاری همهٔ اعلانها به عنوان خوانده شده اطمینان دارید؟'))
return;
var url = '?handler=MarkAllRead';
async function markAllRead() {
var ok = await upConfirm('آیا از علامتگذاری همهٔ اعلان‌ها به عنوان خوانده شده اطمینان دارید؟', { danger: false, okText: 'بله، علامتگذاری کن' });
if (!ok) return;
$.ajax({
type: "PUT",
url: url,
url: '?handler=MarkAllRead',
error: function (e) {
if (e.responseText == null)
alert(e);
else
alert(e.responseText);
upToast(e.responseText || 'خطایی رخ داد.', 'error');
},
success: function () {
location.reload();
@ -71,20 +67,15 @@ else
});
}
function deleteAllRead() {
if (!confirm('آیا از حذف همهٔ اعلانهای خوانده شده اطمینان دارید؟'))
return;
var url = '?handler=AllRead';
async function deleteAllRead() {
var ok = await upConfirm('آیا از حذف همهٔ اعلان‌های خوانده شده اطمینان دارید؟');
if (!ok) return;
$.ajax({
type: "DELETE",
url: url,
url: '?handler=AllRead',
error: function (e) {
if (e.responseText == null)
alert(e);
else
alert(e.responseText);
upToast(e.responseText || 'خطایی رخ داد.', 'error');
},
success: function () {
location.reload();
@ -93,78 +84,46 @@ else
}
</script>
<p style="text-align:center">
@foreach (var paginationLink in Model.PaginationLinks)
{
if (!string.IsNullOrEmpty(paginationLink.Url))
{
<a href="@paginationLink.Url">@paginationLink.Name</a>
}
else
{
@paginationLink.Name
}
}
</p>
<partial name="_Pagination" model="Model.PaginationLinks" />
@if (Model.Notifications.Count == 0)
{
<p>
در حال حاضر اعلانی وجود ندارد. در صورتی که کاربری با زدن دکمهٔ «پاسخگویی» برای حاشیهٔ شما پاسخی بنویسد یا شما با پیشنهاد آهنگ مرتبط با شعر یا روش دیگری در گنجور مشارکت کنید و مشارکت شما بررسی شود در قسمت «اعلان‌های من» با دریافت یک اعلان از این رویدادها مطلع می شوید.
</p>
<partial name="_EmptyState" model="@("در حال حاضر اعلانی وجود ندارد. در صورتی که کاربری با زدن دکمهٔ «پاسخگویی» برای حاشیهٔ شما پاسخی بنویسد یا شما با پیشنهاد آهنگ مرتبط با شعر یا روش دیگری در گنجور مشارکت کنید و مشارکت شما بررسی شود در قسمت «اعلان‌های من» با دریافت یک اعلان از این رویدادها مطلع می‌شوید.")" />
}
else
{
<p>
<a role="button" onclick="markAllRead()" class="pagebutton">همه را خواندم</a>
</p>
<p>
<a role="button" onclick="deleteAllRead()" class="pagebutton">حذف همهٔ خوانده شده‌ها</a>
</p>
}
<div class="up-btn-group up-mt-0" style="margin-bottom: var(--up-space-3);">
<a role="button" onclick="markAllRead()" class="up-btn up-btn--ghost">همه را خواندم</a>
<a role="button" onclick="deleteAllRead()" class="up-btn up-btn--ghost">حذف همهٔ خوانده شده‌ها</a>
</div>
<div class="up-list">
@foreach (var notification in Model.Notifications)
{
if (color == "gray")
color = "lightsteelblue";
else
color = "gray";
<div style="background-color:@color" id="notification-@notification.Id">
<blockquote>
<p>
@notification.Subject <small>در @notification.DateTime.ToFriendlyPersianDateTextify()</small>
</p>
bool unread = notification.Status == RSecurityBackend.Models.Notification.NotificationStatus.Unread;
<div class="up-list-item @(unread ? "up-list-item--unread" : "")" id="notification-@notification.Id">
<div>
<strong>@notification.Subject</strong>
<span class="up-list-item__meta">@notification.DateTime.ToFriendlyPersianDateTextify()</span>
</div>
<div id="notification-text-@notification.Id">
@Html.Raw(notification.HtmlText)
</div>
</blockquote>
<p>
<a role="button" onclick="deleteNotification('@notification.Id')" class="actionlink" title="حذف"><i class="noindent-info-button delete-icon"></i></a>
@if (notification.Status == RSecurityBackend.Models.Notification.NotificationStatus.Unread)
<div class="up-list-item__actions">
<a role="button" onclick="deleteNotification('@notification.Id')" class="up-icon-btn" title="حذف" aria-label="حذف این اعلان">
<i class="noindent-info-button delete-icon"></i>
</a>
@if (unread)
{
<a role="button" onclick="markReadNotification('@notification.Id')" class="actionlink" title="خواندم" id="unread-@notification.Id"><i class="noindent-info-button done-icon"></i></a>
<a role="button" onclick="markReadNotification('@notification.Id')" class="up-icon-btn" title="خواندم" aria-label="علامت‌گذاری به عنوان خوانده شده" id="unread-@notification.Id">
<i class="noindent-info-button done-icon"></i>
</a>
}
</div>
</div>
}
</p>
</div>
<hr />
<partial name="_Pagination" model="Model.PaginationLinks" />
}
<p style="text-align:center">
@foreach (var paginationLink in Model.PaginationLinks)
{
if (!string.IsNullOrEmpty(paginationLink.Url))
{
<a href="@paginationLink.Url">@paginationLink.Name</a>
}
else
{
@paginationLink.Name
}
}
</p>
}

View File

@ -0,0 +1,11 @@
@* Styled replacement for window.confirm(). Included once by
_UserPanelLayout.cshtml. Trigger with JS: await upConfirm('پیام شما') *@
<div class="up-modal-backdrop" id="upConfirmBackdrop" hidden>
<div class="up-modal" role="alertdialog" aria-modal="true" aria-labelledby="upConfirmTitle">
<p id="upConfirmTitle" class="up-modal__message"></p>
<div class="up-modal__actions">
<button type="button" class="up-btn up-btn--ghost" id="upConfirmCancel">انصراف</button>
<button type="button" class="up-btn up-btn--danger" id="upConfirmOk">تأیید</button>
</div>
</div>
</div>

View File

@ -0,0 +1,12 @@
@*
A consistent "nothing here yet" state for list pages, instead of a bare
<p>. Pass the explanatory text as the model:
<partial name="_EmptyState" model="@("در حال حاضر اعلانی وجود ندارد.")" />
*@
@model string
<div class="up-empty">
<div class="up-empty__icon" aria-hidden="true">&#9679;</div>
<p>@Model</p>
</div>

View File

@ -0,0 +1,29 @@
@*
Renders the pagination links used throughout the User area.
Every list page already builds a List<NameIdUrlImage> called
PaginationLinks (Name = label, Url = link, or Url == "" for the
current/disabled page) — pass that model straight in:
<partial name="_Pagination" model="Model.PaginationLinks" />
Replaces the hand-written @foreach loop that was copy-pasted at the
top and bottom of every list page.
*@
@model System.Collections.Generic.List<GSpotifyProxy.Models.NameIdUrlImage>
@if (Model != null && Model.Count > 0)
{
<nav class="up-pagination" aria-label="صفحه‌بندی">
@foreach (var link in Model)
{
if (!string.IsNullOrEmpty(link.Url))
{
<a href="@link.Url">@link.Name</a>
}
else
{
<span class="up-pagination__current" aria-current="page">@link.Name</span>
}
}
</nav>
}

View File

@ -0,0 +1,3 @@
@* Toast notification host. Included once by _UserPanelLayout.cshtml.
Populate with JS: upToast('پیام شما', 'success' | 'error' | 'info') *@
<div class="up-toast-container" id="upToastContainer" aria-live="polite" aria-atomic="true"></div>

View File

@ -10,6 +10,7 @@
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" />
<link href="/css/p8.css?version=86" rel="stylesheet" type="text/css" />
<link href="/css/user-panel.css?version=1" rel="stylesheet" type="text/css" />
<link rel="shortcut icon" href="/favicon.ico?version=1" type="image/x-icon" />
<link rel="search" type="application/opensearchdescription+xml" title="گنجور" href="https://ganjoor.net/search.xml" />
<script src="~/lib/jquery/dist/jquery.min.js"></script>
@ -180,16 +181,18 @@
</div>
</nav>
</header>
<div class="container">
<div class="container up-shell">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<partial name="_Toasts" />
<partial name="_ConfirmModal" />
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
<script src="/js/user-panel.js?version=1"></script>
@await RenderSectionAsync("Scripts", required: false)
<div id="footer"></div>

View File

@ -0,0 +1,451 @@
/*
User area (پیشخان کاربر) design foundation.
Scoped, additive layer on top of p8.css/site.css — nothing here overrides
the public site's look. Tokens are pulled from p8.css's existing palette
(brand maroon #300, success/danger greens & reds, 5px radius, existing
box-shadow recipe) so the panel still feels like Ganjoor, just tidier.
Class prefix "up-" (user-panel) is used throughout to avoid collisions
with the large, unscoped p8.css class list.
*/
:root {
--up-primary: #300;
--up-primary-hover: #4d1010;
--up-on-primary: #fff;
--up-success: #0a0;
--up-success-dark: darkgreen;
--up-danger: #f00;
--up-danger-dark: darkred;
--up-gray: slategray;
--up-bg: #f5f7fb;
--up-surface: #fff;
--up-surface-alt: #f4f1ec;
--up-border: #e0e0e0;
--up-text: #222;
--up-text-muted: #666;
--up-radius-sm: 5px;
--up-radius: 10px;
--up-radius-lg: 16px;
--up-shadow: 0 5px 0.3rem rgba(0, 0, 0, 0.3);
--up-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.12);
--up-space-1: 4px;
--up-space-2: 8px;
--up-space-3: 16px;
--up-space-4: 24px;
--up-space-5: 32px;
--up-font: 'Vazirmatn', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--up-bg: #121212;
--up-surface: #1c1c1c;
--up-surface-alt: #232323;
--up-border: #3a3a3a;
--up-text: bisque;
--up-text-muted: #c9b8a8;
--up-primary: #a54a4a;
--up-primary-hover: #c25e5e;
--up-on-primary: #fff;
}
}
/* ---------- layout shell ---------- */
.up-shell {
font-family: var(--up-font);
color: var(--up-text);
text-align: right;
}
.up-page-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--up-space-2);
margin-bottom: var(--up-space-3);
flex-wrap: wrap;
}
.up-page-header h1 {
margin: 0;
font-size: 1.5rem;
}
.up-page-subtitle {
color: var(--up-text-muted);
font-size: 0.9rem;
margin-top: var(--up-space-1);
}
/* ---------- card ---------- */
.up-card {
background: var(--up-surface);
border: 1px solid var(--up-border);
border-radius: var(--up-radius-lg);
box-shadow: var(--up-shadow-sm);
padding: var(--up-space-4);
margin-bottom: var(--up-space-3);
}
.up-card + .up-card {
margin-top: var(--up-space-3);
}
.up-card__title {
margin: 0 0 var(--up-space-3);
font-size: 1.1rem;
border-bottom: 1px solid var(--up-border);
padding-bottom: var(--up-space-2);
}
/* ---------- buttons ---------- */
/* Compatible in spirit with the existing .pagebutton/.editor-button look
(radius, shadow) so mixed usage during the migration doesn't feel jarring. */
.up-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--up-space-1);
cursor: pointer;
font-family: var(--up-font);
font-size: 0.9rem;
padding: 8px 16px;
border-radius: var(--up-radius-sm);
border: 1px solid transparent;
box-shadow: var(--up-shadow);
color: var(--up-on-primary);
background-color: var(--up-primary);
text-decoration: none;
transition: background-color 0.15s ease, opacity 0.15s ease, transform 0.05s ease;
}
.up-btn:hover {
background-color: var(--up-primary-hover);
color: var(--up-on-primary);
text-decoration: none;
}
.up-btn:active {
transform: translateY(1px);
}
.up-btn:disabled,
.up-btn[aria-disabled="true"] {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.up-btn--success {
background-color: var(--up-success-dark);
}
.up-btn--success:hover {
background-color: var(--up-success);
}
.up-btn--danger {
background-color: var(--up-danger-dark);
}
.up-btn--danger:hover {
background-color: var(--up-danger);
}
.up-btn--ghost {
background-color: transparent;
color: var(--up-text);
border-color: var(--up-border);
box-shadow: none;
}
.up-btn--ghost:hover {
background-color: var(--up-surface-alt);
color: var(--up-text);
}
.up-btn--sm {
padding: 4px 10px;
font-size: 0.8rem;
}
.up-btn--block {
display: flex;
width: 100%;
}
.up-btn-group {
display: flex;
gap: var(--up-space-2);
flex-wrap: wrap;
}
/* icon-only action buttons (delete/mark-read/etc.) */
.up-icon-btn {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--up-border);
background: var(--up-surface);
color: var(--up-text);
}
.up-icon-btn:hover {
background: var(--up-surface-alt);
}
/* ---------- alerts / notices ---------- */
.up-alert {
border-radius: var(--up-radius);
padding: var(--up-space-3);
margin-bottom: var(--up-space-3);
border: 1px solid transparent;
font-size: 0.9rem;
}
.up-alert--info {
background: #eef3fb;
border-color: #cfe0f5;
color: #1c3a5e;
}
.up-alert--success {
background: #eaf7ea;
border-color: #c9e8c9;
color: #1e4620;
}
.up-alert--danger {
background: #fbeaea;
border-color: #f0caca;
color: #5e1c1c;
}
.up-alert--warning {
background: #fff6e5;
border-color: #f3ddaa;
color: #5e4a1c;
}
@media (prefers-color-scheme: dark) {
.up-alert--info { background: #172633; border-color: #24405c; color: bisque; }
.up-alert--success { background: #142a16; border-color: #1f4a22; color: bisque; }
.up-alert--danger { background: #331616; border-color: #5c2424; color: bisque; }
.up-alert--warning { background: #332a14; border-color: #5c4a1f; color: bisque; }
}
/* ---------- badges ---------- */
.up-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 0.75rem;
line-height: 1.6;
background: var(--up-surface-alt);
color: var(--up-text-muted);
border: 1px solid var(--up-border);
}
.up-badge--primary { background: var(--up-primary); color: var(--up-on-primary); border-color: transparent; }
.up-badge--success { background: var(--up-success-dark); color: #fff; border-color: transparent; }
.up-badge--danger { background: var(--up-danger-dark); color: #fff; border-color: transparent; }
/* ---------- empty state ---------- */
.up-empty {
text-align: center;
padding: var(--up-space-5) var(--up-space-3);
color: var(--up-text-muted);
}
.up-empty__icon {
font-size: 2rem;
margin-bottom: var(--up-space-2);
opacity: 0.6;
}
/* ---------- pagination ---------- */
.up-pagination {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: var(--up-space-1);
margin: var(--up-space-3) 0;
}
.up-pagination a,
.up-pagination span {
display: inline-flex;
min-width: 32px;
height: 32px;
padding: 0 8px;
align-items: center;
justify-content: center;
border-radius: var(--up-radius-sm);
font-size: 0.85rem;
text-decoration: none;
color: var(--up-text);
}
.up-pagination a {
border: 1px solid var(--up-border);
background: var(--up-surface);
}
.up-pagination a:hover {
background: var(--up-surface-alt);
text-decoration: none;
}
.up-pagination span.up-pagination__current {
background: var(--up-primary);
color: var(--up-on-primary);
font-weight: 600;
}
/* ---------- list items (notifications, comments, edits...) ---------- */
.up-list {
display: flex;
flex-direction: column;
gap: var(--up-space-2);
}
.up-list-item {
background: var(--up-surface);
border: 1px solid var(--up-border);
border-radius: var(--up-radius);
padding: var(--up-space-3);
position: relative;
}
.up-list-item--unread {
border-inline-start: 4px solid var(--up-primary);
}
.up-list-item__meta {
color: var(--up-text-muted);
font-size: 0.8rem;
margin-inline-start: var(--up-space-2);
}
.up-list-item__actions {
display: flex;
gap: var(--up-space-2);
margin-top: var(--up-space-2);
}
/* ---------- toasts ---------- */
.up-toast-container {
position: fixed;
bottom: var(--up-space-4);
left: 50%;
transform: translateX(-50%);
z-index: 1080;
display: flex;
flex-direction: column;
gap: var(--up-space-2);
align-items: center;
pointer-events: none;
width: 100%;
padding: 0 var(--up-space-3);
}
.up-toast {
pointer-events: auto;
width: max-content;
max-width: min(420px, 90vw);
padding: 10px 20px;
border-radius: var(--up-radius);
box-shadow: var(--up-shadow-sm);
color: #fff;
background: var(--up-gray);
font-size: 0.85rem;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.up-toast--visible {
opacity: 1;
transform: translateY(0);
}
.up-toast--success {
background: var(--up-success-dark);
}
.up-toast--error {
background: var(--up-danger-dark);
}
/* ---------- confirm modal (replaces window.confirm) ---------- */
.up-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1090;
padding: var(--up-space-3);
}
.up-modal-backdrop[hidden] {
display: none;
}
.up-modal {
background: var(--up-surface);
border-radius: var(--up-radius-lg);
box-shadow: var(--up-shadow-sm);
padding: var(--up-space-4);
max-width: 400px;
width: 100%;
text-align: center;
}
.up-modal__message {
margin: 0 0 var(--up-space-4);
font-size: 0.95rem;
line-height: 1.6;
}
.up-modal__actions {
display: flex;
gap: var(--up-space-2);
justify-content: center;
}
.up-modal__actions .up-btn {
flex: 1;
}
body.up-modal-open {
overflow: hidden;
}
/* ---------- small utilities ---------- */
.up-mt-0 { margin-top: 0; }
.up-text-muted { color: var(--up-text-muted); }
.up-text-center { text-align: center; }

View File

@ -0,0 +1,90 @@
// User area (پیشخان کاربر) shared UI helpers.
// Provides upConfirm() and upToast() as drop-in, styled replacements for
// window.confirm()/window.alert(), used across the panel's AJAX actions.
// Requires the markup from Areas/User/Pages/Shared/_ConfirmModal.cshtml and
// _Toasts.cshtml to be present on the page (included once by _UserPanelLayout).
/**
* Shows a styled confirmation dialog and resolves to true/false.
* Falls back to window.confirm() if the modal markup isn't on the page.
* @param {string} message
* @param {{okText?: string, cancelText?: string, danger?: boolean}} [options]
* @returns {Promise<boolean>}
*/
function upConfirm(message, options) {
options = options || {};
return new Promise(function (resolve) {
var backdrop = document.getElementById('upConfirmBackdrop');
var titleEl = document.getElementById('upConfirmTitle');
var okBtn = document.getElementById('upConfirmOk');
var cancelBtn = document.getElementById('upConfirmCancel');
if (!backdrop || !titleEl || !okBtn || !cancelBtn) {
resolve(window.confirm(message));
return;
}
titleEl.textContent = message;
okBtn.textContent = options.okText || 'تأیید';
cancelBtn.textContent = options.cancelText || 'انصراف';
okBtn.className = 'up-btn ' + (options.danger === false ? 'up-btn--success' : 'up-btn--danger');
backdrop.hidden = false;
document.body.classList.add('up-modal-open');
function cleanup(result) {
backdrop.hidden = true;
document.body.classList.remove('up-modal-open');
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
backdrop.removeEventListener('click', onBackdropClick);
document.removeEventListener('keydown', onKeyDown);
resolve(result);
}
function onOk() { cleanup(true); }
function onCancel() { cleanup(false); }
function onBackdropClick(e) { if (e.target === backdrop) cleanup(false); }
function onKeyDown(e) { if (e.key === 'Escape') cleanup(false); }
okBtn.addEventListener('click', onOk);
cancelBtn.addEventListener('click', onCancel);
backdrop.addEventListener('click', onBackdropClick);
document.addEventListener('keydown', onKeyDown);
okBtn.focus();
});
}
/**
* Shows a transient toast notification.
* Falls back to window.alert() if the toast container isn't on the page.
* @param {string} message
* @param {'success'|'error'|'info'} [type]
*/
function upToast(message, type) {
type = type || 'success';
var container = document.getElementById('upToastContainer');
if (!container) {
alert(message);
return;
}
var toast = document.createElement('div');
toast.className = 'up-toast up-toast--' + type;
toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(function () {
toast.classList.add('up-toast--visible');
});
setTimeout(function () {
toast.classList.remove('up-toast--visible');
setTimeout(function () {
toast.remove();
}, 250);
}, 4000);
}