From 0fe253a127fd4853d7f690eb820b69ba0cff74a7 Mon Sep 17 00:00:00 2001 From: Per Stark Date: Tue, 28 Jan 2025 11:51:45 +0100 Subject: [PATCH] feat: displaying and managing active jobs --- assets/style.css | 284 +++++++++++++++++- src/bin/server.rs | 10 +- src/ingress/jobqueue.rs | 25 ++ src/server/routes/html/index.rs | 114 ++++++- src/server/routes/html/ingress_form.rs | 13 + src/server/routes/html/ingress_tasks.rs | 60 ---- src/server/routes/html/mod.rs | 1 - templates/index/signed_in/active_jobs.html | 46 +++ templates/index/signed_in/base.html | 9 +- templates/index/signed_in/quick_actions.html | 2 +- templates/index/signed_in/recent_content.html | 69 +++-- templates/ingress_form.html | 8 +- templates/queue_tasks.html | 62 ---- 13 files changed, 522 insertions(+), 181 deletions(-) delete mode 100644 src/server/routes/html/ingress_tasks.rs create mode 100644 templates/index/signed_in/active_jobs.html delete mode 100644 templates/queue_tasks.html diff --git a/assets/style.css b/assets/style.css index 4bff654..ca64e68 100644 --- a/assets/style.css +++ b/assets/style.css @@ -828,6 +828,69 @@ } } } + .tab { + position: relative; + display: inline-flex; + cursor: pointer; + appearance: none; + flex-wrap: wrap; + align-items: center; + justify-content: center; + text-align: center; + webkit-user-select: none; + user-select: none; + &:hover { + @media (hover: hover) { + color: var(--color-base-content); + } + } + --tab-p: 1rem; + --tab-bg: var(--color-base-100); + --tab-border-color: var(--color-base-300); + --tab-radius-ss: 0; + --tab-radius-se: 0; + --tab-radius-es: 0; + --tab-radius-ee: 0; + --tab-order: 0; + --tab-radius-min: calc(0.75rem - var(--border)); + border-color: transparent; + order: var(--tab-order); + height: calc(var(--size-field, 0.25rem) * 10); + font-size: 0.875rem; + padding-inline-start: var(--tab-p); + padding-inline-end: var(--tab-p); + &:is(input[type="radio"]) { + min-width: fit-content; + &:after { + content: attr(aria-label); + } + } + &:checked, &:is(.tab-active, [aria-selected="true"]) { + & + .tab-content { + display: block; + height: 100%; + } + } + &:not(:checked, :hover, .tab-active, [aria-selected="true"]) { + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); + } + &:not(input):empty { + flex-grow: 1; + cursor: default; + } + &:focus { + outline: 2px solid transparent; + outline-offset: 2px; + } + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: -5px; + } + &[disabled] { + pointer-events: none; + opacity: 40%; + } + } .dropdown { position: relative; display: inline-block; @@ -2343,6 +2406,25 @@ } } } + .tab-content { + order { + } + order: var(--tabcontent-order); + display: none; + border-color: transparent; + --tabcontent-radius-ss: 0; + --tabcontent-radius-se: 0; + --tabcontent-radius-es: 0; + --tabcontent-radius-ee: 0; + --tabcontent-order: 1; + width: 100%; + margin: var(--tabcontent-margin); + border-width: var(--border); + border-start-start-radius: var(--tabcontent-radius-ss); + border-start-end-radius: var(--tabcontent-radius-se); + border-end-start-radius: var(--tabcontent-radius-es); + border-end-end-radius: var(--tabcontent-radius-ee); + } .modal-box { grid-column-start: 1; grid-row-start: 1; @@ -2390,6 +2472,11 @@ font-size: 2rem; font-weight: 800; } + .drawer-content { + grid-column-start: 2; + grid-row-start: 1; + min-width: calc(0.25rem * 0); + } .chat-image { grid-row: span 2 / span 2; align-self: flex-end; @@ -2427,6 +2514,15 @@ max-width: 96rem; } } + .m-0 { + margin: calc(var(--spacing) * 0); + } + .m-6 { + margin: calc(var(--spacing) * 6); + } + .m-8 { + margin: calc(var(--spacing) * 8); + } .filter { display: flex; flex-wrap: wrap; @@ -3026,6 +3122,9 @@ .mt-8 { margin-top: calc(var(--spacing) * 8); } + .mt-10 { + margin-top: calc(var(--spacing) * 10); + } .mr-1 { margin-right: calc(var(--spacing) * 1); } @@ -3048,6 +3147,9 @@ .mb-8 { margin-bottom: calc(var(--spacing) * 8); } + .mb-10 { + margin-bottom: calc(var(--spacing) * 10); + } .alert { display: grid; width: 100%; @@ -3319,6 +3421,13 @@ column-gap: calc(0.25rem * 3); padding-block: calc(0.25rem * 1); } + .mask { + display: inline-block; + vertical-align: middle; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + } .block { display: block; } @@ -3352,6 +3461,27 @@ .table { display: table; } + .btn-square { + padding-inline: calc(0.25rem * 0); + width: var(--size); + height: var(--size); + } + .size-6 { + width: calc(var(--spacing) * 6); + height: calc(var(--spacing) * 6); + } + .size-8 { + width: calc(var(--spacing) * 8); + height: calc(var(--spacing) * 8); + } + .size-10 { + width: calc(var(--spacing) * 10); + height: calc(var(--spacing) * 10); + } + .size-\[1\.2em\] { + width: 1.2em; + height: 1.2em; + } .h-5 { height: calc(var(--spacing) * 5); } @@ -3367,12 +3497,6 @@ .w-5 { width: calc(var(--spacing) * 5); } - .w-12 { - width: calc(var(--spacing) * 12); - } - .w-24 { - width: calc(var(--spacing) * 24); - } .w-32 { width: calc(var(--spacing) * 32); } @@ -3388,6 +3512,9 @@ .flex-shrink { flex-shrink: 1; } + .shrink { + flex-shrink: 1; + } .flex-grow { flex-grow: 1; } @@ -3463,15 +3590,24 @@ .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } + .\!flex-col { + flex-direction: column !important; + } .flex-col { flex-direction: column; } + .flex-row { + flex-direction: row; + } .flex-wrap { flex-wrap: wrap; } .items-center { align-items: center; } + .justify-between { + justify-content: space-between; + } .justify-center { justify-content: center; } @@ -3481,6 +3617,9 @@ .justify-start { justify-content: flex-start; } + .gap-1 { + gap: calc(var(--spacing) * 1); + } .gap-4 { gap: calc(var(--spacing) * 4); } @@ -3546,12 +3685,21 @@ .border-transparent { border-color: transparent; } + .bg-accent { + background-color: var(--color-accent); + } .bg-base-100 { background-color: var(--color-base-100); } .bg-base-200 { background-color: var(--color-base-200); } + .bg-primary { + background-color: var(--color-primary); + } + .bg-secondary { + background-color: var(--color-secondary); + } .bg-linear-to-r { --tw-gradient-position: to right in oklab,; background-image: linear-gradient(var(--tw-gradient-stops)); @@ -3619,6 +3767,9 @@ .pt-10 { padding-top: calc(var(--spacing) * 10); } + .pb-2 { + padding-bottom: calc(var(--spacing) * 2); + } .text-center { text-align: center; } @@ -3672,6 +3823,10 @@ --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); } + .tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); + } .text-nowrap { text-wrap: nowrap; } @@ -3693,6 +3848,9 @@ .text-accent { color: var(--color-accent); } + .text-accent-content { + color: var(--color-accent-content); + } .text-base-content { color: var(--color-base-content); } @@ -3717,6 +3875,9 @@ .text-secondary { color: var(--color-secondary); } + .text-secondary-content { + color: var(--color-secondary-content); + } .text-transparent { color: transparent; } @@ -3729,6 +3890,10 @@ .italic { font-style: italic; } + .ordinal { + --tw-ordinal: ordinal; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } .btn-link { text-decoration-line: underline; outline-color: currentColor; @@ -3746,6 +3911,9 @@ .underline { text-decoration-line: underline; } + .accent-accent-content { + accent-color: var(--color-accent-content); + } .opacity-60 { opacity: 60%; } @@ -3757,10 +3925,6 @@ opacity: 100%; } } - .ring { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -3866,6 +4030,11 @@ margin-top: calc(var(--spacing) * 4); } } + .sm\:w-fit { + @media (width >= 40rem) { + width: fit-content; + } + } .sm\:max-w-md { @media (width >= 40rem) { max-width: var(--container-md); @@ -3876,6 +4045,16 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } + .sm\:flex-col { + @media (width >= 40rem) { + flex-direction: column; + } + } + .sm\:flex-row { + @media (width >= 40rem) { + flex-direction: row; + } + } .sm\:px-0 { @media (width >= 40rem) { padding-inline: calc(var(--spacing) * 0); @@ -3887,6 +4066,11 @@ line-height: var(--tw-leading, var(--text-6xl--line-height)); } } + .md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } .md\:grid-cols-3 { @media (width >= 48rem) { grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -3907,6 +4091,50 @@ grid-template-columns: auto 1fr; } } + .\[\&\:before\]\:normal-case { + &:before { + text-transform: none; + } + } + .\[\&\:before\]\:uppercase { + &:before { + text-transform: uppercase; + } + } + .\[\&\:before\]\:opacity-60 { + &:before { + opacity: 60%; + } + } + .\[\&\:before\]\:opacity-100 { + &:before { + opacity: 100%; + } + } + .\[\&\:before\]\:content-\[\'Content\:_\'\] { + &:before { + --tw-content: 'Content: '; + content: var(--tw-content); + } + } + .\[\&\:before\]\:content-\[\'Instructions\:_\'\] { + &:before { + --tw-content: 'Instructions: '; + content: var(--tw-content); + } + } + .\[\&\:before\]\:content-\[\'Status\:_\'\] { + &:before { + --tw-content: 'Status: '; + content: var(--tw-content); + } + } + .\[\&\:before\]\:content-\[\'Text\:_\'\] { + &:before { + --tw-content: 'Text: '; + content: var(--tw-content); + } + } } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { @@ -4262,6 +4490,12 @@ video { --tw-gradient-via-position: 50%; --tw-gradient-to-position: 100%; --tw-font-weight: initial; + --tw-tracking: initial; + --tw-ordinal: initial; + --tw-slashed-zero: initial; + --tw-numeric-figure: initial; + --tw-numeric-spacing: initial; + --tw-numeric-fraction: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; --tw-inset-shadow: 0 0 #0000; @@ -4294,6 +4528,7 @@ video { --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; --tw-ease: initial; + --tw-content: ""; } } } @@ -4404,6 +4639,30 @@ video { syntax: "*"; inherits: false; } +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-ordinal { + syntax: "*"; + inherits: false; +} +@property --tw-slashed-zero { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-figure { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-spacing { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-fraction { + syntax: "*"; + inherits: false; +} @property --tw-shadow { syntax: "*"; inherits: false; @@ -4540,3 +4799,8 @@ video { syntax: "*"; inherits: false; } +@property --tw-content { + syntax: "*"; + inherits: false; + initial-value: ""; +} diff --git a/src/bin/server.rs b/src/bin/server.rs index 31d32d5..98310e5 100644 --- a/src/bin/server.rs +++ b/src/bin/server.rs @@ -31,9 +31,8 @@ use zettle_db::{ admin_panel::{show_admin_panel, toggle_registration_status}, documentation::index::show_documentation_index, gdpr::{accept_gdpr, deny_gdpr}, - index::index_handler, - ingress_form::{process_ingress_form, show_ingress_form}, - ingress_tasks::{delete_task, show_queue_tasks}, + index::{delete_job, delete_text_content, index_handler}, + ingress_form::{hide_ingress_form, process_ingress_form, show_ingress_form}, privacy_policy::show_privacy_policy, search_result::search_result_handler, signin::{authenticate_user, show_signin_form}, @@ -168,8 +167,9 @@ fn html_routes( "/ingress-form", get(show_ingress_form).post(process_ingress_form), ) - .route("/queue", get(show_queue_tasks)) - .route("/queue/:delivery_tag", delete(delete_task)) + .route("/hide-ingress-form", get(hide_ingress_form)) + .route("/text-content/:id", delete(delete_text_content)) + .route("/jobs/:job_id", delete(delete_job)) .route("/account", get(show_account_page)) .route("/admin", get(show_admin_panel)) .route("/toggle-registrations", patch(toggle_registration_status)) diff --git a/src/ingress/jobqueue.rs b/src/ingress/jobqueue.rs index 5e5bd48..ad2fba4 100644 --- a/src/ingress/jobqueue.rs +++ b/src/ingress/jobqueue.rs @@ -57,6 +57,31 @@ impl JobQueue { Ok(jobs) } + /// Gets all active jobs for a specific user + pub async fn get_unfinished_user_jobs(&self, user_id: &str) -> Result, AppError> { + let jobs: Vec = self + .db + .query( + "SELECT * FROM type::table($table) + WHERE user_id = $user_id + AND ( + status = 'Created' + OR ( + status.InProgress != NONE + AND status.InProgress.attempts < $max_attempts + ) + ) + ORDER BY created_at DESC", + ) + .bind(("table", Job::table_name())) + .bind(("user_id", user_id.to_owned())) + .bind(("max_attempts", MAX_ATTEMPTS)) + .await? + .take(0)?; + debug!("{:?}", jobs); + Ok(jobs) + } + pub async fn delete_job(&self, id: &str, user_id: &str) -> Result<(), AppError> { get_item::(&self.db.client, id) .await? diff --git a/src/server/routes/html/index.rs b/src/server/routes/html/index.rs index 78d124a..0e701b3 100644 --- a/src/server/routes/html/index.rs +++ b/src/server/routes/html/index.rs @@ -1,4 +1,7 @@ -use axum::{extract::State, response::IntoResponse}; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Redirect}, +}; use axum_session::Session; use axum_session_auth::AuthSession; use axum_session_surreal::SessionSurrealPool; @@ -6,17 +9,23 @@ use surrealdb::{engine::any::Any, Surreal}; use tracing::info; use crate::{ - error::HtmlError, + error::{AppError, HtmlError}, page_data, - server::{routes::html::render_template, AppState}, - storage::types::{text_content::TextContent, user::User}, + server::{ + routes::html::{render_block, render_template}, + AppState, + }, + storage::{ + db::delete_item, + types::{job::Job, text_content::TextContent, user::User}, + }, }; page_data!(IndexData, "index/index.html", { gdpr_accepted: bool, - queue_length: u32, user: Option, - latest_text_contents: Vec + latest_text_contents: Vec, + active_jobs: Vec }); pub async fn index_handler( @@ -28,17 +37,16 @@ pub async fn index_handler( let gdpr_accepted = auth.current_user.is_some() | session.get("gdpr_accepted").unwrap_or(false); - let queue_length = match auth.current_user.is_some() { + let active_jobs = match auth.current_user.is_some() { true => state .job_queue - .get_user_jobs(&auth.current_user.clone().unwrap().id) + .get_unfinished_user_jobs(&auth.current_user.clone().unwrap().id) .await - .map_err(|e| HtmlError::new(e, state.templates.clone()))? - .len(), - false => 0, + .map_err(|e| HtmlError::new(e, state.templates.clone()))?, + false => vec![], }; - let latest_text_contents = match auth.current_user.is_some() { + let latest_text_contents = match auth.current_user.clone().is_some() { true => User::get_latest_text_contents( auth.current_user.clone().unwrap().id.as_str(), &state.surreal_db_client, @@ -65,10 +73,90 @@ pub async fn index_handler( let output = render_template( IndexData::template_name(), IndexData { - queue_length: queue_length.try_into().unwrap(), gdpr_accepted, user: auth.current_user, latest_text_contents, + active_jobs, + }, + state.templates.clone(), + )?; + + Ok(output.into_response()) +} + +#[derive(Serialize)] +pub struct LatestTextContentData { + latest_text_contents: Vec, + user: User, +} + +pub async fn delete_text_content( + State(state): State, + auth: AuthSession, Surreal>, + Path(id): Path, +) -> Result { + let user = match &auth.current_user { + Some(user) => user, + None => return Ok(Redirect::to("/").into_response()), + }; + + delete_item::(&state.surreal_db_client, &id) + .await + .map_err(|e| HtmlError::new(AppError::from(e), state.templates.clone()))?; + + let latest_text_contents = User::get_latest_text_contents(&user.id, &state.surreal_db_client) + .await + .map_err(|e| HtmlError::new(e, state.templates.clone()))?; + + info!("{:?}", latest_text_contents); + + let output = render_block( + "index/signed_in/recent_content.html", + "latest_content_section", + LatestTextContentData { + user: user.clone(), + latest_text_contents, + }, + state.templates.clone(), + )?; + + Ok(output.into_response()) +} + +#[derive(Serialize)] +pub struct ActiveJobsData { + active_jobs: Vec, + user: User, +} + +pub async fn delete_job( + State(state): State, + auth: AuthSession, Surreal>, + Path(id): Path, +) -> Result { + let user = match auth.current_user { + Some(user) => user, + None => return Ok(Redirect::to("/signin").into_response()), + }; + + state + .job_queue + .delete_job(&id, &user.id) + .await + .map_err(|e| HtmlError::new(e, state.templates.clone()))?; + + let active_jobs = state + .job_queue + .get_unfinished_user_jobs(&user.id) + .await + .map_err(|e| HtmlError::new(e, state.templates.clone()))?; + + let output = render_block( + "index/signed_in/active_jobs.html", + "active_jobs_section", + ActiveJobsData { + user: user.clone(), + active_jobs, }, state.templates.clone(), )?; diff --git a/src/server/routes/html/ingress_form.rs b/src/server/routes/html/ingress_form.rs index e6e3de2..0977573 100644 --- a/src/server/routes/html/ingress_form.rs +++ b/src/server/routes/html/ingress_form.rs @@ -33,6 +33,19 @@ pub async fn show_ingress_form( Ok(output.into_response()) } +pub async fn hide_ingress_form( + auth: AuthSession, Surreal>, +) -> Result { + if !auth.is_authenticated() { + return Ok(Redirect::to("/").into_response()); + } + + Ok(Html( + "Add Content", + ) + .into_response()) +} + #[derive(Debug, TryFromMultipart)] pub struct IngressParams { pub content: Option, diff --git a/src/server/routes/html/ingress_tasks.rs b/src/server/routes/html/ingress_tasks.rs deleted file mode 100644 index c15052d..0000000 --- a/src/server/routes/html/ingress_tasks.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::{ - error::HtmlError, - page_data, - server::AppState, - storage::types::{job::Job, user::User}, -}; -use axum::{ - extract::{Path, State}, - response::{Html, IntoResponse, Redirect}, -}; -use axum_session_auth::AuthSession; -use axum_session_surreal::SessionSurrealPool; -use surrealdb::{engine::any::Any, Surreal}; - -use super::render_template; - -page_data!(ShowQueueTasks, "queue_tasks.html", {user : User,jobs: Vec}); - -pub async fn show_queue_tasks( - State(state): State, - auth: AuthSession, Surreal>, -) -> Result { - let user = match auth.current_user { - Some(user) => user, - None => return Ok(Redirect::to("/signin").into_response()), - }; - - let jobs = state - .job_queue - .get_user_jobs(&user.id) - .await - .map_err(|e| HtmlError::new(e, state.templates.clone()))?; - - let rendered = render_template( - ShowQueueTasks::template_name(), - ShowQueueTasks { jobs, user }, - state.templates.clone(), - )?; - - Ok(rendered.into_response()) -} - -pub async fn delete_task( - State(state): State, - auth: AuthSession, Surreal>, - Path(id): Path, -) -> Result { - let user = match auth.current_user { - Some(user) => user, - None => return Ok(Redirect::to("/signin").into_response()), - }; - - state - .job_queue - .delete_job(&id, &user.id) - .await - .map_err(|e| HtmlError::new(e, state.templates.clone()))?; - - Ok(Html("").into_response()) -} diff --git a/src/server/routes/html/mod.rs b/src/server/routes/html/mod.rs index bd5b1a3..6ec72a7 100644 --- a/src/server/routes/html/mod.rs +++ b/src/server/routes/html/mod.rs @@ -11,7 +11,6 @@ pub mod documentation; pub mod gdpr; pub mod index; pub mod ingress_form; -pub mod ingress_tasks; pub mod privacy_policy; pub mod search_result; pub mod signin; diff --git a/templates/index/signed_in/active_jobs.html b/templates/index/signed_in/active_jobs.html new file mode 100644 index 0000000..ee7cc03 --- /dev/null +++ b/templates/index/signed_in/active_jobs.html @@ -0,0 +1,46 @@ +{% block active_jobs_section %} +{% if active_jobs %} +
    +
  • Active Jobs
  • + {% for item in active_jobs %} +
  • +
    + + + +
    +
    +
    + {{item.created_at|datetimeformat(format="short", tz=user.timezone)}}
    +
    + {{item.status}} +
    +
    +

    + {{item.content}} +

    + + + + + + + + + +
  • + {% endfor %} +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/index/signed_in/base.html b/templates/index/signed_in/base.html index 895dae3..145323f 100644 --- a/templates/index/signed_in/base.html +++ b/templates/index/signed_in/base.html @@ -2,8 +2,13 @@
{% include 'index/signed_in/searchbar.html' %} - {% include "index/signed_in/recent_content.html" %} - {% include "index/signed_in/quick_actions.html" %} + +
+ {% include "index/signed_in/active_jobs.html" %} + + {% include "index/signed_in/recent_content.html" %} +
+
\ No newline at end of file diff --git a/templates/index/signed_in/quick_actions.html b/templates/index/signed_in/quick_actions.html index 765748b..1e6b270 100644 --- a/templates/index/signed_in/quick_actions.html +++ b/templates/index/signed_in/quick_actions.html @@ -1,7 +1,7 @@
- Add Content +
\ No newline at end of file diff --git a/templates/index/signed_in/recent_content.html b/templates/index/signed_in/recent_content.html index 1e11d86..503f225 100644 --- a/templates/index/signed_in/recent_content.html +++ b/templates/index/signed_in/recent_content.html @@ -1,24 +1,45 @@ -
- -

{{latest_text_contents}}

-
-

Recently Added Content

-
    - {% for item in latest_text_contents %} -
  • -
    - {{item.created_at|datetimeformat(format="short", tz="Europe/Stockholm")}}
    -
    -
    {{item.category}}
    -
    {{item.text}}
    -
    - - -
  • - {% endfor %} -
-
-
\ No newline at end of file +{% block latest_content_section %} +
    +
  • Recently added content
  • + {% for item in latest_text_contents %} +
  • +
    + + + + +
    +
    +
    + {{item.created_at|datetimeformat(format="short", tz=user.timezone)}}
    +
    + {{item.instructions}} +
    +
    +

    + {{item.text}} +

    + + + + + + + + + +
  • + {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/ingress_form.html b/templates/ingress_form.html index 6e0db9a..3af6a01 100644 --- a/templates/ingress_form.html +++ b/templates/ingress_form.html @@ -1,4 +1,4 @@ -
+