Added reviews. Added pricing.
Build and Publish / Build and Publish Docker Image (push) Successful in 6m27s
Build and Publish / Build and Publish Docker Image (push) Successful in 6m27s
This commit is contained in:
+390
-128
@@ -1,15 +1,15 @@
|
||||
use cot::Template;
|
||||
use cot::db::{Auto, Database, ForeignKey, Model, query};
|
||||
use cot::html::Html;
|
||||
use cot::request::extractors::Path;
|
||||
use cot::request::Request;
|
||||
use cot::request::extractors::Path;
|
||||
use cot::response::{IntoResponse, Redirect, Response};
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::Template;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::i18n::{Lang, Translations};
|
||||
use crate::models::{Client, Lead, Media, Setting, User, Visit};
|
||||
use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit};
|
||||
use crate::telegram;
|
||||
|
||||
const SESSION_USER_ID: &str = "user_id";
|
||||
@@ -81,16 +81,18 @@ fn has_query_flag(request: &Request, flag: &str) -> bool {
|
||||
request
|
||||
.uri()
|
||||
.query()
|
||||
.map(|q| q.split('&').any(|p| p == format!("{}=1", flag) || p == flag))
|
||||
.map(|q| {
|
||||
q.split('&')
|
||||
.any(|p| p == format!("{}=1", flag) || p == flag)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Soft pastel palette for client calendar colors.
|
||||
const CLIENT_COLORS: &[&str] = &[
|
||||
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e",
|
||||
"#8e6bbf", "#5cb8a5", "#c77c4f", "#a3729a", "#6b9e5e",
|
||||
"#d48b6c", "#7a8fc4", "#c45d7c", "#5eab7d", "#b8864e",
|
||||
"#9476b8", "#6aafb5", "#d4785e", "#7f8e5b", "#b56c9e",
|
||||
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f",
|
||||
"#a3729a", "#6b9e5e", "#d48b6c", "#7a8fc4", "#c45d7c", "#5eab7d", "#b8864e", "#9476b8",
|
||||
"#6aafb5", "#d4785e", "#7f8e5b", "#b56c9e",
|
||||
];
|
||||
|
||||
fn rand_client_color() -> &'static str {
|
||||
@@ -136,11 +138,7 @@ async fn get_admin_name(session: &Session) -> Option<String> {
|
||||
|
||||
/// Get admin user ID from session.
|
||||
async fn get_admin_id(session: &Session) -> Option<i64> {
|
||||
session
|
||||
.get::<i64>(SESSION_USER_ID)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
session.get::<i64>(SESSION_USER_ID).await.ok().flatten()
|
||||
}
|
||||
|
||||
/// Redirect to login if not authenticated.
|
||||
@@ -333,11 +331,7 @@ async fn has_any_admin(db: &Database) -> cot::Result<bool> {
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn login_page(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn login_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
if get_admin_name(&session).await.is_some() {
|
||||
return Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response();
|
||||
@@ -380,11 +374,7 @@ struct SetupForm {
|
||||
password_confirm: String,
|
||||
}
|
||||
|
||||
async fn setup_submit(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn setup_submit(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let (lang, form): (_, SetupForm) = parse_form_from_request(request).await?;
|
||||
|
||||
// Block if admins already exist
|
||||
@@ -431,11 +421,7 @@ struct LoginForm {
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn login_submit(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn login_submit(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let (lang, form): (_, LoginForm) = parse_form_from_request(request).await?;
|
||||
|
||||
let login = form.login.clone();
|
||||
@@ -450,9 +436,7 @@ async fn login_submit(
|
||||
.as_deref()
|
||||
.unwrap_or(&user.login)
|
||||
.to_string();
|
||||
session
|
||||
.insert(SESSION_USER_ID, user.id.unwrap())
|
||||
.await?;
|
||||
session.insert(SESSION_USER_ID, user.id.unwrap()).await?;
|
||||
session.insert(SESSION_USER_NAME, display).await?;
|
||||
return Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response();
|
||||
}
|
||||
@@ -477,11 +461,7 @@ async fn logout(request: Request, session: Session) -> cot::Result<Response> {
|
||||
// GET Handlers (protected)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn admin_index(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn admin_index(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,
|
||||
@@ -503,7 +483,9 @@ async fn admin_index(
|
||||
client_name: client.map(|c| c.name.clone()).unwrap_or_default(),
|
||||
client_phone: client.and_then(|c| c.phone.clone()).unwrap_or_default(),
|
||||
client_address: client.and_then(|c| c.address.clone()).unwrap_or_default(),
|
||||
client_color: client.and_then(|c| c.color.clone()).unwrap_or_else(|| "#7c6ed4".to_string()),
|
||||
client_color: client
|
||||
.and_then(|c| c.color.clone())
|
||||
.unwrap_or_else(|| "#7c6ed4".to_string()),
|
||||
visit: v.clone(),
|
||||
}
|
||||
})
|
||||
@@ -520,8 +502,11 @@ async fn admin_index(
|
||||
})
|
||||
.map(|v| {
|
||||
let cid: i64 = v.client_id.primary_key().unwrap();
|
||||
let client_name = clients.iter().find(|c| c.id.unwrap() == cid)
|
||||
.map(|c| c.name.clone()).unwrap_or_default();
|
||||
let client_name = clients
|
||||
.iter()
|
||||
.find(|c| c.id.unwrap() == cid)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
RecentFeedback {
|
||||
visit_id: v.id.unwrap(),
|
||||
client_name,
|
||||
@@ -543,11 +528,7 @@ async fn admin_index(
|
||||
html_response(body, lang)
|
||||
}
|
||||
|
||||
async fn leads_page(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn leads_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,
|
||||
@@ -570,11 +551,7 @@ async fn leads_page(
|
||||
html_response(body, lang)
|
||||
}
|
||||
|
||||
async fn clients_page(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn clients_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,
|
||||
@@ -597,10 +574,7 @@ async fn clients_page(
|
||||
html_response(body, lang)
|
||||
}
|
||||
|
||||
async fn client_new_page(
|
||||
request: Request,
|
||||
session: Session,
|
||||
) -> cot::Result<Response> {
|
||||
async fn client_new_page(request: Request, session: Session) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
let admin_name = match require_auth(&session, lang).await {
|
||||
Ok(name) => name,
|
||||
@@ -642,7 +616,9 @@ async fn client_edit_page(
|
||||
};
|
||||
let client = match query!(Client, $id == client_id).get(&db).await? {
|
||||
Some(c) => c,
|
||||
None => return Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response(),
|
||||
None => {
|
||||
return Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response();
|
||||
}
|
||||
};
|
||||
let t = lang.t();
|
||||
let action_url = format!("/admin/clients/{}/save", client.id);
|
||||
@@ -694,11 +670,7 @@ async fn client_edit_submit(
|
||||
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
async fn users_page(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn users_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,
|
||||
@@ -716,11 +688,7 @@ async fn users_page(
|
||||
html_response(body, lang)
|
||||
}
|
||||
|
||||
async fn settings_page(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn settings_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,
|
||||
@@ -750,11 +718,7 @@ struct AddUserForm {
|
||||
password_confirm: String,
|
||||
}
|
||||
|
||||
async fn add_user(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn add_user(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let (lang, form): (_, AddUserForm) = parse_form_from_request(request).await?;
|
||||
let admin_name = match require_auth(&session, lang).await {
|
||||
Ok(name) => name,
|
||||
@@ -805,13 +769,10 @@ struct SettingsForm {
|
||||
telegram_bot_token: String,
|
||||
telegram_chat_id: String,
|
||||
contact_info: String,
|
||||
pricing_info: String,
|
||||
}
|
||||
|
||||
async fn save_settings(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let (lang, form): (_, SettingsForm) = parse_form_from_request(request).await?;
|
||||
let admin_name = match require_auth(&session, lang).await {
|
||||
Ok(name) => name,
|
||||
@@ -822,6 +783,7 @@ async fn save_settings(
|
||||
("telegram_bot_token", form.telegram_bot_token),
|
||||
("telegram_chat_id", form.telegram_chat_id),
|
||||
("contact_info", form.contact_info),
|
||||
("pricing_info", form.pricing_info),
|
||||
] {
|
||||
let k = key.to_string();
|
||||
let existing = query!(Setting, $key == k).get(&db).await?;
|
||||
@@ -996,11 +958,7 @@ struct AddLeadForm {
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
async fn add_lead(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn add_lead(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let (lang, form): (_, AddLeadForm) = parse_form_from_request(request).await?;
|
||||
if let Err(resp) = require_auth(&session, lang).await {
|
||||
return Ok(resp);
|
||||
@@ -1040,11 +998,7 @@ struct AddClientForm {
|
||||
color: Option<String>,
|
||||
}
|
||||
|
||||
async fn add_client(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn add_client(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let (lang, form): (_, AddClientForm) = parse_form_from_request(request).await?;
|
||||
if let Err(resp) = require_auth(&session, lang).await {
|
||||
return Ok(resp);
|
||||
@@ -1072,10 +1026,7 @@ async fn add_client(
|
||||
// Schedule Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn schedule_page(
|
||||
request: Request,
|
||||
session: Session,
|
||||
) -> cot::Result<Response> {
|
||||
async fn schedule_page(request: Request, session: Session) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
let admin_name = match require_auth(&session, lang).await {
|
||||
Ok(name) => name,
|
||||
@@ -1159,9 +1110,7 @@ async fn schedule_events(
|
||||
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("");
|
||||
let client_address = client.and_then(|c| c.address.as_deref()).unwrap_or("");
|
||||
let client_color = client
|
||||
.and_then(|c| c.color.as_deref())
|
||||
.unwrap_or("#7c6ed4");
|
||||
let client_color = client.and_then(|c| c.color.as_deref()).unwrap_or("#7c6ed4");
|
||||
let admin_name = user
|
||||
.map(|u| u.display_name.as_deref().unwrap_or(&u.login))
|
||||
.unwrap_or("?");
|
||||
@@ -1276,7 +1225,9 @@ async fn schedule_edit_page(
|
||||
};
|
||||
let visit = match query!(Visit, $id == visit_id).get(&db).await? {
|
||||
Some(v) => v,
|
||||
None => return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(),
|
||||
None => {
|
||||
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||
}
|
||||
};
|
||||
let clients = query!(Client, $status == "active").all(&db).await?;
|
||||
let users = query!(User, $status == "active").all(&db).await?;
|
||||
@@ -1395,11 +1346,7 @@ async fn visit_set_cancel(
|
||||
// Media Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn media_page(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<Response> {
|
||||
async fn media_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,
|
||||
@@ -1432,10 +1379,14 @@ async fn media_page(
|
||||
.map(|m| {
|
||||
let cid: i64 = m.client_id.primary_key().unwrap();
|
||||
let client = clients_all.iter().find(|c| c.id.unwrap() == cid);
|
||||
let visit_date = m.visit_id.as_ref().and_then(|fk| {
|
||||
let vid: i64 = fk.primary_key().unwrap();
|
||||
visits_all.iter().find(|v| v.id.unwrap() == vid)
|
||||
}).map(|v| v.visit_date.to_string());
|
||||
let visit_date = m
|
||||
.visit_id
|
||||
.as_ref()
|
||||
.and_then(|fk| {
|
||||
let vid: i64 = fk.primary_key().unwrap();
|
||||
visits_all.iter().find(|v| v.id.unwrap() == vid)
|
||||
})
|
||||
.map(|v| v.visit_date.to_string());
|
||||
MediaItem {
|
||||
client_name: client.map(|c| c.name.clone()).unwrap_or_default(),
|
||||
visit_date,
|
||||
@@ -1444,7 +1395,10 @@ async fn media_page(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let active_clients = clients_all.into_iter().filter(|c| c.status == "active").collect();
|
||||
let active_clients = clients_all
|
||||
.into_iter()
|
||||
.filter(|c| c.status == "active")
|
||||
.collect();
|
||||
|
||||
let body = MediaTemplate {
|
||||
t: lang.t(),
|
||||
@@ -1476,7 +1430,10 @@ async fn media_upload_page(
|
||||
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();
|
||||
let visit_label = format!("{} {} — {}", visit.visit_date, visit.time_start, visit.time_end);
|
||||
let visit_label = format!(
|
||||
"{} {} — {}",
|
||||
visit.visit_date, visit.time_start, visit.time_end
|
||||
);
|
||||
|
||||
let body = MediaUploadTemplate {
|
||||
t: lang.t(),
|
||||
@@ -1492,11 +1449,11 @@ async fn media_upload_page(
|
||||
|
||||
fn extract_boundary(request: &Request) -> Option<String> {
|
||||
let ct = request.headers().get("content-type")?.to_str().ok()?;
|
||||
ct.split(';')
|
||||
.find_map(|part| {
|
||||
let part = part.trim();
|
||||
part.strip_prefix("boundary=").map(|b| b.trim_matches('"').to_string())
|
||||
})
|
||||
ct.split(';').find_map(|part| {
|
||||
let part = part.trim();
|
||||
part.strip_prefix("boundary=")
|
||||
.map(|b| b.trim_matches('"').to_string())
|
||||
})
|
||||
}
|
||||
|
||||
async fn media_upload_submit(
|
||||
@@ -1521,20 +1478,30 @@ async fn media_upload_submit(
|
||||
let client_id: i64 = visit.client_id.primary_key().unwrap();
|
||||
|
||||
let bytes = request.into_body().into_bytes().await?;
|
||||
let stream = futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
|
||||
let stream =
|
||||
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
|
||||
let mut multipart = multer::Multipart::new(stream, boundary);
|
||||
|
||||
let upload_dir = format!("uploads/{}/{}", client_id, visit_id);
|
||||
tokio::fs::create_dir_all(&upload_dir).await.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
tokio::fs::create_dir_all(&upload_dir)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let mut caption = String::new();
|
||||
let mut saved_files: Vec<(String, String)> = Vec::new(); // (path, file_type)
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| cot::Error::internal(e.to_string()))? {
|
||||
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();
|
||||
|
||||
if field_name == "caption" {
|
||||
caption = field.text().await.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
caption = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1559,17 +1526,26 @@ async fn media_upload_submit(
|
||||
let file_id = uuid::Uuid::new_v4();
|
||||
let file_path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
||||
|
||||
let data = field.bytes().await.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
tokio::fs::write(&file_path, &data).await.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
tokio::fs::write(&file_path, &data)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
saved_files.push((file_path, file_type.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let caption_opt = if caption.trim().is_empty() { None } else { Some(caption) };
|
||||
let caption_opt = if caption.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(caption)
|
||||
};
|
||||
|
||||
for (path, ftype) in saved_files {
|
||||
let mut media = Media {
|
||||
@@ -1638,7 +1614,224 @@ async fn serve_upload(
|
||||
};
|
||||
let body = cot::Body::fixed(data);
|
||||
let mut resp = Response::new(body);
|
||||
resp.headers_mut().insert("content-type", content_type.parse().unwrap());
|
||||
resp.headers_mut()
|
||||
.insert("content-type", content_type.parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Testimonials
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/testimonials.html")]
|
||||
struct TestimonialsTemplate<'a> {
|
||||
t: &'a Translations,
|
||||
lang: Lang,
|
||||
admin_name: String,
|
||||
testimonials: Vec<Testimonial>,
|
||||
}
|
||||
|
||||
async fn testimonials_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 mut testimonials = Testimonial::objects().all(&db).await?;
|
||||
testimonials.sort_by(|a, b| {
|
||||
a.sort_order
|
||||
.cmp(&b.sort_order)
|
||||
.then(b.id.unwrap().cmp(&a.id.unwrap()))
|
||||
});
|
||||
|
||||
let body = TestimonialsTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
admin_name,
|
||||
testimonials,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
}
|
||||
|
||||
async fn testimonial_add(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> 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 image_path: Option<String> = None;
|
||||
|
||||
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()))?;
|
||||
}
|
||||
"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()))?;
|
||||
image_path = Some(path);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !text.trim().is_empty() {
|
||||
let max_order: i32 = Testimonial::objects()
|
||||
.all(&db)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|t| t.sort_order)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mut testimonial = Testimonial {
|
||||
id: Auto::auto(),
|
||||
text: text.trim().to_string(),
|
||||
author_note: if author_note.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(author_note.trim().to_string())
|
||||
},
|
||||
image_path,
|
||||
status: "active".to_string(),
|
||||
sort_order: max_order + 1,
|
||||
created_at: now(),
|
||||
};
|
||||
testimonial.save(&db).await?;
|
||||
}
|
||||
|
||||
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
async fn testimonial_toggle(
|
||||
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);
|
||||
}
|
||||
if let Some(mut t) = query!(Testimonial, $id == id).get(&db).await? {
|
||||
t.status = if t.status == "active" {
|
||||
"hidden".to_string()
|
||||
} else {
|
||||
"active".to_string()
|
||||
};
|
||||
t.save(&db).await?;
|
||||
}
|
||||
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
async fn testimonial_delete(
|
||||
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);
|
||||
}
|
||||
if let Some(mut t) = query!(Testimonial, $id == id).get(&db).await? {
|
||||
t.status = "deleted".to_string();
|
||||
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,
|
||||
db: Database,
|
||||
Path(id): Path<i64>,
|
||||
) -> cot::Result<Response> {
|
||||
let testimonial = match query!(Testimonial, $id == id).get(&db).await? {
|
||||
Some(t) => t,
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
let path = match &testimonial.image_path {
|
||||
Some(p) => p.clone(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"webp" => "image/webp",
|
||||
"heic" | "heif" => "image/heic",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
let body = cot::Body::fixed(data);
|
||||
let mut resp = Response::new(body);
|
||||
resp.headers_mut()
|
||||
.insert("content-type", content_type.parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
@@ -1675,19 +1868,55 @@ pub fn admin_router() -> Router {
|
||||
Route::with_handler_and_name("/clients", clients_page, "admin-clients"),
|
||||
Route::with_handler_and_name("/clients/new", client_new_page, "admin-client-new"),
|
||||
Route::with_handler_and_name("/clients/add", add_client, "admin-client-add"),
|
||||
Route::with_handler_and_name("/clients/{client_id}/edit", client_edit_page, "admin-client-edit"),
|
||||
Route::with_handler_and_name("/clients/{client_id}/save", client_edit_submit, "admin-client-edit-submit"),
|
||||
Route::with_handler_and_name("/clients/{client_id}/archive", client_archive, "admin-client-archive"),
|
||||
Route::with_handler_and_name("/clients/{client_id}/activate", client_activate, "admin-client-activate"),
|
||||
Route::with_handler_and_name(
|
||||
"/clients/{client_id}/edit",
|
||||
client_edit_page,
|
||||
"admin-client-edit",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/clients/{client_id}/save",
|
||||
client_edit_submit,
|
||||
"admin-client-edit-submit",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/clients/{client_id}/archive",
|
||||
client_archive,
|
||||
"admin-client-archive",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/clients/{client_id}/activate",
|
||||
client_activate,
|
||||
"admin-client-activate",
|
||||
),
|
||||
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"),
|
||||
Route::with_handler_and_name("/schedule/create", schedule_create, "admin-schedule-create"),
|
||||
Route::with_handler_and_name("/schedule/{visit_id}/edit", schedule_edit_page, "admin-visit-edit"),
|
||||
Route::with_handler_and_name("/schedule/{visit_id}/save", schedule_edit_submit, "admin-visit-save"),
|
||||
Route::with_handler_and_name("/schedule/{visit_id}/delete", visit_delete, "admin-visit-delete"),
|
||||
Route::with_handler_and_name("/schedule/{visit_id}/done", visit_set_done, "admin-visit-done"),
|
||||
Route::with_handler_and_name("/schedule/{visit_id}/cancel", visit_set_cancel, "admin-visit-cancel"),
|
||||
Route::with_handler_and_name(
|
||||
"/schedule/{visit_id}/edit",
|
||||
schedule_edit_page,
|
||||
"admin-visit-edit",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/schedule/{visit_id}/save",
|
||||
schedule_edit_submit,
|
||||
"admin-visit-save",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/schedule/{visit_id}/delete",
|
||||
visit_delete,
|
||||
"admin-visit-delete",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/schedule/{visit_id}/done",
|
||||
visit_set_done,
|
||||
"admin-visit-done",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/schedule/{visit_id}/cancel",
|
||||
visit_set_cancel,
|
||||
"admin-visit-cancel",
|
||||
),
|
||||
Route::with_handler_and_name("/users", users_page, "admin-users"),
|
||||
Route::with_handler_and_name("/users/add", add_user, "admin-user-add"),
|
||||
Route::with_handler_and_name(
|
||||
@@ -1701,10 +1930,43 @@ pub fn admin_router() -> Router {
|
||||
"admin-user-activate",
|
||||
),
|
||||
Route::with_handler_and_name("/media", media_page, "admin-media"),
|
||||
Route::with_handler_and_name("/media/{visit_id}/upload", media_upload_page, "admin-media-upload"),
|
||||
Route::with_handler_and_name("/media/{visit_id}/upload/submit", media_upload_submit, "admin-media-upload-submit"),
|
||||
Route::with_handler_and_name("/media/{media_id}/delete", media_delete, "admin-media-delete"),
|
||||
Route::with_handler_and_name(
|
||||
"/media/{visit_id}/upload",
|
||||
media_upload_page,
|
||||
"admin-media-upload",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/media/{visit_id}/upload/submit",
|
||||
media_upload_submit,
|
||||
"admin-media-upload-submit",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/media/{media_id}/delete",
|
||||
media_delete,
|
||||
"admin-media-delete",
|
||||
),
|
||||
Route::with_handler_and_name("/uploads/{media_id}", serve_upload, "admin-uploads"),
|
||||
Route::with_handler_and_name("/testimonials", testimonials_page, "admin-testimonials"),
|
||||
Route::with_handler_and_name(
|
||||
"/testimonials/add",
|
||||
testimonial_add,
|
||||
"admin-testimonial-add",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/testimonials/{id}/toggle",
|
||||
testimonial_toggle,
|
||||
"admin-testimonial-toggle",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/testimonials/{id}/delete",
|
||||
testimonial_delete,
|
||||
"admin-testimonial-delete",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/testimonials/{id}/image",
|
||||
serve_testimonial_image,
|
||||
"admin-testimonial-image",
|
||||
),
|
||||
Route::with_handler_and_name("/settings", settings_page, "admin-settings-get"),
|
||||
Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"),
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user