penguinui
This commit is contained in:
@@ -207,10 +207,20 @@ mod tests {
|
|||||||
body.contains("href=\"/static/app.css\""),
|
body.contains("href=\"/static/app.css\""),
|
||||||
"{path} is missing the shared stylesheet"
|
"{path} is missing the shared stylesheet"
|
||||||
);
|
);
|
||||||
assert!(
|
// The shell carries exactly two <style> elements, and neither is
|
||||||
!body.contains("<style>"),
|
// page styling: the cascade-order declaration that puts app.css
|
||||||
|
// between Tailwind's preflight and its utilities, and Penguin UI's
|
||||||
|
// @theme. Any third one is a page inlining CSS instead of
|
||||||
|
// linking the stylesheet.
|
||||||
|
assert_eq!(
|
||||||
|
body.matches("<style").count(),
|
||||||
|
2,
|
||||||
"{path} inlines CSS instead of linking the stylesheet"
|
"{path} inlines CSS instead of linking the stylesheet"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("@layer theme, base, app, components, utilities;"),
|
||||||
|
"{path} lost the cascade order app.css depends on"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ pub(crate) fn render_builder(page: &AddTablePageState) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Used when the page itself cannot be loaded (auth or backend failure).
|
/// Used when the page itself cannot be loaded (auth or backend failure).
|
||||||
|
/// There is no draft left to render, so this replaces the builder — the dialog
|
||||||
|
/// is what tells the user why the form just emptied.
|
||||||
pub(crate) fn render_submission_error(message: &str) -> String {
|
pub(crate) fn render_submission_error(message: &str) -> String {
|
||||||
render(&Alert::error("Could not create the table", message))
|
render(&Alert::error("Could not create the table", message))
|
||||||
}
|
}
|
||||||
@@ -145,6 +147,41 @@ mod tests {
|
|||||||
assert!(html.contains(r#"name="accounting_currency" value="EUR" list="currency-codes""#));
|
assert!(html.contains(r#"name="accounting_currency" value="EUR" list="currency-codes""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The dialog only exists when there is a failure, and it carries the same
|
||||||
|
/// message as the inline alert behind it.
|
||||||
|
#[test]
|
||||||
|
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
|
||||||
|
let mut state = page();
|
||||||
|
assert!(!render_builder(&state).contains(r#"role="dialog""#));
|
||||||
|
|
||||||
|
state.error = Some("That column already exists.".to_string());
|
||||||
|
let html = render_builder(&state);
|
||||||
|
assert!(html.contains(r#"role="dialog""#));
|
||||||
|
assert!(html.contains("x-data=\"{ modalIsOpen: true }\""));
|
||||||
|
assert_eq!(html.matches("That column already exists.").count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dialog is Tailwind + Alpine, so the page has to load them; the
|
||||||
|
/// `head` block lives two levels up, in `ui/base.html`.
|
||||||
|
#[test]
|
||||||
|
fn the_page_loads_what_the_dialog_needs() {
|
||||||
|
let html = render_page(&page());
|
||||||
|
|
||||||
|
assert!(html.contains("@tailwindcss/browser@4"));
|
||||||
|
assert!(html.contains("@alpinejs/focus@3"));
|
||||||
|
assert!(html.contains("htmx:beforeSwap"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A load failure has no draft to render, so the dialog is the response.
|
||||||
|
#[test]
|
||||||
|
fn a_load_failure_answers_with_the_dialog() {
|
||||||
|
let html = render_submission_error("The backend is unreachable.");
|
||||||
|
|
||||||
|
assert!(!html.contains("Template error"), "{html}");
|
||||||
|
assert!(html.contains(r#"role="dialog""#));
|
||||||
|
assert!(html.contains("The backend is unreachable."));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_link_mode_is_shown_on_the_button_that_cycles_it() {
|
fn a_link_mode_is_shown_on_the_button_that_cycles_it() {
|
||||||
let html = render_builder(&page());
|
let html = render_builder(&page());
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ impl Default for Nav {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The swap target every form POST answers with.
|
/// The swap target every form POST answers with. An error carries the dialog
|
||||||
|
/// that puts the message in front of the user; see `ui/alert_fragment.html`.
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "ui/alert_fragment.html")]
|
#[template(path = "ui/alert_fragment.html")]
|
||||||
pub(crate) struct Alert<'a> {
|
pub(crate) struct Alert<'a> {
|
||||||
@@ -117,3 +118,24 @@ pub(crate) fn render<T: Template>(template: &T) -> String {
|
|||||||
.render()
|
.render()
|
||||||
.unwrap_or_else(|error| format!("<p class=\"error\">Template error: {error}</p>"))
|
.unwrap_or_else(|error| format!("<p class=\"error\">Template error: {error}</p>"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Every form on the site answers through `Alert`, so this is the one
|
||||||
|
/// place that decides a failure cannot go unnoticed.
|
||||||
|
#[test]
|
||||||
|
fn a_failed_form_gets_a_dialog_and_a_successful_one_does_not() {
|
||||||
|
let failure = render(&Alert::error("Could not save", "A name is required."));
|
||||||
|
assert!(!failure.contains("Template error"), "{failure}");
|
||||||
|
assert!(failure.contains(r#"role="alert""#));
|
||||||
|
assert!(failure.contains(r#"role="dialog""#));
|
||||||
|
// Once in the inline alert, once in the dialog.
|
||||||
|
assert_eq!(failure.matches("A name is required.").count(), 2);
|
||||||
|
|
||||||
|
let success = render(&Alert::success("Saved", "Two rows written."));
|
||||||
|
assert!(success.contains(r#"role="alert""#));
|
||||||
|
assert!(!success.contains(r#"role="dialog""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
/* One stylesheet for every page. Served at /static/app.css and linked from
|
/* One stylesheet for every page. Served at /static/app.css and linked from
|
||||||
templates/base.html, so no page inlines its own <style> block. */
|
templates/ui/base.html, so no page inlines its own <style> block.
|
||||||
|
|
||||||
|
Everything below sits in the `app` cascade layer, whose position is fixed by
|
||||||
|
the @layer line in ui/base.html: above Tailwind's preflight, below Tailwind's
|
||||||
|
utilities. So this file is still the site's look, and a Tailwind class on one
|
||||||
|
element still overrides it. Do not unwrap it — unlayered CSS beats every
|
||||||
|
layer, which would put this file above the utilities again. */
|
||||||
|
|
||||||
|
@layer app {
|
||||||
|
|
||||||
/* ---------- Foundations ---------- */
|
/* ---------- Foundations ---------- */
|
||||||
|
|
||||||
@@ -67,12 +75,11 @@ main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
|
|||||||
.check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 6px; }
|
.check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 6px; }
|
||||||
.check input { width: auto; }
|
.check input { width: auto; }
|
||||||
|
|
||||||
/* ---------- Alerts (components/alert.html) ---------- */
|
/* ---------- Alerts ---------- */
|
||||||
|
|
||||||
.form-error { margin-top: 16px; padding: 11px 13px; border: 1px solid #efc3bf; border-radius: 6px; color: #9d342d; background: #fff5f4; }
|
/* The alert cards themselves are Penguin UI now — see ui/alert.html. What is
|
||||||
.form-error p { margin: 3px 0 0; white-space: pre-wrap; }
|
left here is the plain coloured text that ui/notice.html and the error page
|
||||||
.form-success { margin-top: 16px; padding: 11px 13px; border: 1px solid #b9dec6; border-radius: 6px; color: #21643a; background: #f1fbf4; }
|
use. */
|
||||||
.form-success p { margin: 3px 0 0; }
|
|
||||||
.error { color: #b42318; }
|
.error { color: #b42318; }
|
||||||
.success { color: #067647; }
|
.success { color: #067647; }
|
||||||
.hint, .result-meta { color: #667385; }
|
.hint, .result-meta { color: #667385; }
|
||||||
@@ -166,3 +173,5 @@ button.danger:hover { background: #f8e5e5; }
|
|||||||
.sidebar { position: static; max-height: none; }
|
.sidebar { position: static; max-height: none; }
|
||||||
.query-row { grid-template-columns: 1fr; }
|
.query-row { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,17 @@
|
|||||||
sections are what carry the draft across requests.
|
sections are what carry the draft across requests.
|
||||||
#}
|
#}
|
||||||
{% import "ui/alert.html" as alert %}
|
{% import "ui/alert.html" as alert %}
|
||||||
|
{% import "ui/dialog.html" as dialog %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
A failure is a dialog, not an inline alert: the builder is taller than the
|
||||||
|
viewport, so an alert at the top is off screen for anyone who has scrolled
|
||||||
|
down to the column list or the Create table button. The alert stays too, as
|
||||||
|
what is left on the page after the dialog is dismissed.
|
||||||
|
#}
|
||||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
|
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
|
||||||
{%- if let Some(message) = page.status %}{% call alert::success("Draft updated", message) %}{% endcall %}{% endif -%}
|
{%- if let Some(message) = page.status %}{% call alert::success("Draft updated", message) %}{% endcall %}{% endif -%}
|
||||||
|
{%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
|
||||||
|
|
||||||
<section class="builder-section">
|
<section class="builder-section">
|
||||||
<h2>Table</h2>
|
<h2>Table</h2>
|
||||||
|
|||||||
@@ -1,13 +1,47 @@
|
|||||||
{#
|
{#
|
||||||
Inline status blocks. Imported wherever a form reports its outcome:
|
Inline status blocks — Penguin UI's "alert" component
|
||||||
|
(https://www.penguinui.com/components/alert, modern style, with icon), minus
|
||||||
|
the dark variants because the app has no dark theme. The colours come from
|
||||||
|
the @theme block in ui/base.html.
|
||||||
|
|
||||||
|
Imported wherever a form reports its outcome:
|
||||||
|
|
||||||
{% import "ui/alert.html" as alert %}
|
{% import "ui/alert.html" as alert %}
|
||||||
{% call alert::error("Could not create the table", message) %}{% endcall %}
|
{% call alert::error("Could not create the table", message) %}{% endcall %}
|
||||||
|
|
||||||
|
An error is normally paired with ui/dialog.html, which is what actually puts
|
||||||
|
the message in front of someone who has scrolled away from the top of a form.
|
||||||
#}
|
#}
|
||||||
{% macro error(title, message) %}
|
{% macro error(title, message) %}
|
||||||
<div class="form-error"><strong>{{ title }}</strong><p>{{ message }}</p></div>
|
<div class="relative mt-4 w-full overflow-hidden rounded-md border border-danger bg-surface text-onSurface" role="alert">
|
||||||
|
<div class="flex items-center gap-2 bg-danger/10 p-4">
|
||||||
|
<div class="shrink-0 rounded-full bg-danger/15 p-1 text-danger">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2">
|
||||||
|
<h3 class="text-sm font-semibold text-danger">{{ title }}</h3>
|
||||||
|
<p class="text-sm font-medium whitespace-pre-wrap">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro success(title, message) %}
|
{% macro success(title, message) %}
|
||||||
<div class="form-success"><strong>{{ title }}</strong><p>{{ message }}</p></div>
|
<div class="relative mt-4 w-full overflow-hidden rounded-md border border-success bg-surface text-onSurface" role="alert">
|
||||||
|
<div class="flex items-center gap-2 bg-success/10 p-4">
|
||||||
|
<div class="shrink-0 rounded-full bg-success/15 p-1 text-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2">
|
||||||
|
<h3 class="text-sm font-semibold text-success">{{ title }}</h3>
|
||||||
|
<p class="text-sm font-medium whitespace-pre-wrap">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
{#
|
{#
|
||||||
Standalone swap target for every form POST — crate::ui::Alert.
|
Standalone swap target for every form POST — crate::ui::Alert.
|
||||||
Rendered into the page's #submission-status / #login-status div.
|
Rendered into the page's #submission-status / #login-status div.
|
||||||
|
|
||||||
|
A success is an inline alert and nothing more. A failure also gets the
|
||||||
|
dialog: these divs sit at the bottom of their form, so on any page long
|
||||||
|
enough to scroll — the validation forms, the import form, the table builder
|
||||||
|
— the alert alone lands off screen and the page looks like it did nothing.
|
||||||
#}
|
#}
|
||||||
{% import "ui/alert.html" as alert %}
|
{% import "ui/alert.html" as alert %}
|
||||||
{%- if success %}{% call alert::success(title, message) %}{% endcall %}{% else %}{% call alert::error(title, message) %}{% endcall %}{% endif -%}
|
{% import "ui/dialog.html" as dialog %}
|
||||||
|
{%- if success -%}
|
||||||
|
{% call alert::success(title, message) %}{% endcall %}
|
||||||
|
{%- else -%}
|
||||||
|
{% call alert::error(title, message) %}{% endcall %}
|
||||||
|
{% call dialog::error(title, message) %}{% endcall %}
|
||||||
|
{%- endif -%}
|
||||||
|
|||||||
@@ -11,8 +11,57 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}Komp Accounting{% endblock %} · Komp Accounting</title>
|
<title>{% block title %}Komp Accounting{% endblock %} · Komp Accounting</title>
|
||||||
|
{#
|
||||||
|
Cascade order, declared before anything ships CSS so that it is this line
|
||||||
|
that decides it. `app` is static/app.css, which is wrapped in that layer:
|
||||||
|
it sits above Tailwind's preflight — so the reset cannot touch the site's
|
||||||
|
own look — and below Tailwind's utilities, so a utility class put on an
|
||||||
|
element still wins. That is what makes Tailwind usable here one element at
|
||||||
|
a time, without converting the stylesheet.
|
||||||
|
#}
|
||||||
|
<style>@layer theme, base, app, components, utilities;</style>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
||||||
|
|
||||||
|
{#
|
||||||
|
Penguin UI (https://www.penguinui.com) is Tailwind + Alpine, and its
|
||||||
|
components are what this app uses for anything the user has to be told:
|
||||||
|
ui/alert.html and ui/dialog.html. The focus plugin is what x-trap in the
|
||||||
|
dialog needs, and it has to load before Alpine itself.
|
||||||
|
#}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<style type="text/tailwindcss">
|
||||||
|
/* Penguin UI's "modern" theme setup: its semantic names, this app's palette. */
|
||||||
|
@theme {
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surfaceAlt: #f3f5f7;
|
||||||
|
--color-surfaceDark: #152238;
|
||||||
|
--color-onSurface: #465267;
|
||||||
|
--color-onSurfaceStrong: #17202a;
|
||||||
|
--color-outline: #d9dfe7;
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-onPrimary: #ffffff;
|
||||||
|
--color-danger: #9d342d;
|
||||||
|
--color-success: #21643a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/focus@3/dist/cdn.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// htmx only swaps 2xx responses. Every form in this app answers a failure
|
||||||
|
// with a status that says so — 422 for a rejected draft, 403 for a lost
|
||||||
|
// session, 502 for an unreachable backend — so the markup explaining the
|
||||||
|
// failure was rendered, returned, and then thrown away, and the page did
|
||||||
|
// nothing at all. Swap it instead; the response body is the explanation.
|
||||||
|
document.addEventListener('htmx:beforeSwap', function (event) {
|
||||||
|
if (event.detail.xhr.status >= 400) {
|
||||||
|
event.detail.shouldSwap = true;
|
||||||
|
event.detail.isError = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
54
web/templates/ui/dialog.html
Normal file
54
web/templates/ui/dialog.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{#
|
||||||
|
Blocking modal dialog — Penguin UI's "modal" component
|
||||||
|
(https://www.penguinui.com/components/modal, modern style), with the dark
|
||||||
|
variants dropped because the app has no dark theme.
|
||||||
|
|
||||||
|
It is Tailwind + Alpine markup, so a page that calls this macro must load
|
||||||
|
both in its `head` block; `pages/add_table/add_table.html` shows the set.
|
||||||
|
The `@theme` block there maps Penguin UI's semantic colour names onto the
|
||||||
|
palette in `static/app.css`.
|
||||||
|
|
||||||
|
Rendered already open (`modalIsOpen: true`): the server only sends the
|
||||||
|
dialog when there is something the user has to be told, so there is no
|
||||||
|
trigger button — the response itself is the trigger.
|
||||||
|
|
||||||
|
{% import "ui/dialog.html" as dialog %}
|
||||||
|
{% call dialog::error("Could not add the column", message) %}{% endcall %}
|
||||||
|
#}
|
||||||
|
{% macro error(title, message) %}
|
||||||
|
<div x-data="{ modalIsOpen: true }">
|
||||||
|
<div x-cloak x-show="modalIsOpen" x-transition.opacity.duration.200ms
|
||||||
|
x-trap.inert.noscroll="modalIsOpen"
|
||||||
|
x-on:keydown.esc.window="modalIsOpen = false" x-on:click.self="modalIsOpen = false"
|
||||||
|
class="fixed inset-0 z-30 flex items-end justify-center bg-surfaceDark/20 p-4 pb-8 backdrop-blur-xs sm:items-center lg:p-8"
|
||||||
|
role="dialog" aria-modal="true" aria-labelledby="dialogTitle">
|
||||||
|
<!-- Modal Dialog -->
|
||||||
|
<div x-show="modalIsOpen"
|
||||||
|
x-transition:enter="transition delay-100 duration-200 ease-out motion-reduce:transition-opacity"
|
||||||
|
x-transition:enter-start="scale-50 opacity-0" x-transition:enter-end="scale-100 opacity-100"
|
||||||
|
class="flex max-w-lg flex-col gap-4 overflow-hidden rounded-lg border border-outline bg-surface text-onSurface">
|
||||||
|
<!-- Dialog Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-outline bg-surfaceAlt/60 p-4">
|
||||||
|
<h3 id="dialogTitle" class="font-semibold tracking-wide text-danger">{{ title }}</h3>
|
||||||
|
<button type="button" x-on:click="modalIsOpen = false" class="cursor-pointer text-onSurface" aria-label="close dialog">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor"
|
||||||
|
fill="none" stroke-width="1.4" class="h-5 w-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Dialog Body -->
|
||||||
|
<div class="px-4 py-4">
|
||||||
|
<p class="text-pretty whitespace-pre-wrap">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
<!-- Dialog Footer -->
|
||||||
|
<div class="flex flex-col-reverse justify-between gap-2 border-t border-outline bg-surfaceAlt/60 p-4 sm:flex-row sm:items-center md:justify-end">
|
||||||
|
<button type="button" x-on:click="modalIsOpen = false"
|
||||||
|
class="cursor-pointer rounded-md whitespace-nowrap bg-primary px-4 py-2 text-center text-sm font-medium tracking-wide text-onPrimary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0">
|
||||||
|
Back to the form
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
Reference in New Issue
Block a user