1use super::{archive, engine, store, EditTheme, Kind, Revision, Template, Theme, ThemeError};
5use laterite_admin::axum::extract::{Form, Path, State};
6use laterite_admin::axum::http::{header, StatusCode};
7use laterite_admin::axum::response::{IntoResponse, Redirect, Response};
8use laterite_admin::axum::routing::{get, post};
9use laterite_admin::axum::{Extension, Router};
10use laterite_admin::html::Markup;
11use laterite_admin::routes::{RouteCtx, Screen, ScreenReg};
12use laterite_admin::VerifiedUpload;
13use laterite_admin::{FlashLevel, PageHead, Permission, SessionHandle, Shell, ROLE_ADMIN};
14use laterite_auth::AuthenticatedUser;
15use laterite_core::{t, Text};
16use std::sync::Arc;
17
18pub const PERMISSION: &str = "web.manage_themes";
20
21pub fn permission() -> Permission {
23 Permission::new(PERMISSION, t!("Manage themes"), t!("Website")).roles([ROLE_ADMIN])
24}
25
26pub fn screen() -> ScreenReg {
28 ScreenReg::new("/themes", PERMISSION, Arc::new(ThemeScreen))
29 .in_menu(t!("Themes"))
30 .menu(laterite_admin::nav::Placement::new().icon("palette"))
31}
32
33pub struct ThemeScreen;
35
36impl Screen for ThemeScreen {
37 fn mount(&self, ctx: &RouteCtx) -> Router {
38 let state = Arc::new(ctx.clone());
39 Router::new()
40 .route("/", get(index).post(create))
41 .route("/import", post(import_new))
42 .route("/{code}/export", get(export))
43 .route("/{code}/import", post(import_into))
44 .route("/{code}", get(show))
45 .route("/{code}/config", post(update_config))
46 .route("/{code}/properties", post(save_properties))
47 .route("/{code}/settings", post(save_settings))
48 .route("/{code}/activate", post(activate))
49 .route("/{code}/edit", post(edit))
50 .route("/{code}/duplicate", post(duplicate))
51 .route("/{code}/child", post(child))
52 .route("/{code}/lock", post(lock))
53 .route("/{code}/delete", post(delete))
54 .route("/{code}/templates", post(create_template))
55 .route("/{code}/templates/{id}", get(template))
56 .route("/{code}/templates/{id}/save", post(save_template))
57 .route("/{code}/templates/{id}/delete", post(delete_template))
58 .route("/{code}/templates/{id}/restore", post(restore))
59 .with_state(state)
60 }
61}
62
63type Ctx = State<Arc<RouteCtx>>;
64
65fn back(ctx: &RouteCtx, rest: &str) -> Response {
66 Redirect::to(&ctx.url(rest)).into_response()
67}
68
69fn flash_error(session: &SessionHandle, e: &ThemeError) {
70 session.push_flash(FlashLevel::Error, Text::dynamic(e.to_string()));
71}
72
73fn page<T: askama::Template>(shell: &Shell, title: Text, template: &T) -> Response {
74 page_with(shell, PageHead::new(title), template)
75}
76
77fn page_with<T: askama::Template>(shell: &Shell, head: PageHead, template: &T) -> Response {
78 match Markup::from_template(template) {
79 Ok(body) => shell.page_with(head, body),
80 Err(e) => {
81 tracing::error!(error = %e, "rendering a theme page failed");
82 shell.page(&t!("Themes"), Markup::empty())
83 }
84 }
85}
86
87async fn standing(ctx: &RouteCtx, user: &AuthenticatedUser) -> (Option<String>, Option<String>) {
89 let active = super::active_theme(ctx.db())
90 .await
91 .ok()
92 .flatten()
93 .map(|t| t.code);
94 let editing = laterite_admin::settings::load_for::<EditTheme>(ctx.db(), user.user.id)
97 .await
98 .ok()
99 .and_then(|e| e.code);
100 (active, editing)
101}
102
103fn initials(name: &str) -> String {
106 let mut words = name.split_whitespace().filter_map(|w| w.chars().next());
107 let mut out: String = words
108 .by_ref()
109 .take(2)
110 .flat_map(|c| c.to_uppercase())
111 .collect();
112 if out.is_empty() {
113 out.push('?');
114 }
115 out
116}
117
118struct ThemeRow {
120 code: String,
121 name: String,
122 initials: String,
124 preview: String,
125 author: String,
126 description: String,
127 parent: String,
128 active: bool,
129 editing: bool,
130 locked: bool,
131 href: String,
132}
133
134#[derive(askama::Template)]
135#[template(path = "themes.html")]
136struct IndexTemplate {
137 shell: Shell,
138 base: String,
139 themes: Vec<ThemeRow>,
140}
141
142async fn index(
143 State(ctx): Ctx,
144 Extension(shell): Extension<Shell>,
145 Extension(user): Extension<AuthenticatedUser>,
146) -> Response {
147 let themes = store::list_themes(ctx.db()).await.unwrap_or_default();
148 let (active, editing) = standing(&ctx, &user).await;
149 let rows = themes
150 .into_iter()
151 .map(|t| ThemeRow {
152 href: ctx.url(&t.code),
153 active: active.as_deref() == Some(t.code.as_str()),
154 editing: editing.as_deref() == Some(t.code.as_str()),
155 parent: t.parent.clone().unwrap_or_default(),
156 initials: initials(&t.config.name),
157 preview: t.config.preview.clone().unwrap_or_default(),
158 author: t.config.author.clone(),
159 description: t.config.description.clone(),
160 name: t.config.name.clone(),
161 locked: t.locked,
162 code: t.code,
163 })
164 .collect();
165 page(
166 &shell,
167 t!("Themes"),
168 &IndexTemplate {
169 base: ctx.url(""),
170 shell: shell.clone(),
171 themes: rows,
172 },
173 )
174}
175
176#[derive(serde::Deserialize)]
177struct NewTheme {
178 code: String,
179 name: String,
180}
181
182async fn create(
183 State(ctx): Ctx,
184 Extension(session): Extension<SessionHandle>,
185 Extension(user): Extension<AuthenticatedUser>,
186 Form(form): Form<NewTheme>,
187) -> Response {
188 let config = super::ThemeConfig {
189 name: form.name.trim().to_string(),
190 ..Default::default()
191 };
192 let yaml = match config.to_yaml() {
193 Ok(y) => y,
194 Err(e) => {
195 flash_error(&session, &e);
196 return back(&ctx, "");
197 }
198 };
199 match store::create_theme(ctx.db(), form.code.trim(), &yaml).await {
200 Ok(theme) => {
201 ctx.audit(
202 &user,
203 "web.theme.create",
204 Some("theme"),
205 Some(&theme.code),
206 Some(&theme.config.name),
207 None,
208 )
209 .await;
210 session.push_flash(FlashLevel::Success, t!("Theme created."));
211 back(&ctx, &theme.code)
212 }
213 Err(e) => {
214 flash_error(&session, &e);
215 back(&ctx, "")
216 }
217 }
218}
219
220const UPLOAD_LIMIT: u64 = 32 * 1024 * 1024;
222
223async fn export(
224 State(ctx): Ctx,
225 Extension(session): Extension<SessionHandle>,
226 Path(code): Path<String>,
227) -> Response {
228 match archive::export(ctx.db(), &code).await {
229 Ok(bytes) => (
230 StatusCode::OK,
231 [
232 (header::CONTENT_TYPE, "application/zip".to_string()),
233 (
234 header::CONTENT_DISPOSITION,
235 format!("attachment; filename=\"{code}.zip\""),
236 ),
237 ],
238 bytes,
239 )
240 .into_response(),
241 Err(e) => {
242 flash_error(&session, &e);
243 back(&ctx, &code)
244 }
245 }
246}
247
248struct ImportForm {
251 code: String,
252 replace: bool,
253 file: Option<Vec<u8>>,
254 too_large: bool,
255}
256
257async fn read_import(upload: &mut VerifiedUpload) -> ImportForm {
258 let mut form = ImportForm {
259 code: String::new(),
260 replace: false,
261 file: None,
262 too_large: false,
263 };
264 while let Ok(Some(mut field)) = upload.next_field().await {
265 match field.name().unwrap_or_default() {
266 "code" => form.code = field.text().await.unwrap_or_default().trim().to_string(),
267 "replace" => form.replace = field.text().await.unwrap_or_default() == "1",
268 "file" => {
269 let mut bytes = Vec::new();
270 while let Ok(Some(chunk)) = field.chunk().await {
271 if bytes.len() as u64 + chunk.len() as u64 > UPLOAD_LIMIT {
272 form.too_large = true;
273 break;
274 }
275 bytes.extend_from_slice(&chunk);
276 }
277 form.file = Some(bytes);
278 }
279 _ => {}
280 }
281 }
282 form
283}
284
285async fn run_import(
286 ctx: &RouteCtx,
287 session: &SessionHandle,
288 user: &AuthenticatedUser,
289 code: &str,
290 form: ImportForm,
291) -> Option<String> {
292 if form.too_large {
293 session.push_flash(FlashLevel::Error, t!("The archive is larger than 32 MB."));
294 return None;
295 }
296 let Some(bytes) = form.file.filter(|b| !b.is_empty()) else {
297 session.push_flash(FlashLevel::Error, t!("Choose a zip to import."));
298 return None;
299 };
300 match archive::import(ctx.db(), code, &bytes, form.replace, Some(user.user.id)).await {
301 Ok(report) => {
302 ctx.audit(
303 user,
304 "web.theme.import",
305 Some("theme"),
306 Some(code),
307 None,
308 Some(&format!(
309 "{{\"created\":{},\"imported\":{},\"skipped\":{}}}",
310 report.created, report.imported, report.skipped
311 )),
312 )
313 .await;
314 session.push_flash(
315 FlashLevel::Success,
316 t!(
317 "Imported {imported} template(s), skipped {skipped}.",
318 imported = report.imported as i64,
319 skipped = report.skipped as i64
320 ),
321 );
322 Some(code.to_string())
323 }
324 Err(e) => {
325 flash_error(session, &e);
326 None
327 }
328 }
329}
330
331async fn import_new(
332 State(ctx): Ctx,
333 Extension(session): Extension<SessionHandle>,
334 Extension(user): Extension<AuthenticatedUser>,
335 mut upload: VerifiedUpload,
336) -> Response {
337 let form = read_import(&mut upload).await;
338 let code = form.code.clone();
339 if code.is_empty() {
340 session.push_flash(FlashLevel::Error, t!("Give the new theme a code."));
341 return back(&ctx, "");
342 }
343 match run_import(&ctx, &session, &user, &code, form).await {
344 Some(code) => back(&ctx, &code),
345 None => back(&ctx, ""),
346 }
347}
348
349async fn import_into(
350 State(ctx): Ctx,
351 Extension(session): Extension<SessionHandle>,
352 Extension(user): Extension<AuthenticatedUser>,
353 Path(code): Path<String>,
354 mut upload: VerifiedUpload,
355) -> Response {
356 let form = read_import(&mut upload).await;
357 run_import(&ctx, &session, &user, &code, form).await;
358 back(&ctx, &code)
359}
360
361async fn layouts_of(ctx: &RouteCtx, code: &str) -> Result<Vec<String>, ThemeError> {
363 let mut layouts = Vec::new();
364 for theme in store::chain(ctx.db(), code).await? {
365 for t in store::list_templates(ctx.db(), theme.id, Some(Kind::Layout)).await? {
366 if !layouts.contains(&t.path) {
367 layouts.push(t.path);
368 }
369 }
370 }
371 layouts.sort();
372 Ok(layouts)
373}
374
375fn components_help(ctx: &RouteCtx) -> String {
377 let regs = ctx.contributions::<super::component::ComponentReg>();
378 if regs.is_empty() {
379 return String::from("No component is registered.");
380 }
381 regs.iter()
382 .map(|r| {
383 let props: Vec<String> = r
384 .component
385 .properties()
386 .iter()
387 .map(|p| p.name.clone())
388 .collect();
389 if props.is_empty() {
390 r.code.clone()
391 } else {
392 format!("{} ({})", r.code, props.join(", "))
393 }
394 })
395 .collect::<Vec<_>>()
396 .join("; ")
397}
398
399fn page_fields(layouts: &[String], components_help: &str) -> Vec<laterite_admin::form::FormField> {
401 use laterite_admin::form::FormField;
402 let names: Vec<String> = layouts
403 .iter()
404 .map(|l| super::page::page_name(l).to_string())
405 .collect();
406 let mut choices: Vec<(&str, &str)> = vec![("", "")];
407 choices.extend(names.iter().map(|n| (n.as_str(), n.as_str())));
408 let components_help = Text::dynamic(format!(
409 "A JSON object keyed by alias: {{\"posts\": {{\"component\": \"acme.posts\", \"limit\": 5}}}}. Registered: {components_help}"
410 ));
411 vec![
412 FormField::text("url", t!("URL"))
413 .required()
414 .help(t!("A pattern: /blog/:slug, /docs/:path*, /page/:n?1")),
415 FormField::select("layout", t!("Layout"), choices),
416 FormField::text("title", t!("Title")),
417 FormField::textarea("description", t!("Description")),
418 FormField::text("meta_title", t!("Meta title")),
419 FormField::textarea("meta_description", t!("Meta description")),
420 FormField::text("meta_image", t!("Meta image")),
421 FormField::text("meta_type", t!("Meta type")),
422 FormField::text("meta_robots", t!("Meta robots")),
423 FormField::switch("hidden", t!("Hidden: shown only while previewing")),
424 FormField::textarea("components", t!("Components")).help(components_help),
425 ]
426}
427
428fn bag_for_fields(settings: &serde_json::Value) -> serde_json::Value {
430 let mut bag = settings.clone();
431 if let Some(obj) = bag.as_object_mut() {
432 let text = match obj.get("components") {
433 Some(serde_json::Value::Object(m)) if !m.is_empty() => {
434 serde_json::to_string_pretty(m).unwrap_or_default()
435 }
436 _ => String::new(),
437 };
438 obj.insert("components".into(), serde_json::Value::String(text));
439 }
440 bag
441}
442
443async fn check_page(
446 ctx: &RouteCtx,
447 code: &str,
448 settings: &serde_json::Value,
449) -> Result<(), ThemeError> {
450 let page = super::page::PageSettings::from_value(settings)?;
451 page.check(&layouts_of(ctx, code).await?)?;
452 let regs = ctx.contributions::<super::component::ComponentReg>();
453 super::component::check(settings, ®s)
454}
455
456struct TemplateRow {
458 path: String,
459 from: String,
460 href: String,
461 url: String,
463 active: bool,
465}
466
467struct KindGroup {
468 kind: &'static str,
469 label: String,
470 rows: Vec<TemplateRow>,
471}
472
473#[derive(askama::Template)]
475#[template(path = "theme_actions.html")]
476struct ActionsTemplate {
477 shell: Shell,
478 theme_url: String,
479 active: bool,
480 editing: bool,
481 locked: bool,
482}
483
484#[derive(askama::Template)]
485#[template(path = "theme.html")]
486struct ShowTemplate {
487 shell: Shell,
488 theme_url: String,
489 code: String,
490 name: String,
491 parent: String,
492 author: String,
493 homepage: String,
494 description: String,
495 active: bool,
496 editing: bool,
497 locked: bool,
498 preview: String,
499 settings: Option<String>,
502 groups: Vec<KindGroup>,
503 kinds: Vec<(&'static str, String)>,
504}
505
506fn kind_label(shell: &Shell, kind: Kind) -> String {
507 match kind {
508 Kind::Layout => shell.t("Layouts"),
509 Kind::Page => shell.t("Pages"),
510 Kind::Partial => shell.t("Partials"),
511 Kind::Content => shell.t("Content"),
512 Kind::Asset => shell.t("Assets"),
513 }
514}
515
516async fn groups(
520 ctx: &RouteCtx,
521 shell: &Shell,
522 chain: &[Theme],
523 current: Option<i64>,
524) -> Result<Vec<KindGroup>, ThemeError> {
525 let mut seen: std::collections::HashMap<(Kind, String), (String, i64, String)> =
526 Default::default();
527 for theme in chain {
528 for t in store::list_templates(ctx.db(), theme.id, None).await? {
529 let url = match t.kind {
530 Kind::Page => t
531 .settings
532 .get("url")
533 .and_then(|u| u.as_str())
534 .unwrap_or_default()
535 .to_string(),
536 _ => String::new(),
537 };
538 seen.entry((t.kind, t.path.clone()))
539 .or_insert((theme.code.clone(), t.id, url));
540 }
541 }
542 let own = &chain[0].code;
543 Ok(Kind::ALL
544 .into_iter()
545 .map(|kind| {
546 let mut rows: Vec<TemplateRow> = seen
547 .iter()
548 .filter(|((k, _), _)| *k == kind)
549 .map(|((_, path), (from, id, url))| TemplateRow {
550 path: path.clone(),
551 from: if from == own {
552 String::new()
553 } else {
554 from.clone()
555 },
556 href: ctx.url(&format!("{from}/templates/{id}")),
557 url: url.clone(),
558 active: current == Some(*id),
559 })
560 .collect();
561 rows.sort_by(|a, b| a.path.cmp(&b.path));
562 KindGroup {
563 kind: kind.as_str(),
564 label: kind_label(shell, kind),
565 rows,
566 }
567 })
568 .collect())
569}
570
571async fn show(
572 State(ctx): Ctx,
573 Extension(shell): Extension<Shell>,
574 Extension(session): Extension<SessionHandle>,
575 Extension(user): Extension<AuthenticatedUser>,
576 Path(code): Path<String>,
577) -> Response {
578 let chain = match store::chain(ctx.db(), &code).await {
579 Ok(chain) => chain,
580 Err(e) => {
581 flash_error(&session, &e);
582 return back(&ctx, "");
583 }
584 };
585 let groups = match groups(&ctx, &shell, &chain, None).await {
586 Ok(g) => g,
587 Err(e) => {
588 flash_error(&session, &e);
589 return back(&ctx, "");
590 }
591 };
592 let theme = &chain[0];
593 let kinds = Kind::ALL
594 .into_iter()
595 .map(|k| (k.as_str(), kind_label(&shell, k)))
596 .collect();
597 let fields = store::form_fields(&chain);
598 let settings = if fields.is_empty() {
599 None
600 } else {
601 match ctx.value_form(&fields, &theme.code) {
602 Ok(form) => Some(form.render(&theme.settings, &shell).into_string()),
603 Err(text) => {
604 session.push_flash(FlashLevel::Error, text);
605 None
606 }
607 }
608 };
609 let (active, editing) = standing(&ctx, &user).await;
610 let active = active.as_deref() == Some(theme.code.as_str());
611 let editing = editing.as_deref() == Some(theme.code.as_str());
612 let actions = Markup::from_template(&ActionsTemplate {
613 shell: shell.clone(),
614 theme_url: ctx.url(&theme.code),
615 active,
616 editing,
617 locked: theme.locked,
618 })
619 .unwrap_or_else(|_| Markup::empty());
620 page_with(
621 &shell,
622 PageHead::new(Text::dynamic(theme.config.name.clone()))
623 .back(t!("Themes"), ctx.url(""))
624 .actions(actions),
625 &ShowTemplate {
626 theme_url: ctx.url(&theme.code),
627 code: theme.code.clone(),
628 name: theme.config.name.clone(),
629 parent: theme.parent.clone().unwrap_or_default(),
630 author: theme.config.author.clone(),
631 homepage: theme.config.homepage.clone(),
632 description: theme.config.description.clone(),
633 active,
634 editing,
635 locked: theme.locked,
636 preview: theme.config.preview.clone().unwrap_or_default(),
637 settings,
638 groups,
639 kinds,
640 shell: shell.clone(),
641 },
642 )
643}
644
645#[derive(serde::Deserialize)]
646struct ConfigForm {
647 config: String,
648}
649
650async fn update_config(
651 State(ctx): Ctx,
652 Extension(session): Extension<SessionHandle>,
653 Extension(user): Extension<AuthenticatedUser>,
654 Path(code): Path<String>,
655 Form(form): Form<ConfigForm>,
656) -> Response {
657 match store::update_theme_config(ctx.db(), &code, &form.config).await {
658 Ok(theme) => {
659 ctx.audit(
660 &user,
661 "web.theme.update",
662 Some("theme"),
663 Some(&theme.code),
664 Some(&theme.config.name),
665 None,
666 )
667 .await;
668 session.push_flash(FlashLevel::Success, t!("Theme config saved."));
669 }
670 Err(e) => flash_error(&session, &e),
671 }
672 back(&ctx, &code)
673}
674
675#[derive(serde::Deserialize)]
676struct PropertiesForm {
677 #[serde(default)]
678 name: String,
679 #[serde(default)]
680 author: String,
681 #[serde(default)]
682 homepage: String,
683 #[serde(default)]
684 description: String,
685 #[serde(default)]
686 preview: String,
687}
688
689async fn save_properties(
692 State(ctx): Ctx,
693 Extension(session): Extension<SessionHandle>,
694 Extension(user): Extension<AuthenticatedUser>,
695 Path(code): Path<String>,
696 Form(form): Form<PropertiesForm>,
697) -> Response {
698 let name = form.name.trim();
699 if name.is_empty() {
700 session.push_flash(FlashLevel::Error, t!("A theme needs a name."));
701 return back(&ctx, &code);
702 }
703 let theme = match store::find_theme(ctx.db(), &code).await {
704 Ok(Some(theme)) => theme,
705 Ok(None) => {
706 flash_error(&session, &ThemeError::NotFound(code.clone()));
707 return back(&ctx, "");
708 }
709 Err(e) => {
710 flash_error(&session, &e);
711 return back(&ctx, "");
712 }
713 };
714 let mut config = theme.config.clone();
715 config.name = name.to_string();
716 config.author = form.author.trim().to_string();
717 config.homepage = form.homepage.trim().to_string();
718 config.description = form.description.trim().to_string();
719 config.preview = Some(form.preview.trim().to_string()).filter(|p| !p.is_empty());
720 let yaml = match config.to_yaml() {
721 Ok(yaml) => yaml,
722 Err(e) => {
723 flash_error(&session, &e);
724 return back(&ctx, &code);
725 }
726 };
727 match store::update_theme_config(ctx.db(), &code, &yaml).await {
728 Ok(theme) => {
729 ctx.audit(
730 &user,
731 "web.theme.update",
732 Some("theme"),
733 Some(&theme.code),
734 Some(&theme.config.name),
735 None,
736 )
737 .await;
738 session.push_flash(FlashLevel::Success, t!("Theme properties saved."));
739 }
740 Err(e) => flash_error(&session, &e),
741 }
742 back(&ctx, &code)
743}
744
745async fn save_settings(
746 State(ctx): Ctx,
747 Extension(session): Extension<SessionHandle>,
748 Extension(user): Extension<AuthenticatedUser>,
749 Path(code): Path<String>,
750 Form(data): Form<std::collections::HashMap<String, String>>,
751) -> Response {
752 let chain = match store::chain(ctx.db(), &code).await {
753 Ok(chain) => chain,
754 Err(e) => {
755 flash_error(&session, &e);
756 return back(&ctx, "");
757 }
758 };
759 let fields = store::form_fields(&chain);
760 let collected = ctx
761 .value_form(&fields, &code)
762 .and_then(|form| form.collect(&data));
763 match collected {
764 Ok(values) => match store::save_settings(ctx.db(), &code, &values).await {
765 Ok(()) => {
766 ctx.audit(
767 &user,
768 "web.theme.settings",
769 Some("theme"),
770 Some(&code),
771 Some(&chain[0].config.name),
772 None,
773 )
774 .await;
775 session.push_flash(FlashLevel::Success, t!("Theme settings saved."));
776 }
777 Err(e) => flash_error(&session, &e),
778 },
779 Err(text) => session.push_flash(FlashLevel::Error, text),
780 }
781 back(&ctx, &code)
782}
783
784async fn activate(
785 State(ctx): Ctx,
786 Extension(session): Extension<SessionHandle>,
787 Extension(user): Extension<AuthenticatedUser>,
788 Path(code): Path<String>,
789) -> Response {
790 match super::set_active_theme(ctx.db(), &code).await {
791 Ok(()) => {
792 ctx.audit(
793 &user,
794 "web.theme.activate",
795 Some("theme"),
796 Some(&code),
797 None,
798 None,
799 )
800 .await;
801 session.push_flash(FlashLevel::Success, t!("The site now uses this theme."));
802 }
803 Err(e) => flash_error(&session, &e),
804 }
805 back(&ctx, "")
806}
807
808async fn edit(
809 State(ctx): Ctx,
810 Extension(session): Extension<SessionHandle>,
811 Extension(user): Extension<AuthenticatedUser>,
812 Path(code): Path<String>,
813) -> Response {
814 let current: Option<String> = super::edit_theme(ctx.db(), user.user.id)
815 .await
816 .ok()
817 .flatten()
818 .map(|t| t.code);
819 let active = super::active_theme(ctx.db())
820 .await
821 .ok()
822 .flatten()
823 .map(|t| t.code);
824 let choice =
827 if current.as_deref() == Some(code.as_str()) || active.as_deref() == Some(code.as_str()) {
828 None
829 } else {
830 Some(code.as_str())
831 };
832 let _ = EditTheme::default();
833 match super::set_edit_theme(ctx.db(), user.user.id, choice).await {
834 Ok(()) => session.push_flash(
835 FlashLevel::Success,
836 if choice.is_some() {
837 t!("You are now editing this theme.")
838 } else {
839 t!("You are now editing the site's theme.")
840 },
841 ),
842 Err(e) => flash_error(&session, &e),
843 }
844 back(&ctx, "")
845}
846
847#[derive(serde::Deserialize)]
848struct CopyForm {
849 new_code: String,
850 #[serde(default)]
851 name: String,
852}
853
854async fn duplicate(
855 State(ctx): Ctx,
856 Extension(session): Extension<SessionHandle>,
857 Extension(user): Extension<AuthenticatedUser>,
858 Path(code): Path<String>,
859 Form(form): Form<CopyForm>,
860) -> Response {
861 match store::duplicate_theme(ctx.db(), &code, form.new_code.trim()).await {
862 Ok(copy) => {
863 ctx.audit(
864 &user,
865 "web.theme.duplicate",
866 Some("theme"),
867 Some(©.code),
868 Some(©.config.name),
869 Some(&format!("{{\"from\":\"{code}\"}}")),
870 )
871 .await;
872 session.push_flash(FlashLevel::Success, t!("Theme duplicated."));
873 back(&ctx, ©.code)
874 }
875 Err(e) => {
876 flash_error(&session, &e);
877 back(&ctx, "")
878 }
879 }
880}
881
882async fn child(
883 State(ctx): Ctx,
884 Extension(session): Extension<SessionHandle>,
885 Extension(user): Extension<AuthenticatedUser>,
886 Path(code): Path<String>,
887 Form(form): Form<CopyForm>,
888) -> Response {
889 let name = if form.name.trim().is_empty() {
890 form.new_code.trim().to_string()
891 } else {
892 form.name.trim().to_string()
893 };
894 match store::create_child_theme(ctx.db(), &code, form.new_code.trim(), &name).await {
895 Ok(child) => {
896 ctx.audit(
897 &user,
898 "web.theme.create",
899 Some("theme"),
900 Some(&child.code),
901 Some(&child.config.name),
902 Some(&format!("{{\"parent\":\"{code}\"}}")),
903 )
904 .await;
905 session.push_flash(FlashLevel::Success, t!("Child theme created."));
906 back(&ctx, &child.code)
907 }
908 Err(e) => {
909 flash_error(&session, &e);
910 back(&ctx, "")
911 }
912 }
913}
914
915#[derive(serde::Deserialize)]
916struct LockForm {
917 locked: i32,
918}
919
920async fn lock(
921 State(ctx): Ctx,
922 Extension(session): Extension<SessionHandle>,
923 Extension(user): Extension<AuthenticatedUser>,
924 Path(code): Path<String>,
925 Form(form): Form<LockForm>,
926) -> Response {
927 let locked = form.locked != 0;
928 match store::set_locked(ctx.db(), &code, locked).await {
929 Ok(()) => {
930 ctx.audit(
931 &user,
932 if locked {
933 "web.theme.lock"
934 } else {
935 "web.theme.unlock"
936 },
937 Some("theme"),
938 Some(&code),
939 None,
940 None,
941 )
942 .await;
943 session.push_flash(
944 FlashLevel::Success,
945 if locked {
946 t!("Theme locked.")
947 } else {
948 t!("Theme unlocked.")
949 },
950 );
951 }
952 Err(e) => flash_error(&session, &e),
953 }
954 back(&ctx, "")
955}
956
957async fn delete(
958 State(ctx): Ctx,
959 Extension(session): Extension<SessionHandle>,
960 Extension(user): Extension<AuthenticatedUser>,
961 Path(code): Path<String>,
962) -> Response {
963 match store::delete_theme(ctx.db(), &code).await {
964 Ok(()) => {
965 ctx.audit(
966 &user,
967 "web.theme.delete",
968 Some("theme"),
969 Some(&code),
970 None,
971 None,
972 )
973 .await;
974 session.push_flash(FlashLevel::Success, t!("Theme deleted."));
975 }
976 Err(e) => flash_error(&session, &e),
977 }
978 back(&ctx, "")
979}
980
981#[derive(serde::Deserialize)]
982struct NewTemplate {
983 kind: String,
984 path: String,
985}
986
987async fn create_template(
988 State(ctx): Ctx,
989 Extension(session): Extension<SessionHandle>,
990 Extension(user): Extension<AuthenticatedUser>,
991 Path(code): Path<String>,
992 Form(form): Form<NewTemplate>,
993) -> Response {
994 let theme = match store::find_theme(ctx.db(), &code).await {
995 Ok(Some(t)) => t,
996 Ok(None) => {
997 flash_error(&session, &ThemeError::NotFound(code.clone()));
998 return back(&ctx, "");
999 }
1000 Err(e) => {
1001 flash_error(&session, &e);
1002 return back(&ctx, "");
1003 }
1004 };
1005 let kind: Kind = match form.kind.parse() {
1006 Ok(k) => k,
1007 Err(e) => {
1008 flash_error(&session, &e);
1009 return back(&ctx, &code);
1010 }
1011 };
1012 let path = form.path.trim();
1013 if let Ok(Some(existing)) = store::find_template(ctx.db(), theme.id, kind, path).await {
1014 return back(&ctx, &format!("{code}/templates/{}", existing.id));
1015 }
1016 let settings = (kind == Kind::Page)
1018 .then(|| serde_json::json!({ "url": format!("/{}", super::page::page_name(path)) }));
1019 match store::save_template(
1020 ctx.db(),
1021 theme.id,
1022 kind,
1023 path,
1024 settings,
1025 "",
1026 Some(user.user.id),
1027 )
1028 .await
1029 {
1030 Ok(t) => {
1031 ctx.audit(
1032 &user,
1033 "web.theme.template.create",
1034 Some("theme_template"),
1035 Some(&t.id.to_string()),
1036 Some(&engine::template_name(kind, path)),
1037 Some(&format!("{{\"theme\":\"{code}\"}}")),
1038 )
1039 .await;
1040 back(&ctx, &format!("{code}/templates/{}", t.id))
1041 }
1042 Err(e) => {
1043 flash_error(&session, &e);
1044 back(&ctx, &code)
1045 }
1046 }
1047}
1048
1049struct RevisionRow {
1050 id: i64,
1051 saved_at: String,
1052 saved_by: String,
1053}
1054
1055#[derive(askama::Template)]
1056#[template(path = "theme_template.html")]
1057struct EditorTemplate {
1058 shell: Shell,
1059 theme_url: String,
1060 template_url: String,
1061 path: String,
1062 locked: bool,
1063 settings: String,
1064 page_fields: Option<String>,
1066 markup: String,
1067 revisions: Vec<RevisionRow>,
1068 groups: Vec<KindGroup>,
1069 kinds: Vec<(&'static str, String)>,
1070}
1071
1072#[derive(askama::Template)]
1074#[template(path = "theme_template_actions.html")]
1075struct TemplateActionsTemplate {
1076 shell: Shell,
1077 template_url: String,
1078 locked: bool,
1079}
1080
1081async fn owned(ctx: &RouteCtx, code: &str, id: i64) -> Result<(Theme, Template), ThemeError> {
1084 let theme = store::find_theme(ctx.db(), code)
1085 .await?
1086 .ok_or_else(|| ThemeError::NotFound(code.to_string()))?;
1087 let template = store::template(ctx.db(), id)
1088 .await?
1089 .filter(|t| t.theme_id == theme.id)
1090 .ok_or_else(|| ThemeError::TemplateNotFound(id.to_string()))?;
1091 Ok((theme, template))
1092}
1093
1094fn revision_rows(shell: &Shell, revisions: Vec<Revision>) -> Vec<RevisionRow> {
1095 revisions
1096 .into_iter()
1097 .map(|r| RevisionRow {
1098 id: r.id,
1099 saved_at: shell.datetime(&r.saved_at),
1100 saved_by: r.saved_by.map(|id| format!("#{id}")).unwrap_or_default(),
1101 })
1102 .collect()
1103}
1104
1105async fn template(
1106 State(ctx): Ctx,
1107 Extension(shell): Extension<Shell>,
1108 Extension(session): Extension<SessionHandle>,
1109 Path((code, id)): Path<(String, i64)>,
1110) -> Response {
1111 let (theme, template) = match owned(&ctx, &code, id).await {
1112 Ok(pair) => pair,
1113 Err(e) => {
1114 flash_error(&session, &e);
1115 return back(&ctx, &code);
1116 }
1117 };
1118 let revisions = store::revisions(ctx.db(), template.id)
1119 .await
1120 .unwrap_or_default();
1121 let chain = store::chain(ctx.db(), &code).await.unwrap_or_default();
1122 let groups = groups(&ctx, &shell, &chain, Some(template.id))
1123 .await
1124 .unwrap_or_default();
1125 let kinds = Kind::ALL
1126 .into_iter()
1127 .map(|k| (k.as_str(), kind_label(&shell, k)))
1128 .collect();
1129 let page_fields = if template.kind == Kind::Page {
1130 let layouts = layouts_of(&ctx, &code).await.unwrap_or_default();
1131 match ctx.value_form(&page_fields(&layouts, &components_help(&ctx)), &code) {
1132 Ok(form) => Some(
1133 form.render(&bag_for_fields(&template.settings), &shell)
1134 .into_string(),
1135 ),
1136 Err(text) => {
1137 session.push_flash(FlashLevel::Error, text);
1138 None
1139 }
1140 }
1141 } else {
1142 None
1143 };
1144 let template_url = ctx.url(&format!("{code}/templates/{id}"));
1145 let actions = Markup::from_template(&TemplateActionsTemplate {
1146 shell: shell.clone(),
1147 template_url: template_url.clone(),
1148 locked: theme.locked,
1149 })
1150 .unwrap_or_else(|_| Markup::empty());
1151 page_with(
1152 &shell,
1153 PageHead::new(Text::dynamic(template.path.clone()))
1154 .back(
1155 Text::dynamic(theme.config.name.clone()),
1156 ctx.url(&theme.code),
1157 )
1158 .actions(actions),
1159 &EditorTemplate {
1160 theme_url: ctx.url(&theme.code),
1161 template_url,
1162 path: engine::template_name(template.kind, &template.path),
1163 locked: theme.locked,
1164 settings: serde_json::to_string_pretty(&template.settings).unwrap_or_default(),
1165 page_fields,
1166 markup: template.markup.clone(),
1167 revisions: revision_rows(&shell, revisions),
1168 groups,
1169 kinds,
1170 shell: shell.clone(),
1171 },
1172 )
1173}
1174
1175async fn save_template(
1176 State(ctx): Ctx,
1177 Extension(session): Extension<SessionHandle>,
1178 Extension(user): Extension<AuthenticatedUser>,
1179 Path((code, id)): Path<(String, i64)>,
1180 Form(form): Form<std::collections::HashMap<String, String>>,
1181) -> Response {
1182 let here = format!("{code}/templates/{id}");
1183 let (theme, template) = match owned(&ctx, &code, id).await {
1184 Ok(pair) => pair,
1185 Err(e) => {
1186 flash_error(&session, &e);
1187 return back(&ctx, &code);
1188 }
1189 };
1190 let settings: serde_json::Value = if template.kind == Kind::Page {
1193 let layouts = layouts_of(&ctx, &code).await.unwrap_or_default();
1194 let collected = ctx
1195 .value_form(&page_fields(&layouts, &components_help(&ctx)), &code)
1196 .and_then(|f| f.collect(&form));
1197 match collected {
1198 Ok(serde_json::Value::Object(mut fields)) => {
1199 let text = fields
1201 .remove("components")
1202 .and_then(|v| v.as_str().map(str::to_string))
1203 .unwrap_or_default();
1204 let components = if text.trim().is_empty() {
1205 serde_json::json!({})
1206 } else {
1207 match serde_json::from_str::<serde_json::Value>(&text) {
1208 Ok(v @ serde_json::Value::Object(_)) => v,
1209 _ => {
1210 session.push_flash(
1211 FlashLevel::Error,
1212 t!("Components must be a JSON object keyed by alias."),
1213 );
1214 return back(&ctx, &here);
1215 }
1216 }
1217 };
1218 let mut bag = template.settings.as_object().cloned().unwrap_or_default();
1219 bag.extend(fields);
1220 bag.insert("components".into(), components);
1221 serde_json::Value::Object(bag)
1222 }
1223 Ok(_) => serde_json::json!({}),
1224 Err(text) => {
1225 session.push_flash(FlashLevel::Error, text);
1226 return back(&ctx, &here);
1227 }
1228 }
1229 } else {
1230 let raw = form.get("settings").map(String::as_str).unwrap_or("{}");
1231 match serde_json::from_str(raw.trim()) {
1232 Ok(v @ serde_json::Value::Object(_)) => v,
1233 Ok(_) => {
1234 session.push_flash(FlashLevel::Error, t!("The settings must be a JSON object."));
1235 return back(&ctx, &here);
1236 }
1237 Err(e) => {
1238 session.push_flash(
1239 FlashLevel::Error,
1240 Text::dynamic(format!("The settings do not parse: {e}")),
1241 );
1242 return back(&ctx, &here);
1243 }
1244 }
1245 };
1246 let markup = form
1247 .get("markup")
1248 .map(String::as_str)
1249 .unwrap_or_default()
1250 .replace("\r\n", "\n");
1251 if template.kind != Kind::Content {
1252 if let Err(e) = engine::check(&markup) {
1253 session.push_flash(FlashLevel::Error, Text::dynamic(e.to_string()));
1254 return back(&ctx, &here);
1255 }
1256 }
1257 if template.kind == Kind::Page {
1258 if let Err(e) = check_page(&ctx, &code, &settings).await {
1259 flash_error(&session, &e);
1260 return back(&ctx, &here);
1261 }
1262 }
1263 match store::save_template(
1264 ctx.db(),
1265 theme.id,
1266 template.kind,
1267 &template.path,
1268 Some(settings),
1269 &markup,
1270 Some(user.user.id),
1271 )
1272 .await
1273 {
1274 Ok(saved) => {
1275 ctx.audit(
1276 &user,
1277 "web.theme.template.save",
1278 Some("theme_template"),
1279 Some(&saved.id.to_string()),
1280 Some(&engine::template_name(saved.kind, &saved.path)),
1281 Some(&format!("{{\"theme\":\"{code}\"}}")),
1282 )
1283 .await;
1284 session.push_flash(FlashLevel::Success, t!("Template saved."));
1285 }
1286 Err(e) => flash_error(&session, &e),
1287 }
1288 back(&ctx, &here)
1289}
1290
1291async fn delete_template(
1292 State(ctx): Ctx,
1293 Extension(session): Extension<SessionHandle>,
1294 Extension(user): Extension<AuthenticatedUser>,
1295 Path((code, id)): Path<(String, i64)>,
1296) -> Response {
1297 let (_, template) = match owned(&ctx, &code, id).await {
1298 Ok(pair) => pair,
1299 Err(e) => {
1300 flash_error(&session, &e);
1301 return back(&ctx, &code);
1302 }
1303 };
1304 match store::delete_template(ctx.db(), id).await {
1305 Ok(()) => {
1306 ctx.audit(
1307 &user,
1308 "web.theme.template.delete",
1309 Some("theme_template"),
1310 Some(&id.to_string()),
1311 Some(&engine::template_name(template.kind, &template.path)),
1312 Some(&format!("{{\"theme\":\"{code}\"}}")),
1313 )
1314 .await;
1315 session.push_flash(FlashLevel::Success, t!("Template deleted."));
1316 back(&ctx, &code)
1317 }
1318 Err(e) => {
1319 flash_error(&session, &e);
1320 back(&ctx, &format!("{code}/templates/{id}"))
1321 }
1322 }
1323}
1324
1325#[derive(serde::Deserialize)]
1326struct RestoreForm {
1327 revision: i64,
1328}
1329
1330async fn restore(
1331 State(ctx): Ctx,
1332 Extension(session): Extension<SessionHandle>,
1333 Extension(user): Extension<AuthenticatedUser>,
1334 Path((code, id)): Path<(String, i64)>,
1335 Form(form): Form<RestoreForm>,
1336) -> Response {
1337 let here = format!("{code}/templates/{id}");
1338 if let Err(e) = owned(&ctx, &code, id).await {
1339 flash_error(&session, &e);
1340 return back(&ctx, &code);
1341 }
1342 let belongs = store::revisions(ctx.db(), id)
1343 .await
1344 .map(|revs| revs.iter().any(|r| r.id == form.revision))
1345 .unwrap_or(false);
1346 if !belongs {
1347 flash_error(
1348 &session,
1349 &ThemeError::TemplateNotFound(form.revision.to_string()),
1350 );
1351 return back(&ctx, &here);
1352 }
1353 match store::restore_revision(ctx.db(), form.revision, Some(user.user.id)).await {
1354 Ok(saved) => {
1355 ctx.audit(
1356 &user,
1357 "web.theme.template.restore",
1358 Some("theme_template"),
1359 Some(&saved.id.to_string()),
1360 Some(&engine::template_name(saved.kind, &saved.path)),
1361 Some(&format!("{{\"revision\":{}}}", form.revision)),
1362 )
1363 .await;
1364 session.push_flash(FlashLevel::Success, t!("Revision restored."));
1365 }
1366 Err(e) => flash_error(&session, &e),
1367 }
1368 back(&ctx, &here)
1369}