2 Commits

Author SHA1 Message Date
Ultradesu 91ca486e64 Added user/visits 'deleted' status, hide it in UI
Build and Publish / Build and Publish Docker Image (push) Successful in 1m22s
2026-06-04 13:41:18 +03:00
Ultradesu 2389bca42b Fixed image transcoding. Paying attention to EXIF orientation data
Build and Publish / Build and Publish Docker Image (push) Successful in 1m24s
2026-06-04 13:08:34 +03:00
9 changed files with 241 additions and 56 deletions
Generated
+1 -1
View File
@@ -3467,7 +3467,7 @@ dependencies = [
[[package]]
name = "web-petting"
version = "0.1.12"
version = "0.1.14"
dependencies = [
"base64",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "web-petting"
version = "0.1.13"
version = "0.1.15"
edition = "2024"
[dependencies]
+155 -37
View File
@@ -6,6 +6,7 @@ use cot::request::extractors::Path;
use cot::response::{IntoResponse, Redirect, Response};
use cot::router::{Route, Router};
use cot::session::Session;
use image::ImageDecoder;
use image::ImageFormat;
use image::ImageReader;
use image::codecs::jpeg::JpegEncoder;
@@ -19,6 +20,7 @@ use crate::telegram;
const SESSION_USER_ID: &str = "user_id";
const SESSION_USER_NAME: &str = "user_name";
const SESSION_OIDC_STATE: &str = "oidc_state";
const MAX_UPLOADED_IMAGE_DIMENSION: u32 = 1920;
const UPLOADED_IMAGE_JPEG_QUALITY: u8 = 82;
@@ -117,9 +119,16 @@ fn transcode_uploaded_image(data: &[u8], ext: &str) -> cot::Result<Option<Vec<u8
return Ok(None);
};
let image = ImageReader::with_format(Cursor::new(data), format)
.decode()
let mut decoder = ImageReader::with_format(Cursor::new(data), format)
.into_decoder()
.map_err(|e| cot::Error::internal(e.to_string()))?;
let orientation = decoder
.orientation()
.map_err(|e| cot::Error::internal(e.to_string()))?;
let mut image = image::DynamicImage::from_decoder(decoder)
.map_err(|e| cot::Error::internal(e.to_string()))?;
image.apply_orientation(orientation);
let resized = image.resize(
MAX_UPLOADED_IMAGE_DIMENSION,
MAX_UPLOADED_IMAGE_DIMENSION,
@@ -357,7 +366,7 @@ struct ScheduleEditTemplate<'a> {
lang: Lang,
admin_name: &'a str,
visit: Visit,
clients: Vec<Client>,
client: Client,
users: Vec<User>,
media: Vec<Media>,
}
@@ -640,7 +649,28 @@ fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
serde_json::from_slice(&bytes).ok()
}
async fn oidc_start(request: Request, db: Database) -> cot::Result<Response> {
fn oidc_state_cookie(value: &str, max_age_seconds: u32) -> String {
format!(
"oidc_state={}; Path=/admin/oidc; HttpOnly; SameSite=Lax; Max-Age={}",
value, max_age_seconds,
)
}
fn get_cookie(request: &Request, name: &str) -> Option<String> {
let prefix = format!("{name}=");
request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|part| {
let part = part.trim();
part.strip_prefix(&prefix).map(|v| v.to_string())
})
})
}
async fn oidc_start(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
let client_id = oidc_setting(&db, "oidc_client_id").await?;
@@ -666,6 +696,7 @@ async fn oidc_start(request: Request, db: Database) -> cot::Result<Response> {
};
let state = rand_token();
session.insert(SESSION_OIDC_STATE, state.clone()).await?;
let redirect_uri = format!("{}/admin/oidc/callback", site_domain.trim_end_matches('/'));
@@ -677,10 +708,7 @@ async fn oidc_start(request: Request, db: Database) -> cot::Result<Response> {
urlencoding::encode(&state),
);
let state_cookie = format!(
"oidc_state={}; Path=/admin/oidc; HttpOnly; Secure; SameSite=Lax; Max-Age=600",
state,
);
let state_cookie = oidc_state_cookie(&state, 600);
Redirect::new(redirect_url)
.into_response()?
@@ -692,18 +720,18 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
let lang = detect_lang(&request);
let fail = |code: &str| format!("/admin/login?lang={}&error={}", lang.code(), code);
// Read saved state from cookie
let saved_state = request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|part| {
let part = part.trim();
part.strip_prefix("oidc_state=").map(|v| v.to_string())
})
})
.unwrap_or_default();
// Prefer the server-side session; keep the cookie as a compatibility
// fallback for flows started before this code was deployed.
let saved_state_from_session = session
.get::<String>(SESSION_OIDC_STATE)
.await
.ok()
.flatten();
let saved_state_from_cookie = get_cookie(&request, "oidc_state");
let saved_state = saved_state_from_session
.as_deref()
.or(saved_state_from_cookie.as_deref())
.unwrap_or("");
// Extract code and state from query string
let query_str = request.uri().query().unwrap_or("");
@@ -719,12 +747,20 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
if code.is_empty() || state.is_empty() || state != saved_state {
tracing::warn!(
"OIDC state mismatch: state={state:?}, saved={saved_state:?}, code_empty={}, state_empty={}",
code.is_empty(),
state.is_empty(),
target: "oidc",
has_session_state = saved_state_from_session.is_some(),
has_cookie_state = saved_state_from_cookie.is_some(),
code_empty = code.is_empty(),
state_empty = state.is_empty(),
"OIDC state mismatch",
);
return Redirect::new(fail("sso")).into_response();
let clear_cookie = oidc_state_cookie("", 0);
return Redirect::new(fail("sso"))
.into_response()?
.with_header("set-cookie", clear_cookie)
.into_response();
}
let _ = session.remove::<String>(SESSION_OIDC_STATE).await;
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
let client_id = oidc_setting(&db, "oidc_client_id").await?;
@@ -886,7 +922,7 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
session.insert(SESSION_USER_NAME, session_name).await?;
// Clear the oidc_state cookie
let clear_cookie = "oidc_state=; Path=/admin/oidc; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
let clear_cookie = oidc_state_cookie("", 0);
Redirect::new(format!("/admin/?lang={}", lang.code()))
.into_response()?
.with_header("set-cookie", clear_cookie)
@@ -907,8 +943,10 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
let tz = crate::tz::load_tz(&db).await;
let today = crate::tz::today_in_tz(tz);
let all_visits = Visit::objects().all(&db).await?;
let clients = Client::objects().all(&db).await?;
let mut all_visits = Visit::objects().all(&db).await?;
all_visits.retain(|v| v.status != "deleted");
let mut clients = Client::objects().all(&db).await?;
clients.retain(|c| c.status != "deleted");
let mut today_visits: Vec<TodayVisit> = all_visits
.iter()
@@ -1010,11 +1048,12 @@ async fn clients_page(request: Request, session: Session, db: Database) -> cot::
Err(resp) => return Ok(resp),
};
let show_all = has_query_flag(&request, "all");
let clients = if show_all {
let mut clients = if show_all {
Client::objects().all(&db).await?
} else {
query!(Client, $status == "active").all(&db).await?
};
clients.retain(|c| c.status != "deleted");
let body = ClientsTemplate {
t: lang.t(),
lang,
@@ -1428,6 +1467,24 @@ async fn client_activate(
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
}
async fn client_delete(
request: Request,
session: Session,
db: Database,
Path(client_id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
client.status = "deleted".to_string();
client.updated_at = now_utc();
client.save(&db).await?;
}
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
}
async fn user_archive(
request: Request,
session: Session,
@@ -1645,12 +1702,18 @@ async fn schedule_events(
let mut events = Vec::new();
for v in &visits {
if v.status == "deleted" {
continue;
}
if v.visit_date < start_date || v.visit_date > end_date {
continue;
}
let client_id_val: i64 = v.client_id.primary_key().unwrap();
let user_id_val: i64 = v.user_id.primary_key().unwrap();
let client = clients.iter().find(|c| c.id.unwrap() == client_id_val);
if client.map(|c| c.status.as_str()) == Some("deleted") {
continue;
}
let user = users.iter().find(|u| u.id.unwrap() == user_id_val);
let client_name = client.map(|c| c.name.as_str()).unwrap_or("?");
let client_phone = client.and_then(|c| c.phone.as_deref()).unwrap_or("");
@@ -1774,7 +1837,16 @@ async fn schedule_edit_page(
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
}
};
let clients = query!(Client, $status == "active").all(&db).await?;
if visit.status == "deleted" {
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
}
let client_id: i64 = visit.client_id.primary_key().unwrap();
let client = match query!(Client, $id == client_id).get(&db).await? {
Some(c) => c,
None => {
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
}
};
let users = query!(User, $status == "active").all(&db).await?;
let mut visit_media = Media::objects().all(&db).await?;
visit_media.retain(|m| {
@@ -1790,7 +1862,7 @@ async fn schedule_edit_page(
lang,
admin_name: &admin_name,
visit,
clients,
client,
users,
media: visit_media,
}
@@ -1800,7 +1872,6 @@ async fn schedule_edit_page(
#[derive(Deserialize)]
struct EditVisitForm {
client_id: i64,
user_id: i64,
visit_date: String,
time_start: String,
@@ -1821,7 +1892,9 @@ async fn schedule_edit_submit(
return Ok(resp);
}
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
visit.client_id = ForeignKey::PrimaryKey(Auto::fixed(form.client_id));
if visit.status == "deleted" {
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
}
visit.user_id = ForeignKey::PrimaryKey(Auto::fixed(form.user_id));
if let Ok(d) = chrono::NaiveDate::parse_from_str(&form.visit_date, "%Y-%m-%d") {
visit.visit_date = d;
@@ -1847,7 +1920,11 @@ async fn visit_delete(
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
query!(Visit, $id == visit_id).delete(&db).await?;
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
visit.status = "deleted".to_string();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
}
@@ -1862,6 +1939,9 @@ async fn visit_set_done(
return Ok(resp);
}
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
if visit.status == "deleted" {
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
}
visit.status = "completed".to_string();
visit.updated_at = now_utc();
visit.save(&db).await?;
@@ -1880,6 +1960,9 @@ async fn visit_set_cancel(
return Ok(resp);
}
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
if visit.status == "deleted" {
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
}
visit.status = "cancelled".to_string();
visit.updated_at = now_utc();
visit.save(&db).await?;
@@ -1909,16 +1992,40 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re
})
.unwrap_or(0);
let clients_all = Client::objects().all(&db).await?;
let visits_all = Visit::objects().all(&db).await?;
let mut media_list = Media::objects().all(&db).await?;
media_list.retain(|m| m.status == "active");
media_list.retain(|m| {
if m.status != "active" {
return false;
}
let cid: i64 = m.client_id.primary_key().unwrap();
if clients_all
.iter()
.find(|c| c.id.unwrap() == cid)
.map(|c| c.status.as_str())
== Some("deleted")
{
return false;
}
if let Some(fk) = &m.visit_id {
let vid: i64 = fk.primary_key().unwrap();
if visits_all
.iter()
.find(|v| v.id.unwrap() == vid)
.map(|v| v.status.as_str())
== Some("deleted")
{
return false;
}
}
true
});
if filter_client_id > 0 {
media_list.retain(|m| m.client_id.primary_key().unwrap() == filter_client_id);
}
media_list.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let clients_all = Client::objects().all(&db).await?;
let visits_all = Visit::objects().all(&db).await?;
let items: Vec<MediaItem> = media_list
.into_iter()
.map(|m| {
@@ -1972,6 +2079,9 @@ async fn media_upload_page(
Some(v) => v,
None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(),
};
if visit.status == "deleted" {
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
}
let cid: i64 = visit.client_id.primary_key().unwrap();
let client = query!(Client, $id == cid).get(&db).await?;
let client_name = client.map(|c| c.name).unwrap_or_default();
@@ -2020,6 +2130,9 @@ async fn media_upload_submit(
Some(v) => v,
None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(),
};
if visit.status == "deleted" {
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
}
let client_id: i64 = visit.client_id.primary_key().unwrap();
let bytes = request.into_body().into_bytes().await?;
@@ -2555,6 +2668,11 @@ pub fn admin_router() -> Router {
client_activate,
"admin-client-activate",
),
Route::with_handler_and_name(
"/clients/{client_id}/delete",
client_delete,
"admin-client-delete",
),
Route::with_handler_and_name("/schedule", schedule_page, "admin-schedule"),
Route::with_handler_and_name("/schedule/new", schedule_new_page, "admin-schedule-new"),
Route::with_handler_and_name("/schedule/events", schedule_events, "admin-schedule-events"),
+14
View File
@@ -103,8 +103,11 @@ pub struct Translations {
pub clients_media_link: &'static str,
pub clients_add_title: &'static str,
pub clients_add_button: &'static str,
pub clients_delete: &'static str,
pub clients_delete_confirm: &'static str,
pub client_status_active: &'static str,
pub client_status_archived: &'static str,
pub client_status_deleted: &'static str,
// Users
pub users_title: &'static str,
@@ -256,6 +259,7 @@ pub struct Translations {
pub visit_status_scheduled: &'static str,
pub visit_status_completed: &'static str,
pub visit_status_cancelled: &'static str,
pub visit_status_deleted: &'static str,
pub schedule_mark_done: &'static str,
pub schedule_cancel: &'static str,
pub schedule_edit_title: &'static str,
@@ -336,8 +340,11 @@ static RU: Translations = Translations {
clients_media_link: "Медиа",
clients_add_title: "Добавить клиента",
clients_add_button: "Добавить",
clients_delete: "Удалить клиента",
clients_delete_confirm: "Точно удалить этого клиента?",
client_status_active: "Активный",
client_status_archived: "Архив",
client_status_deleted: "Удалён",
users_title: "Администраторы",
users_login: "Логин",
@@ -453,6 +460,7 @@ static RU: Translations = Translations {
visit_status_scheduled: "Запланирован",
visit_status_completed: "Выполнен",
visit_status_cancelled: "Отменён",
visit_status_deleted: "Удалён",
schedule_mark_done: "Выполнен",
schedule_cancel: "Отменить",
schedule_edit_title: "Редактировать визит",
@@ -557,8 +565,11 @@ static EN: Translations = Translations {
clients_media_link: "Media",
clients_add_title: "Add Client",
clients_add_button: "Add",
clients_delete: "Delete client",
clients_delete_confirm: "Are you sure you want to delete this client?",
client_status_active: "Active",
client_status_archived: "Archived",
client_status_deleted: "Deleted",
users_title: "Administrators",
users_login: "Login",
@@ -674,6 +685,7 @@ static EN: Translations = Translations {
visit_status_scheduled: "Scheduled",
visit_status_completed: "Completed",
visit_status_cancelled: "Cancelled",
visit_status_deleted: "Deleted",
schedule_mark_done: "Done",
schedule_cancel: "Cancel",
schedule_edit_title: "Edit Visit",
@@ -760,6 +772,7 @@ impl Translations {
"scheduled" => self.visit_status_scheduled,
"completed" => self.visit_status_completed,
"cancelled" => self.visit_status_cancelled,
"deleted" => self.visit_status_deleted,
_ => "?",
}
}
@@ -768,6 +781,7 @@ impl Translations {
match status {
"active" => self.client_status_active,
"archived" => self.client_status_archived,
"deleted" => self.client_status_deleted,
_ => "?",
}
}
+20 -2
View File
@@ -51,14 +51,32 @@ impl App for PublicApp {
struct PettingProject;
fn parse_bool_env(name: &str) -> Option<bool> {
let value = std::env::var(name).ok()?;
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn debug_enabled(config_name: &str) -> bool {
parse_bool_env("WEB_PETTING_DEBUG").unwrap_or_else(|| {
matches!(
config_name,
"dev" | "development" | "debug" | "local" | "test"
)
})
}
impl Project for PettingProject {
fn cli_metadata(&self) -> CliMetadata {
cot::cli::metadata!()
}
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
fn config(&self, config_name: &str) -> cot::Result<ProjectConfig> {
Ok(ProjectConfig::builder()
.debug(true)
.debug(debug_enabled(config_name))
.database(
DatabaseConfig::builder()
.url("sqlite://db.sqlite3?mode=rwc")
+3 -1
View File
@@ -43,6 +43,7 @@ pub enum VisitStatus {
Scheduled,
Completed,
Cancelled,
Deleted,
}
impl VisitStatus {
@@ -51,6 +52,7 @@ impl VisitStatus {
Self::Scheduled => "scheduled",
Self::Completed => "completed",
Self::Cancelled => "cancelled",
Self::Deleted => "deleted",
}
}
}
@@ -146,7 +148,7 @@ pub struct Visit {
pub public_notes: Option<String>,
/// Feedback text from client via portal.
pub client_feedback: Option<String>,
/// scheduled | completed | cancelled
/// scheduled | completed | cancelled | deleted
pub status: String,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
+26 -4
View File
@@ -240,7 +240,8 @@ async fn client_portal(
.unwrap_or(false);
let client = match query!(Client, $media_token == token).get(&db).await? {
Some(c) => c,
Some(c) if c.status != "deleted" => c,
Some(_) => return Html::new("404").into_response(),
None => return Html::new("404").into_response(),
};
@@ -249,7 +250,11 @@ async fn client_portal(
let today = crate::tz::today_in_tz(tz);
let mut visits = Visit::objects().all(&db).await?;
visits.retain(|v| v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled");
visits.retain(|v| {
v.client_id.primary_key().unwrap() == client_id
&& v.status != "cancelled"
&& v.status != "deleted"
});
visits.sort_by(|a, b| {
a.visit_date
.cmp(&b.visit_date)
@@ -327,7 +332,8 @@ async fn submit_feedback(
// Verify token matches visit's client
let token_clone = token.clone();
let client = match query!(Client, $media_token == token).get(&db).await? {
Some(c) => c,
Some(c) if c.status != "deleted" => c,
Some(_) => return Html::new("404").into_response(),
None => return Html::new("404").into_response(),
};
let client_id = client.id.unwrap();
@@ -346,6 +352,14 @@ async fn submit_feedback(
}
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
if visit.status == "deleted" {
return Redirect::new(format!(
"/client/{}?lang={}",
token_clone,
lang.code()
))
.into_response();
}
if visit.client_id.primary_key().unwrap() == client_id {
visit.client_feedback = Some(form.feedback);
visit.updated_at = now_utc();
@@ -369,7 +383,8 @@ async fn portal_media(
) -> cot::Result<Response> {
// Verify token
let client = match query!(Client, $media_token == token).get(&db).await? {
Some(c) => c,
Some(c) if c.status != "deleted" => c,
Some(_) => return Html::new("404").into_response(),
None => return Html::new("404").into_response(),
};
let client_id = client.id.unwrap();
@@ -378,6 +393,13 @@ async fn portal_media(
Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m,
_ => return Html::new("404").into_response(),
};
if let Some(fk) = &media.visit_id {
let visit_id: i64 = fk.primary_key().unwrap();
match query!(Visit, $id == visit_id).get(&db).await? {
Some(v) if v.status != "deleted" => {}
_ => return Html::new("404").into_response(),
}
}
match tokio::fs::read(&media.file_path).await {
Ok(data) => {
+6 -1
View File
@@ -76,11 +76,16 @@
<form method="post" action="/admin/clients/{{ client_id }}/archive">
<button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button>
</form>
{% else %}
{% else if client_status == "archived" %}
<form method="post" action="/admin/clients/{{ client_id }}/activate">
<button type="submit" class="button is-success is-outlined is-fullwidth">{{ t.action_activate }}</button>
</form>
{% endif %}
{% if client_status != "deleted" %}
<form method="post" action="/admin/clients/{{ client_id }}/delete" onsubmit="return confirm('{{ t.clients_delete_confirm }}');" style="margin-top:0.75rem;">
<button type="submit" class="button is-danger is-outlined is-fullwidth">{{ t.clients_delete }}</button>
</form>
{% endif %}
{% endif %}
</div>
{% endblock %}
+15 -9
View File
@@ -14,15 +14,7 @@
<div class="field">
<label class="label">{{ t.schedule_client }}</label>
<div class="control">
<div class="select is-fullwidth">
<select name="client_id" required>
{% for c in &clients %}
<option value="{{ c.id }}" {% if c.id.unwrap() == visit.client_id.primary_key().unwrap() %}selected{% endif %}>
{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}
</option>
{% endfor %}
</select>
</div>
<input class="input" type="text" value="{{ client.name }}{% if let Some(p) = client.phone.as_deref() %} ({{ p }}){% endif %}" readonly>
</div>
</div>
@@ -132,6 +124,9 @@
{% if let Some(cap) = m.caption.as_deref() %}
<div class="media-cap">{{ cap }}</div>
{% endif %}
<div class="visit-media-delete">
<button type="submit" form="visit-media-delete-{{ m.id.unwrap() }}" class="button is-small is-danger is-outlined">{{ t.media_delete }}</button>
</div>
</div>
{% endfor %}
</div>
@@ -141,6 +136,9 @@
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
</form>
{% for m in &media %}
<form id="visit-media-delete-{{ m.id.unwrap() }}" method="post" action="/admin/media/{{ m.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');"></form>
{% endfor %}
<hr style="margin:1rem 0;">
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
@@ -254,6 +252,14 @@
overflow: hidden;
text-overflow: ellipsis;
}
.visit-media-delete {
padding: 0.25rem 0.4rem 0.4rem;
}
.visit-media-delete .button {
width: 100%;
font-size: 0.68rem;
min-height: 1.65rem;
}
.upload-modal-bg {
display: none;
position: fixed;