Skip to main content

max / makenotwork

4.2 KB · 126 lines History Blame Raw
1 //! Project embed handlers.
2
3 use axum::{
4 extract::{Path, State},
5 response::{Html, IntoResponse, Response},
6 };
7
8 use crate::{
9 db::{self},
10 error::{AppError, Result},
11 AppState,
12 };
13
14 use super::item::set_embed_headers;
15
16 #[tracing::instrument(skip_all, name = "embed::project_card")]
17 /// GET /embed/p/{project_slug}/card
18 pub(super) async fn project_card(
19 State(state): State<AppState>,
20 Path(project_slug): Path<String>,
21 ) -> Result<Response> {
22 let project = db::projects::get_public_project_by_slug_str(&state.db, &project_slug)
23 .await?
24 .ok_or(AppError::NotFound)?;
25
26 let user = db::users::get_user_by_id(&state.db, project.user_id)
27 .await?
28 .ok_or(AppError::NotFound)?;
29
30 if user.is_suspended() || user.is_deactivated() {
31 return Err(AppError::NotFound);
32 }
33
34 let items = db::items::get_public_items_by_project(&state.db, project.id).await?;
35 let item_count = items.len();
36
37 let description_excerpt: String = project.description.as_deref()
38 .unwrap_or("")
39 .chars()
40 .take(150)
41 .collect();
42
43 let project_url = format!("{}/p/{}", state.config.host_url, project_slug);
44 let profile_url = format!("{}/u/{}", state.config.host_url, user.username);
45 let creator_name = user.display_name.as_deref().unwrap_or(&user.username);
46 let category_label = project.project_type.label();
47
48 let cover_html = project.cover_image_url.as_ref()
49 .map(|url| format!(r#"<img src="{}" alt="" style="width:100%;height:100%;object-fit:cover;">"#, html_escape(url)))
50 .unwrap_or_default();
51
52 let html = format!(
53 r#"<!doctype html>
54 <html lang="en">
55 <head>
56 <meta charset="UTF-8">
57 <meta name="viewport" content="width=device-width, initial-scale=1.0">
58 <title>{title} — Makenot.work</title>
59 <style>
60 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
61 body {{
62 font-family: Lato, -apple-system, sans-serif;
63 background: #ede8e1; color: #3d3530;
64 display: flex; align-items: center; justify-content: center;
65 min-height: 100vh; padding: 12px;
66 }}
67 .card {{
68 background: #fff; border-radius: 8px; overflow: hidden;
69 border: 1px solid rgba(61,53,48,0.1);
70 max-width: 350px; width: 100%;
71 }}
72 .cover {{ width: 100%; height: 160px; background: #f5f0eb; }}
73 .body {{ padding: 14px; }}
74 .title {{ font-family: 'Young Serif', Georgia, serif; font-size: 15px; font-weight: bold; margin-bottom: 4px; }}
75 .creator {{ font-family: 'IBM Plex Mono', monospace; font-size: 11px; opacity: 0.6; margin-bottom: 6px; }}
76 .creator a {{ color: inherit; text-decoration: none; }}
77 .creator a:hover {{ text-decoration: underline; }}
78 .meta {{ font-size: 11px; opacity: 0.6; margin-bottom: 8px; font-family: 'IBM Plex Mono', monospace; }}
79 .desc {{ font-size: 12px; opacity: 0.8; line-height: 1.4; margin-bottom: 12px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }}
80 .view-btn {{
81 display: inline-block; background: #6c5ce7; color: #fff; border-radius: 4px;
82 padding: 8px 14px; font-size: 12px; font-weight: 600;
83 text-decoration: none; white-space: nowrap;
84 }}
85 .view-btn:hover {{ background: #5a4bd6; }}
86 </style>
87 </head>
88 <body>
89 <div class="card">
90 <div class="cover">{cover}</div>
91 <div class="body">
92 <div class="title">{title}</div>
93 <div class="creator">by <a href="{profile_url}" target="_blank" rel="noopener">{creator}</a></div>
94 <div class="meta">{item_count} items &middot; {category}</div>
95 {desc_html}
96 <a class="view-btn" href="{project_url}" target="_blank" rel="noopener">View</a>
97 </div>
98 </div>
99 </body>
100 </html>"#,
101 title = html_escape(&project.title),
102 cover = cover_html,
103 profile_url = profile_url,
104 creator = html_escape(creator_name),
105 item_count = item_count,
106 category = category_label,
107 desc_html = if description_excerpt.is_empty() {
108 String::new()
109 } else {
110 format!(r#"<div class="desc">{}</div>"#, html_escape(&description_excerpt))
111 },
112 project_url = project_url,
113 );
114
115 let mut response = Html(html).into_response();
116 set_embed_headers(&mut response);
117 Ok(response)
118 }
119
120 fn html_escape(s: &str) -> String {
121 s.replace('&', "&amp;")
122 .replace('<', "&lt;")
123 .replace('>', "&gt;")
124 .replace('"', "&quot;")
125 }
126