7 Commits
Author SHA1 Message Date
Ultradesu 87fb8c744d Added timezone support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m9s
2026-05-11 13:46:40 +01:00
Ultradesu 357a2ed423 Added timezone support
Build and Publish / Build and Publish Docker Image (push) Failing after 56s
2026-05-11 13:43:31 +01:00
ab 434ed7a376 Update .github/workflows/build-and-publish.yml 2026-05-11 12:31:28 +00:00
Ultradesu b685075129 Added comment editing
Build and Publish / Build and Publish Docker Image (push) Successful in 1m20s
2026-05-11 13:30:34 +01:00
Ultradesu 9b8cc6bb08 Fixed styling again
Build and Publish / Build and Publish Docker Image (push) Successful in 1m11s
2026-05-11 13:25:19 +01:00
Ultradesu e8ddaad424 Fixed styling again
Build and Publish / Build and Publish Docker Image (push) Successful in 1m11s
2026-05-11 13:03:51 +01:00
Ultradesu 4321c0aac1 Fixed styling again
Build and Publish / Build and Publish Docker Image (push) Successful in 1m14s
2026-05-11 13:00:22 +01:00
13 changed files with 318 additions and 65 deletions

No files matched your search

+3 -3
View File
@@ -2,9 +2,9 @@ name: Build and Publish
on:
push:
branches:
- master
- main
#branches:
# - master
# - main
tags:
- 'v*.*.*'
Generated
+2 -1
View File
@@ -3255,9 +3255,10 @@ dependencies = [
[[package]]
name = "web-petting"
version = "0.1.1"
version = "0.1.4"
dependencies = [
"chrono",
"chrono-tz",
"cot",
"futures",
"multer",
+2 -1
View File
@@ -1,11 +1,12 @@
[package]
name = "web-petting"
version = "0.1.2"
version = "0.1.4"
edition = "2024"
[dependencies]
cot = { version = "0.6.0", features = ["sqlite"] }
chrono = "0.4"
chrono-tz = "0.10"
serde = { version = "1", features = ["derive"] }
serde_html_form = "0.4"
password-auth = "1"
+150 -31
View File
@@ -109,7 +109,7 @@ fn rand_client_color() -> &'static str {
CLIENT_COLORS[(h.finish() as usize) % CLIENT_COLORS.len()]
}
fn now() -> chrono::NaiveDateTime {
fn now_utc() -> chrono::NaiveDateTime {
chrono::Utc::now().naive_utc()
}
@@ -260,6 +260,7 @@ struct ScheduleTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
timezone: String,
}
#[derive(Debug, Template)]
@@ -399,8 +400,8 @@ async fn setup_submit(request: Request, session: Session, db: Database) -> cot::
password_hash: password_auth::generate_hash(&form.password),
display_name: display,
status: "active".to_string(),
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
user.save(&db).await?;
@@ -468,7 +469,8 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
Err(resp) => return Ok(resp),
};
let user_id = get_admin_id(&session).await.unwrap_or(0);
let today = chrono::Utc::now().date_naive();
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?;
@@ -664,7 +666,7 @@ async fn client_edit_submit(
if let Some(color) = form.color.filter(|s| !s.trim().is_empty()) {
client.color = Some(color);
}
client.updated_at = now();
client.updated_at = now_utc();
client.save(&db).await?;
}
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
@@ -740,8 +742,8 @@ async fn add_user(request: Request, session: Session, db: Database) -> cot::Resu
password_hash: password_auth::generate_hash(&form.password),
display_name: display,
status: "active".to_string(),
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
user.save(&db).await?;
None
@@ -770,6 +772,7 @@ struct SettingsForm {
telegram_chat_id: String,
contact_info: String,
pricing_info: String,
timezone: String,
}
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
@@ -784,13 +787,14 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
("telegram_chat_id", form.telegram_chat_id),
("contact_info", form.contact_info),
("pricing_info", form.pricing_info),
("timezone", form.timezone),
] {
let k = key.to_string();
let existing = query!(Setting, $key == k).get(&db).await?;
match existing {
Some(mut s) => {
s.value = value;
s.updated_at = now();
s.updated_at = now_utc();
s.save(&db).await?;
}
None => {
@@ -798,7 +802,7 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
id: Auto::auto(),
key: key.to_string(),
value,
updated_at: now(),
updated_at: now_utc(),
};
s.save(&db).await?;
}
@@ -835,7 +839,7 @@ async fn lead_set_status(
if let Some(mut lead) = query!(Lead, $id == lead_id).get(&db).await? {
lead.status = form.status;
lead.updated_at = now();
lead.updated_at = now_utc();
lead.save(&db).await?;
}
@@ -864,14 +868,14 @@ async fn lead_convert(
media_token: rand_token(),
color: Some(rand_client_color().to_string()),
status: "active".to_string(),
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
client.save(&db).await?;
lead.status = "converted".to_string();
lead.client_id = Some(ForeignKey::from(&client));
lead.updated_at = now();
lead.updated_at = now_utc();
lead.save(&db).await?;
}
@@ -890,7 +894,7 @@ async fn client_archive(
}
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
client.status = "archived".to_string();
client.updated_at = now();
client.updated_at = now_utc();
client.save(&db).await?;
}
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
@@ -908,7 +912,7 @@ async fn client_activate(
}
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
client.status = "active".to_string();
client.updated_at = now();
client.updated_at = now_utc();
client.save(&db).await?;
}
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
@@ -926,7 +930,7 @@ async fn user_archive(
}
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
user.status = "archived".to_string();
user.updated_at = now();
user.updated_at = now_utc();
user.save(&db).await?;
}
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
@@ -944,7 +948,7 @@ async fn user_activate(
}
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
user.status = "active".to_string();
user.updated_at = now();
user.updated_at = now_utc();
user.save(&db).await?;
}
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
@@ -972,8 +976,8 @@ async fn add_lead(request: Request, session: Session, db: Database) -> cot::Resu
comment: form.comment.filter(|s| !s.trim().is_empty()),
status: "new".to_string(),
client_id: None,
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
lead.save(&db).await?;
@@ -1014,8 +1018,8 @@ async fn add_client(request: Request, session: Session, db: Database) -> cot::Re
media_token: rand_token(),
color: Some(rand_client_color().to_string()),
status: "active".to_string(),
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
client.save(&db).await?;
@@ -1026,16 +1030,18 @@ async fn add_client(request: Request, session: Session, db: Database) -> cot::Re
// Schedule Handlers
// ---------------------------------------------------------------------------
async fn schedule_page(request: Request, session: Session) -> cot::Result<Response> {
async fn schedule_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let tz = crate::tz::load_tz(&db).await;
let body = ScheduleTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
timezone: tz.to_string(),
}
.render()?;
html_response(body, lang)
@@ -1089,10 +1095,12 @@ async fn schedule_events(
}
}
let tz = crate::tz::load_tz(&db).await;
let tz_today = crate::tz::today_in_tz(tz);
let start_date = chrono::NaiveDate::parse_from_str(start_str, "%Y-%m-%d")
.unwrap_or_else(|_| chrono::Utc::now().date_naive() - chrono::Duration::days(60));
.unwrap_or_else(|_| tz_today - chrono::Duration::days(60));
let end_date = chrono::NaiveDate::parse_from_str(end_str, "%Y-%m-%d")
.unwrap_or_else(|_| chrono::Utc::now().date_naive() + chrono::Duration::days(60));
.unwrap_or_else(|_| tz_today + chrono::Duration::days(60));
let visits = Visit::objects().all(&db).await?;
let clients = Client::objects().all(&db).await?;
@@ -1203,8 +1211,8 @@ async fn schedule_create(
public_notes: None,
client_feedback: None,
status: "scheduled".to_string(),
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
visit.save(&db).await?;
}
@@ -1286,7 +1294,7 @@ async fn schedule_edit_submit(
visit.status = form.status;
visit.notes = form.notes.filter(|s| !s.trim().is_empty());
visit.public_notes = form.public_notes.filter(|s| !s.trim().is_empty());
visit.updated_at = now();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
@@ -1318,7 +1326,7 @@ async fn visit_set_done(
}
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
visit.status = "completed".to_string();
visit.updated_at = now();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
@@ -1336,7 +1344,7 @@ async fn visit_set_cancel(
}
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
visit.status = "cancelled".to_string();
visit.updated_at = now();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
@@ -1556,7 +1564,7 @@ async fn media_upload_submit(
file_type: ftype,
caption: caption_opt.clone(),
status: "active".to_string(),
created_at: now(),
created_at: now_utc(),
};
media.save(&db).await?;
}
@@ -1759,7 +1767,7 @@ async fn testimonial_add(
image_path,
status: "active".to_string(),
sort_order: max_order + 1,
created_at: now(),
created_at: now_utc(),
};
testimonial.save(&db).await?;
}
@@ -1805,6 +1813,112 @@ async fn testimonial_delete(
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
}
async fn testimonial_edit(
request: Request,
session: Session,
db: Database,
Path(id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
let boundary = extract_boundary(&request)
.ok_or_else(|| cot::Error::internal("missing multipart boundary"))?;
let bytes = request.into_body().into_bytes().await?;
let stream =
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
let mut multipart = multer::Multipart::new(stream, boundary);
let mut text = String::new();
let mut author_note = String::new();
let mut new_image_path: Option<String> = None;
let mut remove_image = false;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
{
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"text" => {
text = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
"author_note" => {
author_note = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
"remove_image" => {
let val = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
remove_image = val == "1";
}
"image" => {
let original_name = field.file_name().unwrap_or("").to_string();
if original_name.is_empty() {
continue;
}
let ext = original_name
.rsplit('.')
.next()
.unwrap_or("bin")
.to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" | "png" | "webp" | "heic" | "heif" => {}
_ => continue,
}
let data = field
.bytes()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
if data.is_empty() {
continue;
}
let upload_dir = "uploads/testimonials";
tokio::fs::create_dir_all(upload_dir)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let file_id = uuid::Uuid::new_v4();
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
tokio::fs::write(&path, &data)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
new_image_path = Some(path);
}
_ => {}
}
}
if let Some(mut t) = query!(Testimonial, $id == id).get(&db).await? {
if !text.trim().is_empty() {
t.text = text.trim().to_string();
}
t.author_note = if author_note.trim().is_empty() {
None
} else {
Some(author_note.trim().to_string())
};
if let Some(path) = new_image_path {
t.image_path = Some(path);
} else if remove_image {
t.image_path = None;
}
t.save(&db).await?;
}
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
}
/// Serve testimonial images (public, no auth needed for landing page).
async fn serve_testimonial_image(
_request: Request,
@@ -1962,6 +2076,11 @@ pub fn admin_router() -> Router {
testimonial_delete,
"admin-testimonial-delete",
),
Route::with_handler_and_name(
"/testimonials/{id}/edit",
testimonial_edit,
"admin-testimonial-edit",
),
Route::with_handler_and_name(
"/testimonials/{id}/image",
serve_testimonial_image,
+12
View File
@@ -130,6 +130,7 @@ pub struct Translations {
pub settings_telegram_chat_id: &'static str,
pub settings_contact_info: &'static str,
pub settings_pricing_info: &'static str,
pub settings_timezone: &'static str,
pub landing_contact_label: &'static str,
pub landing_pricing_title: &'static str,
@@ -196,6 +197,9 @@ pub struct Translations {
pub testimonials_add_button: &'static str,
pub testimonials_status_active: &'static str,
pub testimonials_status_hidden: &'static str,
pub testimonials_edit: &'static str,
pub testimonials_save: &'static str,
pub testimonials_remove_image: &'static str,
// Client edit
pub clients_edit_title: &'static str,
@@ -335,6 +339,7 @@ static RU: Translations = Translations {
settings_telegram_chat_id: "Chat ID для уведомлений",
settings_contact_info: "Контактная информация (отображается на лендинге)",
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
landing_contact_label: "Или свяжитесь с нами напрямую",
landing_pricing_title: "Стоимость",
@@ -458,6 +463,9 @@ static RU: Translations = Translations {
testimonials_add_button: "Добавить",
testimonials_status_active: "Отображается",
testimonials_status_hidden: "Скрыт",
testimonials_edit: "Редактировать",
testimonials_save: "Сохранить",
testimonials_remove_image: "Удалить фото",
no_value: "—",
action_convert: "Конвертировать",
@@ -530,6 +538,7 @@ static EN: Translations = Translations {
settings_telegram_chat_id: "Notification Chat ID",
settings_contact_info: "Contact info (shown on landing page)",
settings_pricing_info: "Pricing block (shown on landing page)",
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
landing_contact_label: "Or contact us directly",
landing_pricing_title: "Pricing",
@@ -653,6 +662,9 @@ static EN: Translations = Translations {
testimonials_add_button: "Add",
testimonials_status_active: "Visible",
testimonials_status_hidden: "Hidden",
testimonials_edit: "Edit",
testimonials_save: "Save",
testimonials_remove_image: "Remove photo",
no_value: "—",
action_convert: "Convert",
+1
View File
@@ -4,6 +4,7 @@ mod migrations;
pub mod models;
mod public;
mod telegram;
mod tz;
use cot::cli::CliMetadata;
use cot::config::{
+6 -5
View File
@@ -60,7 +60,7 @@ fn html_response(body: String, lang: Lang) -> cot::Result<Response> {
.into_response()
}
fn now() -> chrono::NaiveDateTime {
fn now_utc() -> chrono::NaiveDateTime {
chrono::Utc::now().naive_utc()
}
@@ -131,8 +131,8 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
comment: form.comment.filter(|s| !s.trim().is_empty()),
status: "new".to_string(),
client_id: None,
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
lead.save(&db).await?;
@@ -188,7 +188,8 @@ async fn client_portal(
};
let client_id = client.id.unwrap();
let today = chrono::Utc::now().date_naive();
let tz = crate::tz::load_tz(&db).await;
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");
@@ -277,7 +278,7 @@ async fn submit_feedback(
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
if visit.client_id.primary_key().unwrap() == client_id {
visit.client_feedback = Some(form.feedback);
visit.updated_at = now();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
}
+31
View File
@@ -0,0 +1,31 @@
use chrono::TimeZone;
use chrono_tz::Tz;
use cot::db::{Database, Model, query};
use crate::models::Setting;
const DEFAULT_TZ: &str = "UTC";
/// Load timezone from the database settings.
pub async fn load_tz(db: &Database) -> Tz {
let key = "timezone".to_string();
let tz_str = query!(Setting, $key == key)
.get(db)
.await
.ok()
.flatten()
.map(|s| s.value)
.unwrap_or_else(|| DEFAULT_TZ.to_string());
tz_str.parse::<Tz>().unwrap_or(Tz::UTC)
}
/// Current date+time in the configured timezone, returned as NaiveDateTime.
#[allow(dead_code)]
pub fn now_in_tz(tz: Tz) -> chrono::NaiveDateTime {
chrono::Utc::now().with_timezone(&tz).naive_local()
}
/// Today's date in the configured timezone.
pub fn today_in_tz(tz: Tz) -> chrono::NaiveDate {
chrono::Utc::now().with_timezone(&tz).date_naive()
}
+17 -1
View File
@@ -85,7 +85,23 @@
/* ── Forms ── */
.form-card { background: #fff; border-radius: 10px; padding: 1.25rem; border: 1px solid #eee; }
input, textarea, select { background-color: #fff; color: #333; }
input, textarea, select,
.input, .textarea, .select select {
background-color: #fff !important;
color: #333 !important;
border-color: #dbdbdb !important;
}
.label, label {
color: #363636 !important;
}
h1, h2, h3, h4, h5, h6, strong, b, p, span, div, a, td, th, li {
color: inherit;
}
.title, .subtitle, .content, .has-text-dark {
color: #363636 !important;
}
.has-text-grey { color: #7a7a7a !important; }
.notification { color: #333 !important; }
</style>
</head>
<body>
+1
View File
@@ -89,6 +89,7 @@
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: #333;
}
@media (max-width: 480px) {
.media-grid {
+9 -1
View File
@@ -20,7 +20,14 @@
{% endif %}
<style>
#calendar-wrap { overflow: hidden; }
.fc { font-size: 0.85rem; }
.fc { font-size: 0.85rem; color: #333; }
.fc .fc-daygrid-day-number,
.fc .fc-col-header-cell-cushion,
.fc .fc-list-day-text,
.fc .fc-list-day-side-text { color: #333 !important; }
.fc th, .fc td { color: #333; }
.fc .fc-toolbar-title { color: #333 !important; }
.fc .fc-button { color: #fff !important; }
.fc .fc-toolbar { flex-wrap: wrap; gap: 0.3rem; }
.fc .fc-toolbar-title { font-size: 1.1rem !important; }
.fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; }
@@ -74,6 +81,7 @@ document.addEventListener('DOMContentLoaded', function() {
const calendar = new FullCalendar.Calendar(calEl, {
locale: lang,
timeZone: '{{ timezone }}',
initialView: window.innerWidth < 768 ? 'listWeek' : 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
+6
View File
@@ -38,6 +38,12 @@
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_timezone }}</label>
<div class="control">
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
</form>
</div>
+78 -22
View File
@@ -39,36 +39,92 @@
<p style="color:#888;">{{ t.testimonials_empty }}</p>
{% else %}
{% for item in &testimonials %}
<div class="item-card">
<div class="item-card-header">
<div style="display:flex;align-items:center;gap:0.75rem;">
{% if item.image_path.is_some() %}
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
{% endif %}
<div>
<div style="font-size:0.95rem;line-height:1.5;">{{ item.text }}</div>
{% if let Some(note) = item.author_note.as_deref() %}
<div style="font-size:0.8rem;color:#888;margin-top:0.2rem;">{{ note }}</div>
<div class="item-card" id="card-{{ item.id.unwrap() }}">
<!-- View mode -->
<div class="tm-view" id="view-{{ item.id.unwrap() }}">
<div class="item-card-header">
<div style="display:flex;align-items:center;gap:0.75rem;">
{% if item.image_path.is_some() %}
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
{% endif %}
<div>
<div style="font-size:0.95rem;line-height:1.5;">{{ item.text }}</div>
{% if let Some(note) = item.author_note.as_deref() %}
<div style="font-size:0.8rem;color:#888;margin-top:0.2rem;">{{ note }}</div>
{% endif %}
</div>
</div>
<span class="badge {% if item.status == "active" %}badge-active{% else %}badge-archived{% endif %}">
{% if item.status == "active" %}{{ t.testimonials_status_active }}{% else %}{{ t.testimonials_status_hidden }}{% endif %}
</span>
</div>
<div class="item-card-actions">
<button type="button" class="button btn-sm is-info is-light" onclick="toggleEdit({{ item.id.unwrap() }})">{{ t.testimonials_edit }}</button>
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/toggle">
{% if item.status == "active" %}
<button type="submit" class="button btn-sm is-warning is-light">{{ t.action_archive }}</button>
{% else %}
<button type="submit" class="button btn-sm is-success is-light">{{ t.action_activate }}</button>
{% endif %}
</form>
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/delete" onsubmit="return confirm('Delete?')">
<button type="submit" class="button btn-sm is-danger is-light">{{ t.media_delete }}</button>
</form>
</div>
<span class="badge {% if item.status == "active" %}badge-active{% else %}badge-archived{% endif %}">
{% if item.status == "active" %}{{ t.testimonials_status_active }}{% else %}{{ t.testimonials_status_hidden }}{% endif %}
</span>
</div>
<div class="item-card-actions">
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/toggle">
{% if item.status == "active" %}
<button type="submit" class="button btn-sm is-warning is-light">{{ t.action_archive }}</button>
{% else %}
<button type="submit" class="button btn-sm is-success is-light">{{ t.action_activate }}</button>
<!-- Edit mode (hidden by default) -->
<div class="tm-edit" id="edit-{{ item.id.unwrap() }}" style="display:none;">
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/edit" enctype="multipart/form-data">
<div class="field">
<label class="label">{{ t.testimonials_text }}</label>
<div class="control">
<textarea class="input" name="text" rows="3" style="min-height:70px;resize:vertical;">{{ item.text }}</textarea>
</div>
</div>
<div class="field">
<label class="label">{{ t.testimonials_author_note }}</label>
<div class="control">
<input class="input" type="text" name="author_note" value="{{ item.author_note.as_deref().unwrap_or("") }}">
</div>
</div>
{% if item.image_path.is_some() %}
<div class="field">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;">
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
<label style="font-size:0.85rem;cursor:pointer;color:#888;">
<input type="checkbox" name="remove_image" value="1"> {{ t.testimonials_remove_image }}
</label>
</div>
</div>
{% endif %}
</form>
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/delete" onsubmit="return confirm('Delete?')">
<button type="submit" class="button btn-sm is-danger is-light">{{ t.media_delete }}</button>
<div class="field">
<label class="label">{{ t.testimonials_image }}</label>
<div class="control">
<input class="input" type="file" name="image" accept="image/*">
</div>
</div>
<div style="display:flex;gap:0.5rem;">
<button type="submit" class="button btn-sm is-primary">{{ t.testimonials_save }}</button>
<button type="button" class="button btn-sm is-light" onclick="toggleEdit({{ item.id.unwrap() }})">✕</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endif %}
<script>
function toggleEdit(id) {
var view = document.getElementById('view-' + id);
var edit = document.getElementById('edit-' + id);
if (edit.style.display === 'none') {
view.style.display = 'none';
edit.style.display = 'block';
} else {
view.style.display = 'block';
edit.style.display = 'none';
}
}
</script>
{% endblock %}