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,168 +1,177 @@
|
|||||||
/* 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.
|
||||||
|
|
||||||
/* ---------- Foundations ---------- */
|
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. */
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
@layer app {
|
||||||
:root { color: #17202a; background: #f3f5f7; font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif; }
|
|
||||||
body { margin: 0; }
|
|
||||||
[x-cloak] { display: none !important; }
|
|
||||||
button, input, select, textarea { font: inherit; }
|
|
||||||
select, input, textarea { width: 100%; padding: 9px 10px; border: 1px solid #cbd3dd; border-radius: 6px; color: #1e2938; background: white; }
|
|
||||||
textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; }
|
|
||||||
main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
|
|
||||||
|
|
||||||
/* ---------- Navbar (components/navbar.html) ---------- */
|
/* ---------- Foundations ---------- */
|
||||||
|
|
||||||
.topbar { min-height: 58px; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; color: #eef4ff; background: #152238; }
|
* { box-sizing: border-box; }
|
||||||
.topbar > div, .topbar nav { display: flex; align-items: center; gap: 18px; }
|
:root { color: #17202a; background: #f3f5f7; font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif; }
|
||||||
.topbar a, .link-button { color: #cbd7e8; text-decoration: none; background: transparent; border: 0; cursor: pointer; padding: 8px 2px; width: auto; }
|
body { margin: 0; }
|
||||||
.topbar a:hover, .link-button:hover, .topbar a.active { color: white; }
|
[x-cloak] { display: none !important; }
|
||||||
.topbar a.active { border-bottom: 2px solid #68a4ff; }
|
button, input, select, textarea { font: inherit; }
|
||||||
.topbar form { margin: 0; }
|
select, input, textarea { width: 100%; padding: 9px 10px; border: 1px solid #cbd3dd; border-radius: 6px; color: #1e2938; background: white; }
|
||||||
.role { color: #9fb0c8; font-size: 12px; }
|
textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; }
|
||||||
|
main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
|
||||||
|
|
||||||
/* ---------- Page heading (components/heading.html) ---------- */
|
/* ---------- Navbar (components/navbar.html) ---------- */
|
||||||
|
|
||||||
.heading { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 22px; }
|
.topbar { min-height: 58px; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; color: #eef4ff; background: #152238; }
|
||||||
.heading h1 { margin: 0; font-size: 28px; }
|
.topbar > div, .topbar nav { display: flex; align-items: center; gap: 18px; }
|
||||||
.heading p { margin: 5px 0 0; color: #687384; }
|
.topbar a, .link-button { color: #cbd7e8; text-decoration: none; background: transparent; border: 0; cursor: pointer; padding: 8px 2px; width: auto; }
|
||||||
.eyebrow { color: #2563eb !important; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
.topbar a:hover, .link-button:hover, .topbar a.active { color: white; }
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
.topbar a.active { border-bottom: 2px solid #68a4ff; }
|
||||||
.actions a, .actions span { padding: 8px 12px; border: 1px solid #cfd7e1; border-radius: 7px; color: #283548; background: white; text-decoration: none; }
|
.topbar form { margin: 0; }
|
||||||
|
.role { color: #9fb0c8; font-size: 12px; }
|
||||||
|
|
||||||
/* ---------- Admin three-pane browser ---------- */
|
/* ---------- Page heading (components/heading.html) ---------- */
|
||||||
|
|
||||||
.workspace { display: grid; grid-template-columns: minmax(210px, .75fr) minmax(280px, 1.15fr) minmax(300px, 1.25fr); min-height: 560px; border: 1px solid #d9dfe7; border-radius: 11px; overflow: hidden; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
.heading { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 22px; }
|
||||||
.pane + .pane { border-left: 1px solid #e1e5eb; }
|
.heading h1 { margin: 0; font-size: 28px; }
|
||||||
.pane-title { height: 54px; padding: 0 17px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5e8ed; background: #fafbfc; }
|
.heading p { margin: 5px 0 0; color: #687384; }
|
||||||
.pane-title h2 { margin: 0; font-size: 14px; }
|
.eyebrow { color: #2563eb !important; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
.pane-title span { color: #7c8796; font-size: 12px; }
|
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.pane-list { padding: 10px; }
|
.actions a, .actions span { padding: 8px 12px; border: 1px solid #cfd7e1; border-radius: 7px; color: #283548; background: white; text-decoration: none; }
|
||||||
.pane-list form { margin: 0; }
|
|
||||||
.browser-item { width: 100%; display: grid; gap: 3px; padding: 11px 12px; text-align: left; border: 1px solid transparent; border-radius: 7px; color: #253246; background: transparent; cursor: pointer; }
|
|
||||||
.browser-item:hover { background: #f2f6fc; }
|
|
||||||
.browser-item.selected { border-color: #a9c7f6; background: #eaf2ff; }
|
|
||||||
.browser-item small, .column small { color: #788495; }
|
|
||||||
.column { display: grid; grid-template-columns: minmax(100px, 1fr) auto; gap: 3px 12px; padding: 10px 11px; border-bottom: 1px solid #edf0f3; }
|
|
||||||
.column code { color: #315078; font-size: 12px; }
|
|
||||||
.column small { grid-column: 1 / -1; }
|
|
||||||
.empty { margin: 8px; color: #7c8796; }
|
|
||||||
|
|
||||||
/* ---------- Forms (components/form_card.html) ---------- */
|
/* ---------- Admin three-pane browser ---------- */
|
||||||
|
|
||||||
.form-main { width: min(900px, calc(100% - 32px)); }
|
.workspace { display: grid; grid-template-columns: minmax(210px, .75fr) minmax(280px, 1.15fr) minmax(300px, 1.25fr); min-height: 560px; border: 1px solid #d9dfe7; border-radius: 11px; overflow: hidden; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
||||||
.back-link { display: inline-block; margin-bottom: 14px; color: #315f9d; text-decoration: none; }
|
.pane + .pane { border-left: 1px solid #e1e5eb; }
|
||||||
.form-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
.pane-title { height: 54px; padding: 0 17px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5e8ed; background: #fafbfc; }
|
||||||
.form-card h1 { margin: 0; }
|
.pane-title h2 { margin: 0; font-size: 14px; }
|
||||||
.form-card > p { color: #687384; }
|
.pane-title span { color: #7c8796; font-size: 12px; }
|
||||||
.form-grid { margin-top: 22px; display: grid; grid-template-columns: 1fr 1fr; gap: 17px; }
|
.pane-list { padding: 10px; }
|
||||||
.form-grid label { display: grid; align-content: start; gap: 6px; color: #465267; font-size: 12px; }
|
.pane-list form { margin: 0; }
|
||||||
.form-grid label.wide { grid-column: 1 / -1; }
|
.browser-item { width: 100%; display: grid; gap: 3px; padding: 11px 12px; text-align: left; border: 1px solid transparent; border-radius: 7px; color: #253246; background: transparent; cursor: pointer; }
|
||||||
.form-grid small { color: #7c8796; }
|
.browser-item:hover { background: #f2f6fc; }
|
||||||
.form-actions { margin-top: 20px; display: flex; justify-content: end; align-items: center; gap: 14px; }
|
.browser-item.selected { border-color: #a9c7f6; background: #eaf2ff; }
|
||||||
.form-actions a { color: #59677a; }
|
.browser-item small, .column small { color: #788495; }
|
||||||
.form-actions button { padding: 9px 16px; border: 0; border-radius: 6px; color: white; background: #2563eb; cursor: pointer; }
|
.column { display: grid; grid-template-columns: minmax(100px, 1fr) auto; gap: 3px 12px; padding: 10px 11px; border-bottom: 1px solid #edf0f3; }
|
||||||
.check-group { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 12px 20px; }
|
.column code { color: #315078; font-size: 12px; }
|
||||||
.check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 6px; }
|
.column small { grid-column: 1 / -1; }
|
||||||
.check input { width: auto; }
|
.empty { margin: 8px; color: #7c8796; }
|
||||||
|
|
||||||
/* ---------- Alerts (components/alert.html) ---------- */
|
/* ---------- Forms (components/form_card.html) ---------- */
|
||||||
|
|
||||||
.form-error { margin-top: 16px; padding: 11px 13px; border: 1px solid #efc3bf; border-radius: 6px; color: #9d342d; background: #fff5f4; }
|
.form-main { width: min(900px, calc(100% - 32px)); }
|
||||||
.form-error p { margin: 3px 0 0; white-space: pre-wrap; }
|
.back-link { display: inline-block; margin-bottom: 14px; color: #315f9d; text-decoration: none; }
|
||||||
.form-success { margin-top: 16px; padding: 11px 13px; border: 1px solid #b9dec6; border-radius: 6px; color: #21643a; background: #f1fbf4; }
|
.form-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
||||||
.form-success p { margin: 3px 0 0; }
|
.form-card h1 { margin: 0; }
|
||||||
.error { color: #b42318; }
|
.form-card > p { color: #687384; }
|
||||||
.success { color: #067647; }
|
.form-grid { margin-top: 22px; display: grid; grid-template-columns: 1fr 1fr; gap: 17px; }
|
||||||
.hint, .result-meta { color: #667385; }
|
.form-grid label { display: grid; align-content: start; gap: 6px; color: #465267; font-size: 12px; }
|
||||||
.error-page { width: min(600px, calc(100% - 32px)); margin: 15vh auto; padding: 24px; border: 1px solid #f1c4c0; border-radius: 10px; background: white; }
|
.form-grid label.wide { grid-column: 1 / -1; }
|
||||||
.error-page h1 { margin-top: 0; }
|
.form-grid small { color: #7c8796; }
|
||||||
.error-page p { color: #a33a31; }
|
.form-actions { margin-top: 20px; display: flex; justify-content: end; align-items: center; gap: 14px; }
|
||||||
|
.form-actions a { color: #59677a; }
|
||||||
|
.form-actions button { padding: 9px 16px; border: 0; border-radius: 6px; color: white; background: #2563eb; cursor: pointer; }
|
||||||
|
.check-group { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 12px 20px; }
|
||||||
|
.check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 6px; }
|
||||||
|
.check input { width: auto; }
|
||||||
|
|
||||||
/* ---------- Login (pages/login.html) ---------- */
|
/* ---------- Alerts ---------- */
|
||||||
|
|
||||||
.login-main { width: min(420px, calc(100% - 32px)); margin: 12vh auto; }
|
/* The alert cards themselves are Penguin UI now — see ui/alert.html. What is
|
||||||
.login-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
left here is the plain coloured text that ui/notice.html and the error page
|
||||||
.login-card h1 { margin: 0 0 18px; font-size: 24px; }
|
use. */
|
||||||
.login-card label { display: grid; gap: 5px; margin-bottom: 14px; color: #465267; font-size: 12px; }
|
.error { color: #b42318; }
|
||||||
.login-card button { width: 100%; border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
|
.success { color: #067647; }
|
||||||
.login-card button:disabled, .login-card button.htmx-request { opacity: .55; cursor: wait; }
|
.hint, .result-meta { color: #667385; }
|
||||||
|
.error-page { width: min(600px, calc(100% - 32px)); margin: 15vh auto; padding: 24px; border: 1px solid #f1c4c0; border-radius: 10px; background: white; }
|
||||||
|
.error-page h1 { margin-top: 0; }
|
||||||
|
.error-page p { color: #a33a31; }
|
||||||
|
|
||||||
/* ---------- Analytics (pages/analytics.html) ---------- */
|
/* ---------- Login (pages/login.html) ---------- */
|
||||||
|
|
||||||
.analytics { display: grid; grid-template-columns: minmax(290px, 380px) minmax(0, 1fr); align-items: start; gap: 16px; }
|
.login-main { width: min(420px, calc(100% - 32px)); margin: 12vh auto; }
|
||||||
.panel { margin-bottom: 16px; padding: 18px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
|
.login-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
||||||
.panel h2 { margin: 0 0 14px; font-size: 17px; }
|
.login-card h1 { margin: 0 0 18px; font-size: 24px; }
|
||||||
.sidebar { position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
|
.login-card label { display: grid; gap: 5px; margin-bottom: 14px; color: #465267; font-size: 12px; }
|
||||||
.sidebar h2 { margin-bottom: 6px; }
|
.login-card button { width: 100%; border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
|
||||||
.catalog-load { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; margin: 14px 0; }
|
.login-card button:disabled, .login-card button.htmx-request { opacity: .55; cursor: wait; }
|
||||||
.catalog-load label { margin: 0; }
|
|
||||||
.schema-summary { margin: 8px 0; }
|
|
||||||
.catalog-table { border-top: 1px solid #e4e7ec; padding: 9px 0; }
|
|
||||||
.catalog-table summary { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
|
||||||
.currency { color: #667385; font-size: 11px; }
|
|
||||||
.starter-query, .secondary { margin-top: 9px; padding: 6px 9px; width: auto; border: 0; border-radius: 6px; color: #344054; background: #eef2f6; cursor: pointer; }
|
|
||||||
.columns, .links ul { margin: 8px 0; padding: 0; list-style: none; }
|
|
||||||
.columns li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; }
|
|
||||||
.columns li span { color: #667385; font-size: 11px; text-align: right; }
|
|
||||||
.column-name { width: auto; padding: 0; border: 0; color: #2563eb; background: none; font-family: ui-monospace, monospace; text-align: left; cursor: pointer; }
|
|
||||||
.links { color: #667385; font-size: 12px; }
|
|
||||||
.links li { padding: 2px 0; }
|
|
||||||
.llm-context { border-top: 1px solid #e4e7ec; margin-top: 10px; padding-top: 10px; }
|
|
||||||
.llm-context summary { cursor: pointer; font-weight: 600; }
|
|
||||||
.llm-context textarea { min-height: 320px; margin-top: 8px; font-size: 11px; }
|
|
||||||
.query-row { display: grid; grid-template-columns: 180px 120px; gap: 12px; margin-bottom: 12px; }
|
|
||||||
.query-form label { display: grid; gap: 5px; color: #465267; font-size: 12px; }
|
|
||||||
.query-form textarea { min-height: 130px; }
|
|
||||||
.query-form .actions { margin-top: 12px; align-items: center; gap: 12px; }
|
|
||||||
.query-form button { border: 0; border-radius: 6px; padding: 10px 18px; width: auto; color: white; background: #2563eb; cursor: pointer; }
|
|
||||||
.query-form button:disabled, .htmx-request button, button.htmx-request { opacity: .55; cursor: wait; }
|
|
||||||
.chart { height: 560px; }
|
|
||||||
.table-wrap { max-height: 560px; overflow: auto; }
|
|
||||||
table { width: 100%; border-collapse: collapse; background: white; }
|
|
||||||
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
|
|
||||||
th { position: sticky; top: 0; background: #f9fafb; }
|
|
||||||
|
|
||||||
/* ---------- Table builder (Add table) ---------- */
|
/* ---------- Analytics (pages/analytics.html) ---------- */
|
||||||
|
|
||||||
.builder-section { margin-top: 26px; padding-top: 20px; border-top: 1px solid #e8ebf0; }
|
.analytics { display: grid; grid-template-columns: minmax(290px, 380px) minmax(0, 1fr); align-items: start; gap: 16px; }
|
||||||
.builder-section:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; }
|
.panel { margin-bottom: 16px; padding: 18px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
|
||||||
.builder-section h2 { margin: 0; font-size: 15px; color: #33415c; }
|
.panel h2 { margin: 0 0 14px; font-size: 17px; }
|
||||||
.builder-section > .hint { margin: 8px 0 0; font-size: 13px; }
|
.sidebar { position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
|
||||||
.builder-section .count { margin-left: 6px; padding: 1px 7px; border-radius: 9px; font-size: 12px; color: #4b5563; background: #eef1f6; }
|
.sidebar h2 { margin-bottom: 6px; }
|
||||||
.builder-section > button.secondary { margin-top: 16px; padding: 9px 16px; border: 1px solid #c9d2de; border-radius: 6px; color: #24324a; background: #f4f6fa; cursor: pointer; }
|
.catalog-load { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; margin: 14px 0; }
|
||||||
.builder-section > button.secondary:hover { background: #e9edf4; }
|
.catalog-load label { margin: 0; }
|
||||||
|
.schema-summary { margin: 8px 0; }
|
||||||
|
.catalog-table { border-top: 1px solid #e4e7ec; padding: 9px 0; }
|
||||||
|
.catalog-table summary { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||||
|
.currency { color: #667385; font-size: 11px; }
|
||||||
|
.starter-query, .secondary { margin-top: 9px; padding: 6px 9px; width: auto; border: 0; border-radius: 6px; color: #344054; background: #eef2f6; cursor: pointer; }
|
||||||
|
.columns, .links ul { margin: 8px 0; padding: 0; list-style: none; }
|
||||||
|
.columns li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; }
|
||||||
|
.columns li span { color: #667385; font-size: 11px; text-align: right; }
|
||||||
|
.column-name { width: auto; padding: 0; border: 0; color: #2563eb; background: none; font-family: ui-monospace, monospace; text-align: left; cursor: pointer; }
|
||||||
|
.links { color: #667385; font-size: 12px; }
|
||||||
|
.links li { padding: 2px 0; }
|
||||||
|
.llm-context { border-top: 1px solid #e4e7ec; margin-top: 10px; padding-top: 10px; }
|
||||||
|
.llm-context summary { cursor: pointer; font-weight: 600; }
|
||||||
|
.llm-context textarea { min-height: 320px; margin-top: 8px; font-size: 11px; }
|
||||||
|
.query-row { display: grid; grid-template-columns: 180px 120px; gap: 12px; margin-bottom: 12px; }
|
||||||
|
.query-form label { display: grid; gap: 5px; color: #465267; font-size: 12px; }
|
||||||
|
.query-form textarea { min-height: 130px; }
|
||||||
|
.query-form .actions { margin-top: 12px; align-items: center; gap: 12px; }
|
||||||
|
.query-form button { border: 0; border-radius: 6px; padding: 10px 18px; width: auto; color: white; background: #2563eb; cursor: pointer; }
|
||||||
|
.query-form button:disabled, .htmx-request button, button.htmx-request { opacity: .55; cursor: wait; }
|
||||||
|
.chart { height: 560px; }
|
||||||
|
.table-wrap { max-height: 560px; overflow: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: white; }
|
||||||
|
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
|
||||||
|
th { position: sticky; top: 0; background: #f9fafb; }
|
||||||
|
|
||||||
.builder-table { margin-top: 14px; }
|
/* ---------- Table builder (Add table) ---------- */
|
||||||
.builder-table th { position: static; font-size: 12px; color: #5b6678; }
|
|
||||||
.builder-table td { font-size: 13px; }
|
|
||||||
.builder-table .mark { width: 34px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #4a5568; }
|
|
||||||
.builder-table.preview .source-system td { color: #7a8496; background: #fbfcfd; }
|
|
||||||
.builder-table.preview .source-relation td { color: #3c5a86; }
|
|
||||||
|
|
||||||
.builder-list { margin: 12px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
|
.builder-section { margin-top: 26px; padding-top: 20px; border-top: 1px solid #e8ebf0; }
|
||||||
.builder-list li { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid #e1e6ee; border-radius: 7px; background: white; }
|
.builder-section:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; }
|
||||||
|
.builder-section h2 { margin: 0; font-size: 15px; color: #33415c; }
|
||||||
|
.builder-section > .hint { margin: 8px 0 0; font-size: 13px; }
|
||||||
|
.builder-section .count { margin-left: 6px; padding: 1px 7px; border-radius: 9px; font-size: 12px; color: #4b5563; background: #eef1f6; }
|
||||||
|
.builder-section > button.secondary { margin-top: 16px; padding: 9px 16px; border: 1px solid #c9d2de; border-radius: 6px; color: #24324a; background: #f4f6fa; cursor: pointer; }
|
||||||
|
.builder-section > button.secondary:hover { background: #e9edf4; }
|
||||||
|
|
||||||
button.toggle { min-width: 34px; padding: 3px 7px; border: 1px solid #cdd5e0; border-radius: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #24324a; background: #f6f8fb; cursor: pointer; }
|
.builder-table { margin-top: 14px; }
|
||||||
button.toggle:hover { background: #e9edf4; }
|
.builder-table th { position: static; font-size: 12px; color: #5b6678; }
|
||||||
button.toggle.mode-optional { color: #1d4ed8; border-color: #bfd0f5; background: #eef3fe; }
|
.builder-table td { font-size: 13px; }
|
||||||
button.toggle.mode-required { color: #a1410a; border-color: #f0cfae; background: #fdf3e9; }
|
.builder-table .mark { width: 34px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #4a5568; }
|
||||||
button.danger { padding: 4px 10px; border: 1px solid #eccfcf; border-radius: 5px; font-size: 12px; color: #a12b2b; background: #fdf3f3; cursor: pointer; }
|
.builder-table.preview .source-system td { color: #7a8496; background: #fbfcfd; }
|
||||||
button.danger:hover { background: #f8e5e5; }
|
.builder-table.preview .source-relation td { color: #3c5a86; }
|
||||||
|
|
||||||
.tag { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11px; color: #4b5563; background: #eef1f6; }
|
.builder-list { margin: 12px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.tag + .tag { margin-left: 4px; }
|
.builder-list li { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid #e1e6ee; border-radius: 7px; background: white; }
|
||||||
|
|
||||||
/* ---------- Narrow screens ---------- */
|
button.toggle { min-width: 34px; padding: 3px 7px; border: 1px solid #cdd5e0; border-radius: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #24324a; background: #f6f8fb; cursor: pointer; }
|
||||||
|
button.toggle:hover { background: #e9edf4; }
|
||||||
|
button.toggle.mode-optional { color: #1d4ed8; border-color: #bfd0f5; background: #eef3fe; }
|
||||||
|
button.toggle.mode-required { color: #a1410a; border-color: #f0cfae; background: #fdf3e9; }
|
||||||
|
button.danger { padding: 4px 10px; border: 1px solid #eccfcf; border-radius: 5px; font-size: 12px; color: #a12b2b; background: #fdf3f3; cursor: pointer; }
|
||||||
|
button.danger:hover { background: #f8e5e5; }
|
||||||
|
|
||||||
|
.tag { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11px; color: #4b5563; background: #eef1f6; }
|
||||||
|
.tag + .tag { margin-left: 4px; }
|
||||||
|
|
||||||
|
/* ---------- Narrow screens ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 850px) {
|
||||||
|
.heading { align-items: start; flex-direction: column; }
|
||||||
|
.workspace { grid-template-columns: 1fr; }
|
||||||
|
.pane + .pane { border-left: 0; border-top: 1px solid #e1e5eb; }
|
||||||
|
.topbar { padding: 10px 16px; align-items: start; gap: 10px; }
|
||||||
|
.topbar, .topbar nav { flex-wrap: wrap; }
|
||||||
|
.analytics { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { position: static; max-height: none; }
|
||||||
|
.query-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 850px) {
|
|
||||||
.heading { align-items: start; flex-direction: column; }
|
|
||||||
.workspace { grid-template-columns: 1fr; }
|
|
||||||
.pane + .pane { border-left: 0; border-top: 1px solid #e1e5eb; }
|
|
||||||
.topbar { padding: 10px 16px; align-items: start; gap: 10px; }
|
|
||||||
.topbar, .topbar nav { flex-wrap: wrap; }
|
|
||||||
.analytics { grid-template-columns: 1fr; }
|
|
||||||
.sidebar { position: static; max-height: none; }
|
|
||||||
.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