1use axum::{
7 http::StatusCode,
8 response::{Html, IntoResponse, Response},
9};
10use minijinja::Environment;
11use chrono::DateTime;
12use chrono_humanize::HumanTime;
13use chrono_tz::Tz;
14use std::sync::LazyLock;
15use crate::requests::Request as AppRequest;
16use crate::Config;
17use serde_json::{json, Value};
18use regex::Regex;
19
20static HTMX_SRC: LazyLock<String> = LazyLock::new(|| {
22 include_str!("../resources/js/htmx.min.js").to_string()
23});
24
25static CSS_SRC: LazyLock<String> = LazyLock::new(|| {
26 include_str!("../resources/css/style.css").to_string()
27});
28
29
30pub static JINJA: LazyLock<Environment<'static>> = LazyLock::new(|| {
32 let mut env = Environment::new();
33
34 env.set_loader(|name| {
36 let path = format!("src/resources/views/{}", name);
37 if let Ok(content) = std::fs::read_to_string(&path) {
38 return Ok(Some(content));
39 }
40 Ok(None)
41 });
42
43 env.add_filter("diff_for_humans", |value: String| -> String {
47 if let Ok(dt) = DateTime::parse_from_rfc3339(&value) {
48 let ht = HumanTime::from(dt);
49 return ht.to_string();
50 }
51 value
52 });
53
54 env.add_filter("format_date", |value: String, fmt: String| -> String {
56 let cfg = Config::load();
57 let tz_str = cfg.app_timezone.trim();
58 let tz: Tz = tz_str.parse().unwrap_or(chrono_tz::UTC);
59
60 if let Ok(dt) = DateTime::parse_from_rfc3339(&value) {
61 return dt.with_timezone(&tz).format(&fmt).to_string();
62 }
63 value
64 });
65
66 env.add_function("now", || -> String {
68 let cfg = Config::load();
69 let tz_str = cfg.app_timezone.trim();
70 let tz: Tz = tz_str.parse().unwrap_or(chrono_tz::UTC);
71
72 chrono::Utc::now().with_timezone(&tz).to_rfc3339()
73 });
74
75 env.add_function("htmx_js", || -> String {
77 HTMX_SRC.clone()
78 });
79
80 env.add_function("app_css", || -> String {
82 CSS_SRC.clone()
83 });
84
85 env
86});
87
88pub fn render(template: &str, context: minijinja::Value) -> Response {
90 render_internal(template, context)
91}
92
93pub fn render_to_string(template: &str, context: minijinja::Value) -> String {
94 match JINJA.get_template(template) {
95 Ok(tmpl) => tmpl.render(context).unwrap_or_else(|e| format!("Render error: {}", e)),
96 Err(e) => format!("Template error: {}", e),
97 }
98}
99
100pub fn view(req: &AppRequest, template: &str, ctx: minijinja::Value) -> Response {
102 let mut ctx_value = serde_json::to_value(&ctx).unwrap_or_else(|_| json!({}));
103
104 if !ctx_value.is_object() {
105 ctx_value = json!({});
106 }
107
108 let obj = ctx_value.as_object_mut().unwrap();
109
110 if !obj.contains_key("errors") { obj.insert("errors".to_string(), json!({})); }
112 if !obj.contains_key("old") { obj.insert("old".to_string(), json!({})); }
113 if !obj.contains_key("flash_success") { obj.insert("flash_success".to_string(), json!("")); }
114 if !obj.contains_key("flash_error") { obj.insert("flash_error".to_string(), json!("")); }
115
116 if let Some(success) = req.session.get::<String>("flash_success") {
117 obj.insert("flash_success".to_string(), json!(success));
118 req.session.remove("flash_success");
119 }
120 if let Some(error) = req.session.get::<String>("flash_error") {
121 obj.insert("flash_error".to_string(), json!(error));
122 req.session.remove("flash_error");
123 }
124 if let Some(errors) = req.session.get::<Value>("errors") {
125 obj.insert("errors".to_string(), errors);
126 req.session.remove("errors");
127 }
128 if let Some(old) = req.session.get::<Value>("old_input") {
129 obj.insert("old".to_string(), old);
130 }
131
132 if let Some(token) = req.session.get::<String>("_token") {
133 obj.insert("csrf_token".to_string(), json!(token));
134 }
135
136 let is_logged_in = req.session.get::<i64>("user_id").is_some();
137 obj.insert("auth".to_string(), json!(is_logged_in));
138
139 render_internal(template, minijinja::Value::from_serialize(obj))
140}
141
142fn render_internal(template: &str, context: minijinja::Value) -> Response {
143 let cfg = crate::Config::load();
144 tracing::debug!("Rendering template: {} (APP_DEBUG: {})", template, cfg.app_debug);
145
146 match JINJA.get_template(template) {
147 Ok(tmpl) => match tmpl.render(context.clone()) {
148 Ok(rendered) => {
149 let re_comments = Regex::new(r"(?s)<!--.*?-->").unwrap();
151 let without_comments = re_comments.replace_all(&rendered, "");
152
153 let minified = without_comments
154 .lines()
155 .map(|line| line.trim())
156 .filter(|line| !line.is_empty())
157 .collect::<Vec<_>>()
158 .join(" ");
159
160 Html(minified).into_response()
161 },
162 Err(err) => {
163 tracing::error!("Gagal render template: {}", err);
164
165 if cfg.app_debug {
166 return (StatusCode::INTERNAL_SERVER_ERROR, format!("Render Error: {}", err)).into_response();
167 }
168
169 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response()
170 }
171 },
172 Err(err) => {
173 tracing::error!("Template tidak ditemukan: {}", err);
174
175 if cfg.app_debug {
176 return (StatusCode::NOT_FOUND, format!("Template Not Found: {}", err)).into_response();
177 }
178
179 (StatusCode::NOT_FOUND, "Not Found").into_response()
180 }
181 }
182}