Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7a89b431d | ||
|
|
1bee7a7940 |
Generated
+1
-1
@@ -3466,7 +3466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-petting"
|
||||
version = "0.1.15"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -9,6 +9,7 @@ RUN cargo build --release
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /data
|
||||
ENV WEB_PETTING_UPLOAD_DIR=/data/uploads
|
||||
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
|
||||
COPY static /app/static
|
||||
EXPOSE 3000
|
||||
|
||||
+68
-37
@@ -151,14 +151,14 @@ async fn save_uploaded_image(
|
||||
data: &[u8],
|
||||
) -> cot::Result<String> {
|
||||
if let Some(encoded) = transcode_uploaded_image(data, ext)? {
|
||||
let path = format!("{}/{}.jpg", upload_dir, file_id);
|
||||
tokio::fs::write(&path, &encoded)
|
||||
let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.jpg"));
|
||||
crate::uploads::write_db_file(&path, &encoded)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Ok(path)
|
||||
} else {
|
||||
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
||||
tokio::fs::write(&path, data)
|
||||
let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.{ext}"));
|
||||
crate::uploads::write_db_file(&path, data)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Ok(path)
|
||||
@@ -2140,8 +2140,8 @@ async fn media_upload_submit(
|
||||
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)
|
||||
let upload_dir = crate::uploads::media_dir(client_id, visit_id);
|
||||
crate::uploads::create_logical_dir(&upload_dir)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
@@ -2193,8 +2193,8 @@ async fn media_upload_submit(
|
||||
let file_path = if file_type == "photo" {
|
||||
save_uploaded_image(&upload_dir, file_id, &ext, &data).await?
|
||||
} else {
|
||||
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
||||
tokio::fs::write(&path, &data)
|
||||
let path = crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}"));
|
||||
crate::uploads::write_db_file(&path, &data)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
path
|
||||
@@ -2251,7 +2251,16 @@ async fn media_delete(
|
||||
let file_path = m.file_path.clone();
|
||||
m.status = "archived".to_string();
|
||||
m.save(&db).await?;
|
||||
let _ = tokio::fs::remove_file(&file_path).await;
|
||||
if let Err(err) = crate::uploads::remove_db_file(&file_path).await {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
media_id,
|
||||
db_path = %file_path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&file_path),
|
||||
error = %err,
|
||||
"failed to remove uploaded file"
|
||||
);
|
||||
}
|
||||
}
|
||||
let redirect_url = referer
|
||||
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
|
||||
@@ -2276,27 +2285,39 @@ async fn serve_upload(
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
|
||||
match tokio::fs::read(&media.file_path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"heic" | "heif" => "image/heic",
|
||||
"webp" => "image/webp",
|
||||
"mp4" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mkv" => "video/x-matroska",
|
||||
"webm" => "video/webm",
|
||||
_ => "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)
|
||||
let range = request
|
||||
.headers()
|
||||
.get("range")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
|
||||
match {
|
||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"heic" | "heif" => "image/heic",
|
||||
"webp" => "image/webp",
|
||||
"mp4" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mkv" => "video/x-matroska",
|
||||
"webm" => "video/webm",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
|
||||
} {
|
||||
Ok(response) => Ok(response),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
media_id,
|
||||
db_path = %media.file_path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&media.file_path),
|
||||
error = %err,
|
||||
"uploaded file is missing or unreadable"
|
||||
);
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2403,12 +2424,12 @@ async fn testimonial_add(
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let upload_dir = "uploads/testimonials";
|
||||
tokio::fs::create_dir_all(upload_dir)
|
||||
let upload_dir = crate::uploads::testimonials_dir();
|
||||
crate::uploads::create_logical_dir(&upload_dir)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let file_id = uuid::Uuid::new_v4();
|
||||
let path = save_uploaded_image(upload_dir, file_id, &ext, &data).await?;
|
||||
let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?;
|
||||
image_path = Some(path);
|
||||
}
|
||||
_ => {}
|
||||
@@ -2551,12 +2572,12 @@ async fn testimonial_edit(
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let upload_dir = "uploads/testimonials";
|
||||
tokio::fs::create_dir_all(upload_dir)
|
||||
let upload_dir = crate::uploads::testimonials_dir();
|
||||
crate::uploads::create_logical_dir(&upload_dir)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let file_id = uuid::Uuid::new_v4();
|
||||
let path = save_uploaded_image(upload_dir, file_id, &ext, &data).await?;
|
||||
let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?;
|
||||
new_image_path = Some(path);
|
||||
}
|
||||
_ => {}
|
||||
@@ -2597,7 +2618,7 @@ async fn serve_testimonial_image(
|
||||
Some(p) => p.clone(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
match tokio::fs::read(&path).await {
|
||||
match crate::uploads::read_db_file(&path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
@@ -2612,7 +2633,17 @@ async fn serve_testimonial_image(
|
||||
.insert("content-type", content_type.parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
testimonial_id = id,
|
||||
db_path = %path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&path),
|
||||
error = %err,
|
||||
"testimonial image is missing or unreadable"
|
||||
);
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ mod public;
|
||||
mod telegram;
|
||||
mod turnstile;
|
||||
mod tz;
|
||||
mod uploads;
|
||||
|
||||
use tracing_subscriber;
|
||||
|
||||
|
||||
+46
-23
@@ -276,6 +276,7 @@ async fn client_portal(
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.status == "active"
|
||||
&& m.client_id.primary_key().unwrap() == client_id
|
||||
&& m.visit_id
|
||||
.as_ref()
|
||||
.map(|fk| fk.primary_key().unwrap() == vid)
|
||||
@@ -369,7 +370,7 @@ async fn submit_feedback(
|
||||
|
||||
/// Serve media files for the client portal (no auth required, but only via token).
|
||||
async fn portal_media(
|
||||
_request: Request,
|
||||
request: Request,
|
||||
db: Database,
|
||||
Path((token, media_id)): Path<(String, i64)>,
|
||||
) -> cot::Result<Response> {
|
||||
@@ -393,27 +394,39 @@ async fn portal_media(
|
||||
}
|
||||
}
|
||||
|
||||
match tokio::fs::read(&media.file_path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"heic" | "heif" => "image/heic",
|
||||
"webp" => "image/webp",
|
||||
"mp4" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mkv" => "video/x-matroska",
|
||||
"webm" => "video/webm",
|
||||
_ => "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)
|
||||
let range = request
|
||||
.headers()
|
||||
.get("range")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
|
||||
match {
|
||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"heic" | "heif" => "image/heic",
|
||||
"webp" => "image/webp",
|
||||
"mp4" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mkv" => "video/x-matroska",
|
||||
"webm" => "video/webm",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
|
||||
} {
|
||||
Ok(response) => Ok(response),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
media_id,
|
||||
db_path = %media.file_path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&media.file_path),
|
||||
error = %err,
|
||||
"portal media file is missing or unreadable"
|
||||
);
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +443,7 @@ async fn serve_testimonial_image(
|
||||
Some(p) => p.clone(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
match tokio::fs::read(&path).await {
|
||||
match crate::uploads::read_db_file(&path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
@@ -447,7 +460,17 @@ async fn serve_testimonial_image(
|
||||
.insert("cache-control", "public, max-age=86400".parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
testimonial_id = id,
|
||||
db_path = %path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&path),
|
||||
error = %err,
|
||||
"testimonial image is missing or unreadable"
|
||||
);
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cot::response::Response;
|
||||
use cot::{Body, StatusCode};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
||||
const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR";
|
||||
|
||||
pub fn media_dir(client_id: i64, visit_id: i64) -> String {
|
||||
format!("{DEFAULT_UPLOAD_DIR}/{client_id}/{visit_id}")
|
||||
}
|
||||
|
||||
pub fn testimonials_dir() -> String {
|
||||
format!("{DEFAULT_UPLOAD_DIR}/testimonials")
|
||||
}
|
||||
|
||||
pub fn join_db_path(dir: &str, filename: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), filename)
|
||||
}
|
||||
|
||||
pub fn resolve_db_path(db_path: &str) -> PathBuf {
|
||||
let path = PathBuf::from(db_path);
|
||||
if path.is_absolute() {
|
||||
return path;
|
||||
}
|
||||
|
||||
let Some(upload_root) = std::env::var_os(UPLOAD_DIR_ENV) else {
|
||||
return path;
|
||||
};
|
||||
|
||||
let upload_root = PathBuf::from(upload_root);
|
||||
let logical_path = Path::new(db_path);
|
||||
match logical_path.strip_prefix(DEFAULT_UPLOAD_DIR) {
|
||||
Ok(stripped) => upload_root.join(stripped),
|
||||
Err(_) => upload_root.join(logical_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolved_display_path(db_path: &str) -> String {
|
||||
resolve_db_path(db_path).display().to_string()
|
||||
}
|
||||
|
||||
pub async fn create_logical_dir(db_dir: &str) -> std::io::Result<()> {
|
||||
tokio::fs::create_dir_all(resolve_db_path(db_dir)).await
|
||||
}
|
||||
|
||||
pub async fn write_db_file(db_path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
let physical_path = resolve_db_path(db_path);
|
||||
if let Some(parent) = physical_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(physical_path, data).await
|
||||
}
|
||||
|
||||
pub async fn read_db_file(db_path: &str) -> std::io::Result<Vec<u8>> {
|
||||
tokio::fs::read(resolve_db_path(db_path)).await
|
||||
}
|
||||
|
||||
enum ByteRange {
|
||||
Full,
|
||||
Partial { start: u64, end: u64 },
|
||||
Unsatisfiable,
|
||||
}
|
||||
|
||||
fn parse_byte_range(header: Option<&str>, file_len: u64) -> ByteRange {
|
||||
let Some(value) = header else {
|
||||
return ByteRange::Full;
|
||||
};
|
||||
let Some(spec) = value.strip_prefix("bytes=") else {
|
||||
return ByteRange::Unsatisfiable;
|
||||
};
|
||||
if spec.contains(',') || file_len == 0 {
|
||||
return ByteRange::Unsatisfiable;
|
||||
}
|
||||
|
||||
let Some((start, end)) = spec.split_once('-') else {
|
||||
return ByteRange::Unsatisfiable;
|
||||
};
|
||||
if start.is_empty() {
|
||||
let Ok(suffix_len) = end.parse::<u64>() else {
|
||||
return ByteRange::Unsatisfiable;
|
||||
};
|
||||
if suffix_len == 0 {
|
||||
return ByteRange::Unsatisfiable;
|
||||
}
|
||||
let start = file_len.saturating_sub(suffix_len);
|
||||
return ByteRange::Partial {
|
||||
start,
|
||||
end: file_len - 1,
|
||||
};
|
||||
}
|
||||
|
||||
let Ok(start) = start.parse::<u64>() else {
|
||||
return ByteRange::Unsatisfiable;
|
||||
};
|
||||
if start >= file_len {
|
||||
return ByteRange::Unsatisfiable;
|
||||
}
|
||||
let end = if end.is_empty() {
|
||||
file_len - 1
|
||||
} else {
|
||||
let Ok(end) = end.parse::<u64>() else {
|
||||
return ByteRange::Unsatisfiable;
|
||||
};
|
||||
end.min(file_len - 1)
|
||||
};
|
||||
if end < start {
|
||||
return ByteRange::Unsatisfiable;
|
||||
}
|
||||
|
||||
ByteRange::Partial { start, end }
|
||||
}
|
||||
|
||||
/// Read a file into an HTTP response, honoring a single `Range: bytes=...` request.
|
||||
pub async fn ranged_file_response(
|
||||
db_path: &str,
|
||||
content_type: &str,
|
||||
range_header: Option<&str>,
|
||||
) -> std::io::Result<Response> {
|
||||
let path = resolve_db_path(db_path);
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
let file_len = file.metadata().await?.len();
|
||||
let range = parse_byte_range(range_header, file_len);
|
||||
|
||||
let (status, body, content_range) = match range {
|
||||
ByteRange::Full => {
|
||||
let mut data = Vec::with_capacity(file_len as usize);
|
||||
file.read_to_end(&mut data).await?;
|
||||
(StatusCode::OK, data, None)
|
||||
}
|
||||
ByteRange::Partial { start, end } => {
|
||||
let range_len = end - start + 1;
|
||||
let mut data = vec![0; range_len as usize];
|
||||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||
file.read_exact(&mut data).await?;
|
||||
(
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
data,
|
||||
Some(format!("bytes {start}-{end}/{file_len}")),
|
||||
)
|
||||
}
|
||||
ByteRange::Unsatisfiable => {
|
||||
let mut response = Response::new(Body::fixed(Vec::<u8>::new()));
|
||||
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("accept-ranges", "bytes".parse().unwrap());
|
||||
response.headers_mut().insert(
|
||||
"content-range",
|
||||
format!("bytes */{file_len}").parse().unwrap(),
|
||||
);
|
||||
return Ok(response);
|
||||
}
|
||||
};
|
||||
|
||||
let content_len = body.len();
|
||||
let mut response = Response::new(Body::fixed(body));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", content_type.parse().unwrap());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("accept-ranges", "bytes".parse().unwrap());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-length", content_len.to_string().parse().unwrap());
|
||||
if let Some(content_range) = content_range {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-range", content_range.parse().unwrap());
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn remove_db_file(db_path: &str) -> std::io::Result<()> {
|
||||
tokio::fs::remove_file(resolve_db_path(db_path)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ByteRange, parse_byte_range};
|
||||
|
||||
#[test]
|
||||
fn parses_byte_ranges() {
|
||||
assert!(matches!(
|
||||
parse_byte_range(Some("bytes=10-19"), 100),
|
||||
ByteRange::Partial { start: 10, end: 19 }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_byte_range(Some("bytes=90-"), 100),
|
||||
ByteRange::Partial { start: 90, end: 99 }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_byte_range(Some("bytes=-10"), 100),
|
||||
ByteRange::Partial { start: 90, end: 99 }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_byte_range(Some("bytes=100-"), 100),
|
||||
ByteRange::Unsatisfiable
|
||||
));
|
||||
}
|
||||
}
|
||||
+27
-10
@@ -27,12 +27,15 @@
|
||||
{% for item in &items %}
|
||||
<div class="media-card">
|
||||
{% if item.media.file_type == "photo" %}
|
||||
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="photo">
|
||||
<img src="/admin/uploads/{{ item.media.id }}" alt="" loading="lazy">
|
||||
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="photo">
|
||||
<img src="/admin/uploads/{{ item.media.id.unwrap() }}" alt="" loading="lazy">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="video">
|
||||
<div class="video-thumb">🎬</div>
|
||||
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="video">
|
||||
<div class="video-thumb">
|
||||
<video src="/admin/uploads/{{ item.media.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
||||
<span class="video-play">▶</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="media-info">
|
||||
@@ -45,7 +48,7 @@
|
||||
{% if let Some(cap) = item.media.caption.as_deref() %}
|
||||
<div style="font-size:0.82rem;color:#666;margin-top:0.2rem;">{{ cap }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/media/{{ item.media.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="margin-top:0.3rem;">
|
||||
<form method="post" action="/admin/media/{{ item.media.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="margin-top:0.3rem;">
|
||||
<button class="button is-small is-danger is-outlined btn-sm">{{ t.media_delete }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -73,13 +76,27 @@
|
||||
display: block;
|
||||
}
|
||||
.media-card .video-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
background: #f0f0f0;
|
||||
background: #111;
|
||||
}
|
||||
.media-card .video-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
.media-card .video-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 5px #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-info {
|
||||
padding: 0.6rem 0.75rem;
|
||||
|
||||
@@ -113,12 +113,15 @@
|
||||
{% for m in &media %}
|
||||
<div class="visit-media-item">
|
||||
{% if m.file_type == "photo" %}
|
||||
<a href="/admin/uploads/{{ m.id }}" data-lightbox="photo">
|
||||
<img src="/admin/uploads/{{ m.id }}" alt="" loading="lazy">
|
||||
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="photo">
|
||||
<img src="/admin/uploads/{{ m.id.unwrap() }}" alt="" loading="lazy">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/admin/uploads/{{ m.id }}" data-lightbox="video">
|
||||
<div class="video-thumb-sm">🎬</div>
|
||||
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="video">
|
||||
<div class="video-thumb-sm">
|
||||
<video src="/admin/uploads/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
||||
<span class="video-play">▶</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if let Some(cap) = m.caption.as_deref() %}
|
||||
@@ -137,7 +140,7 @@
|
||||
<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>
|
||||
<form id="visit-media-delete-{{ m.id.unwrap() }}" method="post" action="/admin/media/{{ m.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');"></form>
|
||||
{% endfor %}
|
||||
|
||||
<hr style="margin:1rem 0;">
|
||||
@@ -236,13 +239,27 @@
|
||||
display: block;
|
||||
}
|
||||
.visit-media-item .video-thumb-sm {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
background: #f0f0f0;
|
||||
background: #111;
|
||||
}
|
||||
.visit-media-item .video-thumb-sm video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
.visit-media-item .video-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 4px #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.visit-media-item .media-cap {
|
||||
font-size: 0.7rem;
|
||||
|
||||
@@ -52,8 +52,16 @@
|
||||
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
|
||||
}
|
||||
.media-row .vid-thumb {
|
||||
width: 80px; height: 60px; border-radius: 6px; background: #f0f0f0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.5rem;
|
||||
position: relative; width: 80px; height: 60px; border-radius: 6px;
|
||||
overflow: hidden; background: #111;
|
||||
}
|
||||
.media-row .vid-thumb video {
|
||||
width: 100%; height: 100%; display: block; object-fit: cover;
|
||||
}
|
||||
.media-row .video-play {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
color: white; font-size: 1.35rem; line-height: 1;
|
||||
text-shadow: 0 1px 4px #000; pointer-events: none;
|
||||
}
|
||||
.feedback-form { margin-top: 0.6rem; }
|
||||
.feedback-form textarea {
|
||||
@@ -147,12 +155,15 @@
|
||||
<div class="media-row">
|
||||
{% for m in &pv.media %}
|
||||
{% if m.file_type == "photo" %}
|
||||
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="photo">
|
||||
<img src="/client/{{ client.media_token }}/media/{{ m.id }}" alt="" loading="lazy">
|
||||
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="photo">
|
||||
<img src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" alt="" loading="lazy">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="video">
|
||||
<div class="vid-thumb">🎬</div>
|
||||
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="video">
|
||||
<div class="vid-thumb">
|
||||
<video src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
||||
<span class="video-play">▶</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user