add theme setter and getter, update route handlers

This commit is contained in:
glyph 2022-02-03 16:29:20 +02:00
parent 435e819648
commit 0737c435a8
15 changed files with 193 additions and 17 deletions

View File

@ -36,6 +36,7 @@ maintenance = { status = "actively-developed" }
[dependencies]
env_logger = "0.8"
lazy_static = "1.4.0"
log = "0.4"
nest = "1.0.0"
peach-lib = { path = "../peach-lib" }

View File

@ -32,11 +32,14 @@ pub mod routes;
mod tests;
pub mod utils;
use std::process;
use std::{process, sync::RwLock};
use lazy_static::lazy_static;
use log::{debug, error, info};
use rocket::{fairing::AdHoc, serde::Deserialize, Build, Rocket};
use utils::Theme;
pub type BoxError = Box<dyn std::error::Error>;
/// Application configuration parameters.
@ -51,6 +54,10 @@ pub struct RocketConfig {
standalone_mode: bool,
}
lazy_static! {
static ref THEME: RwLock<Theme> = RwLock::new(Theme::Light);
}
static WLAN_IFACE: &str = "wlan0";
static AP_IFACE: &str = "ap0";

View File

@ -6,7 +6,7 @@ use crate::routes::{
catchers::*,
index::*,
scuttlebutt::*,
settings::{admin::*, dns::*, menu::*, network::*, scuttlebutt::*},
settings::{admin::*, dns::*, menu::*, network::*, scuttlebutt::*, theme::*},
status::{device::*, network::*, scuttlebutt::*},
};
@ -28,6 +28,7 @@ pub fn mount_peachpub_routes(rocket: Rocket<Build>) -> Rocket<Build> {
shutdown_cmd,
power_menu,
settings_menu,
set_theme,
],
)
.mount(

View File

@ -13,8 +13,8 @@ use rocket_dyn_templates::{tera::Context, Template};
use peach_lib::{error::PeachError, password_utils};
use crate::error::PeachWebError;
use crate::utils;
use crate::utils::TemplateOrRedirect;
//use crate::DisableAuth;
use crate::RocketConfig;
// HELPERS AND STRUCTS FOR AUTHENTICATION WITH COOKIES
@ -72,7 +72,11 @@ impl<'r> FromRequest<'r> for Authenticated {
#[get("/login")]
pub fn login(flash: Option<FlashMessage>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Login".to_string()));
@ -166,7 +170,11 @@ pub fn save_reset_password_form(password_form: ResetPasswordForm) -> Result<(),
/// and is specifically for users who have forgotten their password.
#[get("/reset_password")]
pub fn reset_password(flash: Option<FlashMessage>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Reset Password".to_string()));
@ -211,7 +219,11 @@ pub fn reset_password_post(reset_password_form: Form<ResetPasswordForm>) -> Temp
/// to initiate the sending of a new password reset.
#[get("/forgot_password")]
pub fn forgot_password_page(flash: Option<FlashMessage>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Send Password Reset".to_string()));
@ -281,7 +293,11 @@ pub fn save_password_form(password_form: PasswordForm) -> Result<(), PeachWebErr
/// Change password request handler. This is used by a user who is already logged in.
#[get("/change_password")]
pub fn change_password(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/settings/admin".to_string()));
context.insert("title", &Some("Change Password".to_string()));

View File

@ -3,16 +3,21 @@ use rocket::{get, request::FlashMessage, State};
use rocket_dyn_templates::{tera::Context, Template};
use crate::routes::authentication::Authenticated;
use crate::utils;
use crate::RocketConfig;
// HELPERS AND ROUTES FOR / (HOME PAGE)
#[get("/")]
pub fn home(_auth: Authenticated, config: &State<RocketConfig>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read().ok();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("sbot_status", &sbot_status);
context.insert("flash_name", &None::<()>);
context.insert("flash_msg", &None::<()>);
@ -28,7 +33,11 @@ pub fn home(_auth: Authenticated, config: &State<RocketConfig>) -> Template {
#[get("/help")]
pub fn help(flash: Option<FlashMessage>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Help".to_string()));

View File

@ -11,6 +11,7 @@ use rocket::{
use rocket_dyn_templates::Template;
use crate::routes::authentication::Authenticated;
use crate::utils;
// HELPERS AND ROUTES FOR /private
@ -20,6 +21,7 @@ pub struct PrivateContext {
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
}
impl PrivateContext {
@ -29,6 +31,7 @@ impl PrivateContext {
flash_name: None,
flash_msg: None,
title: None,
theme: None,
}
}
}
@ -36,15 +39,21 @@ impl PrivateContext {
/// A private message composition and publication page.
#[get("/private")]
pub fn private(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = PrivateContext::build();
context.back = Some("/".to_string());
context.title = Some("Private Messages".to_string());
context.theme = Some(theme);
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/messages", &context)
}
@ -56,6 +65,7 @@ pub struct PeerContext {
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
}
impl PeerContext {
@ -65,6 +75,7 @@ impl PeerContext {
flash_name: None,
flash_msg: None,
title: None,
theme: None,
}
}
}
@ -72,15 +83,21 @@ impl PeerContext {
/// A peer menu which allows navigating to lists of friends, follows, followers and blocks.
#[get("/peers")]
pub fn peers(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = PeerContext::build();
context.back = Some("/".to_string());
context.title = Some("Scuttlebutt Peers".to_string());
context.theme = Some(theme);
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/peers", &context)
}
@ -91,7 +108,10 @@ pub struct Post {
pub text: String,
}
/// Publish a public Scuttlebutt post. Redirects to profile page of the PeachCloud local identity with a flash message describing the outcome of the action (may be successful or unsuccessful).
/// Publish a public Scuttlebutt post.
/// Redirects to profile page of the PeachCloud local identity with a flash
/// message describing the outcome of the action (may be successful or
/// unsuccessful).
#[post("/publish", data = "<post>")]
pub fn publish(post: Form<Post>, _auth: Authenticated) -> Flash<Redirect> {
let post_text = &post.text;
@ -101,6 +121,7 @@ pub fn publish(post: Form<Post>, _auth: Authenticated) -> Flash<Redirect> {
// redirect to the profile template without public key ("home" / local profile)
let pub_key: std::option::Option<&str> = None;
let profile_url = uri!(profile(pub_key));
// consider adding the message reference to the flash message (or render it in the template for
// `profile`
Flash::success(Redirect::to(profile_url), "Published public post")
@ -113,7 +134,9 @@ pub struct PublicKey {
pub key: String,
}
/// Follow a Scuttlebutt profile specified by the given public key. Redirects to the appropriate profile page with a flash message describing the outcome of the action (may be successful or unsuccessful).
/// Follow a Scuttlebutt profile specified by the given public key.
/// Redirects to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
#[post("/follow", data = "<pub_key>")]
pub fn follow(pub_key: Form<PublicKey>, _auth: Authenticated) -> Flash<Redirect> {
let public_key = &pub_key.key;
@ -123,12 +146,15 @@ pub fn follow(pub_key: Form<PublicKey>, _auth: Authenticated) -> Flash<Redirect>
// redirect to the profile template with provided public key
let profile_url = uri!(profile(Some(public_key)));
let success_msg = format!("Followed {}", public_key);
Flash::success(Redirect::to(profile_url), success_msg)
}
// HELPERS AND ROUTES FOR /unfollow
/// Unfollow a Scuttlebutt profile specified by the given public key. Redirects to the appropriate profile page with a flash message describing the outcome of the action (may be successful or unsuccessful).
/// Unfollow a Scuttlebutt profile specified by the given public key.
/// Redirects to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
#[post("/unfollow", data = "<pub_key>")]
pub fn unfollow(pub_key: Form<PublicKey>, _auth: Authenticated) -> Flash<Redirect> {
let public_key = &pub_key.key;
@ -138,12 +164,15 @@ pub fn unfollow(pub_key: Form<PublicKey>, _auth: Authenticated) -> Flash<Redirec
// redirect to the profile template with provided public key
let profile_url = uri!(profile(Some(public_key)));
let success_msg = format!("Unfollowed {}", public_key);
Flash::success(Redirect::to(profile_url), success_msg)
}
// HELPERS AND ROUTES FOR /block
/// Block a Scuttlebutt profile specified by the given public key. Redirects to the appropriate profile page with a flash message describing the outcome of the action (may be successful or unsuccessful).
/// Block a Scuttlebutt profile specified by the given public key.
/// Redirects to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
#[post("/block", data = "<pub_key>")]
pub fn block(pub_key: Form<PublicKey>, _auth: Authenticated) -> Flash<Redirect> {
let public_key = &pub_key.key;
@ -153,6 +182,7 @@ pub fn block(pub_key: Form<PublicKey>, _auth: Authenticated) -> Flash<Redirect>
// redirect to the profile template with provided public key
let profile_url = uri!(profile(Some(public_key)));
let success_msg = format!("Blocked {}", public_key);
Flash::success(Redirect::to(profile_url), success_msg)
}
@ -164,6 +194,7 @@ pub struct ProfileContext {
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
}
impl ProfileContext {
@ -173,6 +204,7 @@ impl ProfileContext {
flash_name: None,
flash_msg: None,
title: None,
theme: None,
}
}
}
@ -184,15 +216,21 @@ pub fn profile(
flash: Option<FlashMessage>,
_auth: Authenticated,
) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = ProfileContext::build();
context.back = Some("/".to_string());
context.title = Some("Profile".to_string());
context.theme = Some(theme);
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/profile", &context)
}
@ -204,6 +242,7 @@ pub struct FriendsContext {
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
}
impl FriendsContext {
@ -213,6 +252,7 @@ impl FriendsContext {
flash_name: None,
flash_msg: None,
title: None,
theme: None,
}
}
}
@ -221,9 +261,13 @@ impl FriendsContext {
/// key of the peer.
#[get("/friends")]
pub fn friends(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = FriendsContext::build();
context.back = Some("/scuttlebutt/peers".to_string());
context.title = Some("Friends".to_string());
context.theme = Some(theme);
// check to see if there is a flash message to display
if let Some(flash) = flash {
@ -231,6 +275,7 @@ pub fn friends(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/peers_list", &context)
}
@ -242,6 +287,7 @@ pub struct FollowsContext {
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
}
impl FollowsContext {
@ -251,6 +297,7 @@ impl FollowsContext {
flash_name: None,
flash_msg: None,
title: None,
theme: None,
}
}
}
@ -259,9 +306,13 @@ impl FollowsContext {
/// key of the peer.
#[get("/follows")]
pub fn follows(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = FollowsContext::build();
context.back = Some("/scuttlebutt/peers".to_string());
context.title = Some("Follows".to_string());
context.theme = Some(theme);
// check to see if there is a flash message to display
if let Some(flash) = flash {
@ -269,6 +320,7 @@ pub fn follows(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/peers_list", &context)
}
@ -280,6 +332,7 @@ pub struct FollowersContext {
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
}
impl FollowersContext {
@ -289,6 +342,7 @@ impl FollowersContext {
flash_name: None,
flash_msg: None,
title: None,
theme: None,
}
}
}
@ -297,9 +351,13 @@ impl FollowersContext {
/// key of the peer.
#[get("/followers")]
pub fn followers(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = FollowersContext::build();
context.back = Some("/scuttlebutt/peers".to_string());
context.title = Some("Followers".to_string());
context.theme = Some(theme);
// check to see if there is a flash message to display
if let Some(flash) = flash {
@ -307,6 +365,7 @@ pub fn followers(flash: Option<FlashMessage>, _auth: Authenticated) -> Template
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/peers_list", &context)
}
@ -318,6 +377,7 @@ pub struct BlocksContext {
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
}
impl BlocksContext {
@ -327,6 +387,7 @@ impl BlocksContext {
flash_name: None,
flash_msg: None,
title: None,
theme: None,
}
}
}
@ -335,9 +396,13 @@ impl BlocksContext {
/// key of the peer.
#[get("/blocks")]
pub fn blocks(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = BlocksContext::build();
context.back = Some("/scuttlebutt/peers".to_string());
context.title = Some("Blocks".to_string());
context.theme = Some(theme);
// check to see if there is a flash message to display
if let Some(flash) = flash {
@ -345,5 +410,6 @@ pub fn blocks(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/peers_list", &context)
}

View File

@ -12,13 +12,18 @@ use peach_lib::config_manager;
use crate::error::PeachWebError;
use crate::routes::authentication::Authenticated;
use crate::utils;
// HELPERS AND ROUTES FOR /settings/admin
/// Administrator settings menu.
#[get("/")]
pub fn admin_menu(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/settings".to_string()));
context.insert("title", &Some("Administrator Settings".to_string()));
@ -36,7 +41,11 @@ pub fn admin_menu(flash: Option<FlashMessage>, _auth: Authenticated) -> Template
/// View and delete currently configured admin.
#[get("/configure")]
pub fn configure_admin(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/settings/admin".to_string()));
context.insert("title", &Some("Configure Admin".to_string()));
@ -81,7 +90,11 @@ pub fn save_add_admin_form(admin_form: AddAdminForm) -> Result<(), PeachWebError
#[get("/add")]
pub fn add_admin(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/settings/admin/configure".to_string()));
context.insert("title", &Some("Add Admin".to_string()));

View File

@ -16,6 +16,7 @@ use peach_lib::{
use crate::{
context::dns::ConfigureDNSContext, error::PeachWebError, routes::authentication::Authenticated,
utils,
};
#[derive(Debug, Deserialize, FromForm)]
@ -76,11 +77,14 @@ pub fn save_dns_configuration(dns_form: DnsForm) -> Result<(), PeachWebError> {
#[get("/dns")]
pub fn configure_dns(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
let mut context = ConfigureDNSContext::build();
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = ConfigureDNSContext::build();
// set back icon link to network route
context.back = Some("/settings/network".to_string());
context.title = Some("Configure DNS".to_string());
context.theme = Some(theme);
// check to see if there is a flash message to display
if let Some(flash) = flash {

View File

@ -2,6 +2,7 @@ use rocket::{get, request::FlashMessage, State};
use rocket_dyn_templates::{tera::Context, Template};
use crate::routes::authentication::Authenticated;
use crate::utils;
use crate::RocketConfig;
// HELPERS AND ROUTES FOR /settings
@ -13,7 +14,11 @@ pub fn settings_menu(
flash: Option<FlashMessage>,
config: &State<RocketConfig>,
) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Settings".to_string()));

View File

@ -3,3 +3,4 @@ pub mod dns;
pub mod menu;
pub mod network;
pub mod scuttlebutt;
pub mod theme;

View File

@ -15,6 +15,7 @@ use rocket::{
use rocket_dyn_templates::{tera::Context, Template};
use crate::routes::authentication::Authenticated;
use crate::utils;
#[derive(Debug, Deserialize, FromForm)]
pub struct SbotConfigForm {
@ -58,10 +59,14 @@ pub struct SbotConfigForm {
/// Scuttlebutt settings menu.
#[get("/")]
pub fn ssb_settings_menu(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read().ok();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("sbot_status", &sbot_status);
context.insert("back", &Some("/settings".to_string()));
context.insert("title", &Some("Scuttlebutt Settings".to_string()));
@ -77,6 +82,9 @@ pub fn ssb_settings_menu(flash: Option<FlashMessage>, _auth: Authenticated) -> T
/// Sbot configuration page (includes form for updating configuration parameters).
#[get("/configure")]
pub fn configure_sbot(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read().ok();
let run_on_startup = sbot_status.map(|status| status.boot_state);
@ -85,6 +93,7 @@ pub fn configure_sbot(flash: Option<FlashMessage>, _auth: Authenticated) -> Temp
let sbot_config = SbotConfig::read().ok();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/settings/scuttlebutt".to_string()));
context.insert("title", &Some("Sbot Configuration".to_string()));
context.insert("sbot_config", &sbot_config);
@ -135,16 +144,14 @@ pub fn configure_sbot_post(
match owned_config.startup {
true => {
info!("Enabling go-sbot.service");
match systemctl_sbot_cmd("enable") {
Err(e) => warn!("Failed to enable go-sbot.service: {}", e),
_ => (),
if let Err(e) = systemctl_sbot_cmd("enable") {
warn!("Failed to enable go-sbot.service: {}", e)
}
}
false => {
info!("Disabling go-sbot.service");
match systemctl_sbot_cmd("disable") {
Err(e) => warn!("Failed to disable go-sbot.service: {}", e),
_ => (),
if let Err(e) = systemctl_sbot_cmd("disable") {
warn!("Failed to disable go-sbot.service: {}", e)
}
}
};

View File

@ -0,0 +1,16 @@
use rocket::{get, response::Redirect};
use crate::routes::authentication::Authenticated;
use crate::{utils, utils::Theme};
/// Set the user-interface theme according to the query parameter value.
#[get("/theme?<theme>")]
pub fn set_theme(_auth: Authenticated, theme: &str) -> Redirect {
match theme {
"light" => utils::set_theme(Theme::Light),
"dark" => utils::set_theme(Theme::Dark),
_ => (),
}
Redirect::to("/")
}

View File

@ -3,18 +3,24 @@ use rocket::{get, State};
use rocket_dyn_templates::{tera::Context, Template};
use crate::routes::authentication::Authenticated;
use crate::utils;
use crate::RocketConfig;
// HELPERS AND ROUTES FOR /status/scuttlebutt
#[get("/scuttlebutt")]
pub fn scuttlebutt_status(_auth: Authenticated, config: &State<RocketConfig>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read().ok();
// retrieve go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("sbot_status", &sbot_status);
context.insert("sbot_config", &sbot_config);
context.insert("flash_name", &None::<()>);

View File

@ -1,9 +1,33 @@
pub mod monitor;
use rocket_dyn_templates::Template;
use log::info;
use rocket::response::{Redirect, Responder};
use rocket::serde::Serialize;
use rocket_dyn_templates::Template;
use crate::THEME;
// THEME FUNCTIONS
#[derive(Debug, Copy, Clone)]
pub enum Theme {
Light,
Dark,
}
pub fn get_theme() -> String {
let current_theme = THEME.read().unwrap();
match *current_theme {
Theme::Dark => "dark".to_string(),
_ => "light".to_string(),
}
}
pub fn set_theme(theme: Theme) {
info!("set ui theme to: {:?}", theme);
let mut writable_theme = THEME.write().unwrap();
*writable_theme = theme;
}
// HELPER FUNCTIONS

View File

@ -1,6 +1,6 @@
<!doctype html>
<html lang="en">
<html lang="en"{% if theme %} data-theme="{{ theme }}"{% endif %}>
<head>
<meta charset="utf-8">
<title>PeachCloud</title>