This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+111
@@ -1805,6 +1805,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 +2068,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,
|
||||
|
||||
@@ -196,6 +196,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,
|
||||
@@ -458,6 +461,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: "Конвертировать",
|
||||
@@ -653,6 +659,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",
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user