1use crate::router::{Html, IntoResponse, Response};
7use http::StatusCode;
8use minijinja::Environment;
9use chrono::DateTime;
10use chrono_tz::Tz;
11use std::sync::LazyLock;
12use crate::requests::Request as AppRequest;
13use crate::Config;
14use serde_json::{json, Value};
15
16use crate::tracing;
17
18static HTMX_SRC: LazyLock<String> = LazyLock::new(|| {
20 include_str!("../resources/js/htmx.min.js").to_string()
21});
22
23static CSS_SRC: LazyLock<String> = LazyLock::new(|| {
24 include_str!("../resources/css/style.css").to_string()
25});
26
27
28static EMBEDDED_TEMPLATES_GET: std::sync::OnceLock<fn(&str) -> Option<rust_embed::EmbeddedFile>> = std::sync::OnceLock::new();
29
30pub fn set_embedded_templates(f: fn(&str) -> Option<rust_embed::EmbeddedFile>) {
31 EMBEDDED_TEMPLATES_GET.set(f).ok();
32}
33
34pub static JINJA: LazyLock<Environment<'static>> = LazyLock::new(|| {
36 let mut env = Environment::new();
37
38 env.set_loader(|name| {
40 let cfg = Config::load();
41 if cfg.app_debug {
42 let path = format!("src/resources/views/{}", name);
43 if let Ok(content) = std::fs::read_to_string(&path) {
44 return Ok(Some(content));
45 }
46 }
47
48 let file = EMBEDDED_TEMPLATES_GET.get().and_then(|f| f(name));
50 if let Some(file) = file {
51 if let Ok(content) = std::str::from_utf8(&file.data) {
52 return Ok(Some(content.to_string()));
53 }
54 }
55
56 Ok(None)
57 });
58
59 env.add_filter("diff_for_humans", |value: String| -> String {
63 if let Ok(dt) = DateTime::parse_from_rfc3339(&value) {
64 let now = chrono::Utc::now();
65 let dt_utc = dt.with_timezone(&chrono::Utc);
66 let duration = now.signed_duration_since(dt_utc);
67 let seconds = duration.num_seconds();
68 if seconds < 0 {
69 let seconds = -seconds;
70 if seconds < 60 {
71 return "in a few seconds".to_string();
72 }
73 let minutes = seconds / 60;
74 if minutes < 60 {
75 return format!("in {} minute{}", minutes, if minutes > 1 { "s" } else { "" });
76 }
77 let hours = minutes / 60;
78 if hours < 24 {
79 return format!("in {} hour{}", hours, if hours > 1 { "s" } else { "" });
80 }
81 let days = hours / 24;
82 if days < 30 {
83 return format!("in {} day{}", days, if days > 1 { "s" } else { "" });
84 }
85 let months = days / 30;
86 if months < 12 {
87 return format!("in {} month{}", months, if months > 1 { "s" } else { "" });
88 }
89 let years = months / 12;
90 return format!("in {} year{}", years, if years > 1 { "s" } else { "" });
91 } else {
92 if seconds < 60 {
93 return "a few seconds ago".to_string();
94 }
95 let minutes = seconds / 60;
96 if minutes < 60 {
97 return format!("{} minute{} ago", minutes, if minutes > 1 { "s" } else { "" });
98 }
99 let hours = minutes / 60;
100 if hours < 24 {
101 return format!("{} hour{} ago", hours, if hours > 1 { "s" } else { "" });
102 }
103 let days = hours / 24;
104 if days < 30 {
105 return format!("{} day{} ago", days, if days > 1 { "s" } else { "" });
106 }
107 let months = days / 30;
108 if months < 12 {
109 return format!("{} month{} ago", months, if months > 1 { "s" } else { "" });
110 }
111 let years = months / 12;
112 return format!("{} year{} ago", years, if years > 1 { "s" } else { "" });
113 }
114 }
115 value
116 });
117
118 env.add_filter("format_date", |value: String, fmt: String| -> String {
120 let cfg = Config::load();
121 let tz_str = cfg.app_timezone.trim();
122 let tz: Tz = tz_str.parse().unwrap_or(chrono_tz::UTC);
123
124 if let Ok(dt) = DateTime::parse_from_rfc3339(&value) {
125 return dt.with_timezone(&tz).format(&fmt).to_string();
126 }
127 value
128 });
129
130 env.add_function("now", || -> String {
132 let cfg = Config::load();
133 let tz_str = cfg.app_timezone.trim();
134 let tz: Tz = tz_str.parse().unwrap_or(chrono_tz::UTC);
135
136 chrono::Utc::now().with_timezone(&tz).to_rfc3339()
137 });
138
139 env.add_function("htmx_js", || -> String {
141 HTMX_SRC.clone()
142 });
143
144 env.add_function("app_css", || -> String {
146 CSS_SRC.clone()
147 });
148
149 env
150});
151
152fn strip_html_comments(html: &str) -> String {
155 let mut result = String::with_capacity(html.len());
156 let mut remaining = html;
157
158 while let Some(start) = remaining.find("<!--") {
159 result.push_str(&remaining[..start]);
160 if let Some(end) = remaining[start..].find("-->") {
161 remaining = &remaining[start + end + 3..];
162 } else {
163 break;
165 }
166 }
167 result.push_str(remaining);
168 result
169}
170
171pub fn render(template: &str, context: minijinja::Value) -> Response {
173 render_internal(template, context)
174}
175
176pub fn render_to_string(template: &str, context: minijinja::Value) -> String {
177 match JINJA.get_template(template) {
178 Ok(tmpl) => tmpl.render(context).unwrap_or_else(|e| format!("Render error: {}", e)),
179 Err(e) => format!("Template error: {}", e),
180 }
181}
182
183pub fn view(req: &AppRequest, template: &str, ctx: minijinja::Value) -> Response {
185 let mut ctx_value = serde_json::to_value(&ctx).unwrap_or_else(|_| json!({}));
186
187 if !ctx_value.is_object() {
188 ctx_value = json!({});
189 }
190
191 let obj = ctx_value.as_object_mut().unwrap();
192
193 if !obj.contains_key("errors") { obj.insert("errors".to_string(), json!({})); }
195 if !obj.contains_key("old") { obj.insert("old".to_string(), json!({})); }
196 if !obj.contains_key("flash_success") { obj.insert("flash_success".to_string(), json!("")); }
197 if !obj.contains_key("flash_error") { obj.insert("flash_error".to_string(), json!("")); }
198
199 if let Some(success) = req.session.get::<String>("flash_success") {
200 obj.insert("flash_success".to_string(), json!(success));
201 req.session.remove("flash_success");
202 }
203 if let Some(error) = req.session.get::<String>("flash_error") {
204 obj.insert("flash_error".to_string(), json!(error));
205 req.session.remove("flash_error");
206 }
207 if let Some(errors) = req.session.get::<Value>("errors") {
208 obj.insert("errors".to_string(), errors);
209 req.session.remove("errors");
210 }
211 if let Some(old) = req.session.get::<Value>("old_input") {
212 obj.insert("old".to_string(), old);
213 }
214
215 if let Some(token) = req.session.get::<String>("_token") {
216 obj.insert("csrf_token".to_string(), json!(token));
217 }
218
219 let is_logged_in = req.session.get::<i64>("user_id").is_some();
220 obj.insert("auth".to_string(), json!(is_logged_in));
221
222 render_internal(template, minijinja::Value::from_serialize(obj))
223}
224
225fn render_internal(template: &str, context: minijinja::Value) -> Response {
226 let cfg = crate::Config::load();
227 tracing::debug!("Rendering template: {} (APP_DEBUG: {})", template, cfg.app_debug);
228
229 match JINJA.get_template(template) {
230 Ok(tmpl) => match tmpl.render(context.clone()) {
231 Ok(rendered) => {
232 let without_comments = strip_html_comments(&rendered);
234
235 let minified = without_comments
236 .lines()
237 .map(|line| line.trim())
238 .filter(|line| !line.is_empty())
239 .collect::<Vec<_>>()
240 .join(" ");
241
242 Html(minified).into_response()
243 },
244 Err(err) => {
245 tracing::error!("Gagal render template: {}", err);
246
247 if cfg.app_debug {
248 return (StatusCode::INTERNAL_SERVER_ERROR, format!("Render Error: {}", err)).into_response();
249 }
250
251 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response()
252 }
253 },
254 Err(err) => {
255 tracing::error!("Template tidak ditemukan: {}", err);
256
257 if cfg.app_debug {
258 return (StatusCode::NOT_FOUND, format!("Template Not Found: {}", err)).into_response();
259 }
260
261 (StatusCode::NOT_FOUND, "Not Found").into_response()
262 }
263 }
264}