diff --git a/crates/frontend/public/assets/style.css b/crates/frontend/public/assets/style.css index 0c218f4..2cd0e3d 100644 --- a/crates/frontend/public/assets/style.css +++ b/crates/frontend/public/assets/style.css @@ -1,21 +1,40 @@ :root { --background-color: #FFF; --background-darker: #EEE; + --text-on-background-color: #111; --primary-color: #2F2FE1; --primary-color-dark: color-mix(in srgb, var(--primary-color), #000000 80%); --text-on-primary-color: #FFF; /* --color-red: #FE2060; */ /* --color-red: #EE1D59; */ --color-red: #E31B39; + --dilute-color: black; + --border-color: black; } -html { +@media (prefers-color-scheme: dark) { + :root { + --background-color: #222; + --background-darker: #292929; + --text-on-background-color: #CACACA; + --primary-color: color-mix(in srgb, #2F2FE1, white 15%); + --primary-color-dark: color-mix(in srgb, var(--primary-color), #000000 80%); + --text-on-primary-color: #FFF; + /* --color-red: #FE2060; */ + --color-red: #EE1D59; + --dilute-color: white; + --border-color: color-mix(in srgb, var(--background-color), var(--dilute-color) 15%); + } +} + +html, dialog { background-color: var(--background-color); + color: var(--text-on-background-color); } body { /* position: relative; */ - font-family: sans-serif; + font-family: 'Noto Sans', Helvetica, Arial, sans-serif; margin: 0 auto; max-width: 1200px; min-height: 100%; @@ -29,6 +48,10 @@ body { padding: 12px; } +a { + color: var(--text-on-background-color); +} + header { background: var(--background-darker); height: 60px; @@ -37,24 +60,45 @@ header { align-items: center; padding: 12px; - border: 2px solid black; + border: 2px solid var(--border-color); border-radius: 12px; margin: 12px; box-shadow: 4px 2px 12px -5px black; - a { + display: flex; + justify-content: space-between; + + a.logo { font-size: 2em; text-decoration: none; - color: black; + } + + nav { + display: flex; + + border-radius: 12px; + background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 5%); + + a { + text-decoration: none; + margin: 4px 8px; + padding: 8px 12px; + border-radius: 12px; + background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 2%); + + &:hover { + background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 20%); + } + + &.active { + background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 15%); + } + } } .logout_form { display: contents; - - button { - margin-left: auto; - } } } @@ -69,7 +113,7 @@ button, .button { border: none; background: var(--primary-color); - padding: 10px 16px; + padding: 8px 16px; border-radius: 8px; color: var(--text-on-primary-color); font-size: 0.9em; @@ -97,7 +141,7 @@ input[type="password"] { } section { - border: 1px solid black; + border: 1px solid var(--border-color); border-radius: 12px; box-shadow: 4px 2px 12px -8px black; border-collapse: collapse; @@ -108,7 +152,7 @@ section { } table { - border: 1px solid black; + border: 1px solid var(--border-color); border-radius: 12px; box-shadow: 4px 2px 12px -6px black; border-collapse: collapse; @@ -118,7 +162,7 @@ table { td, th { padding: 8px; - border: 1px solid black; + border: 1px solid var(--border-color); width: max-content; } @@ -126,12 +170,8 @@ table { height: 40px; } - /* tr:nth-of-type(2n+1) { */ - /* background: var(--background-darker); */ - /* } */ - tr:hover { - background: #DDD; + background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%); } tr:first-child th { @@ -151,87 +191,85 @@ table { } } -#page-user { - ul { - padding-left: 0; +ul.collection-list { + padding-left: 0; - li.collection-list-item { - list-style: none; - display: contents; + li.collection-list-item { + list-style: none; + display: contents; - a { - background: #EEE; - display: grid; - min-height: 80px; - grid-template-areas: - ". . color-chip" - "title comps color-chip" - "description description color-chip" - "subscription-url subscription-url color-chip" - "actions actions color-chip" - ". . color-chip"; - grid-template-rows: 12px auto auto auto auto 12px; - grid-template-columns: min-content auto 80px; - row-gap: 4px; - color: inherit; - text-decoration: none; - padding-left: 12px; + a { + background: color-mix(in srgb, var(--background-color), var(--dilute-color) 5%); + display: grid; + min-height: 80px; + grid-template-areas: + ". . color-chip" + "title comps color-chip" + "description description color-chip" + "subscription-url subscription-url color-chip" + "actions actions color-chip" + ". . color-chip"; + grid-template-rows: 12px auto auto auto auto 12px; + grid-template-columns: min-content auto 80px; + row-gap: 4px; + color: inherit; + text-decoration: none; + padding-left: 12px; - border: 2px solid black; - border-radius: 12px; - margin: 12px; - box-shadow: 4px 2px 12px -6px black; - overflow: hidden; + border: 2px solid var(--border-color); + border-radius: 12px; + margin: 12px; + box-shadow: 4px 2px 12px -6px black; + overflow: hidden; - .title { - font-weight: bold; - grid-area: title; - margin-right: 12px; - white-space: nowrap; - } + .title { + font-weight: bold; + grid-area: title; + margin-right: 12px; + white-space: nowrap; + } + + span { + margin: 8px initial; + } + + .comps { + grid-area: comps; span { - margin: 8px initial; + margin: 0 2px; + background: var(--primary-color); + color: var(--text-on-primary-color); + font-size: .8em; + padding: 3px 8px; + border-radius: 12px; } + } - .comps { - grid-area: comps; + .description { + grid-area: description; + white-space: nowrap; + } - span { - margin: 0 2px; - background: var(--primary-color); - color: var(--text-on-primary-color); - font-size: .8em; - padding: 3px 8px; - border-radius: 12px; - } - } + .subscription-url { + grid-area: subscription-url; + white-space: nowrap; + } - .description { - grid-area: description; - white-space: nowrap; - } + .color-chip { + background: var(--color); + grid-area: color-chip; + } - .subscription-url { - grid-area: subscription-url; - white-space: nowrap; - } + .actions { + grid-area: actions; + width: fit-content; + display: flex; + gap: 12px; + } - .color-chip { - background: var(--color); - grid-area: color-chip; - } - - .actions { - grid-area: actions; - width: fit-content; - display: flex; - gap: 12px; - } - - &:hover { - background: #DDD; - } + &:hover { + background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%); } } } @@ -242,7 +280,7 @@ textarea { } dialog { - border: 1px solid black; + border: 1px solid var(--border-color); border-radius: 16px; padding: 32px; } @@ -252,6 +290,14 @@ footer { justify-content: center; margin-top: 32px; gap: 24px; - /* position: absolute; */ bottom: 20px; } + +input[type="text"], +input[type="password"] { + background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%); + border: 2px solid var(--border-color); + padding: 6px 6px; + color: var(--text-on-background-color); + margin: 2px; +} diff --git a/crates/frontend/public/templates/components/addressbooks_section.html b/crates/frontend/public/templates/components/addressbooks_section.html deleted file mode 100644 index 409fd2f..0000000 --- a/crates/frontend/public/templates/components/addressbooks_section.html +++ /dev/null @@ -1,56 +0,0 @@ -
-

Addressbooks

- - {%if !deleted_addressbooks.is_empty() %} -

Deleted Addressbooks

- - {% endif %} - - - -
diff --git a/crates/frontend/public/templates/components/calendars_section.html b/crates/frontend/public/templates/components/calendars_section.html deleted file mode 100644 index 8499d2b..0000000 --- a/crates/frontend/public/templates/components/calendars_section.html +++ /dev/null @@ -1,72 +0,0 @@ -
-

Calendars

- - {%if !deleted_calendars.is_empty() %} -

Deleted Calendars

- - {% endif %} - - -
diff --git a/crates/frontend/public/templates/components/profile_section.html b/crates/frontend/public/templates/components/profile_section.html deleted file mode 100644 index 5b94c20..0000000 --- a/crates/frontend/public/templates/components/profile_section.html +++ /dev/null @@ -1,58 +0,0 @@ -
-

Profile

- - {% let groups = user.memberships_without_self() %} - {% if groups.len() > 0 %} -

Groups

- - {% endif %} - -

App tokens

- - - - - - - {% for app_token in app_tokens %} - - - - - - {% endfor %} - - - - - -
NameCreated at
{{ app_token.name }} - {% if let Some(created_at) = app_token.created_at %} - {{ chrono_humanize::HumanTime::from(created_at.to_owned()) }} - {% endif %} - -
- -
-
-
- - -
-
- - {% if is_apple %} - - {% endif %} -
- {% if let Some(hostname) = davx5_hostname %} - Configure - in DAVx5 - {% endif %} -
diff --git a/crates/frontend/public/templates/components/sections/addressbooks_section.html b/crates/frontend/public/templates/components/sections/addressbooks_section.html new file mode 100644 index 0000000..94245c7 --- /dev/null +++ b/crates/frontend/public/templates/components/sections/addressbooks_section.html @@ -0,0 +1,54 @@ +

{{user.id }}'s Addressbooks

+ +{%if !deleted_addressbooks.is_empty() %} +

Deleted Addressbooks

+ +{% endif %} + + + diff --git a/crates/frontend/public/templates/components/sections/calendars_section.html b/crates/frontend/public/templates/components/sections/calendars_section.html new file mode 100644 index 0000000..50a1c04 --- /dev/null +++ b/crates/frontend/public/templates/components/sections/calendars_section.html @@ -0,0 +1,70 @@ +

{{ user.id }}'s Calendars

+ +{%if !deleted_calendars.is_empty() %} +

Deleted Calendars

+ +{% endif %} + + diff --git a/crates/frontend/public/templates/components/sections/profile_section.html b/crates/frontend/public/templates/components/sections/profile_section.html new file mode 100644 index 0000000..96faef3 --- /dev/null +++ b/crates/frontend/public/templates/components/sections/profile_section.html @@ -0,0 +1,56 @@ +

{{ user.id }}'s Profile

+ +{% let groups = user.memberships_without_self() %} +{% if groups.len() > 0 %} +

Groups

+ +{% endif %} + +

App tokens

+ + + + + + + {% for app_token in app_tokens %} + + + + + + {% endfor %} + + + + + +
NameCreated at
{{ app_token.name }} + {% if let Some(created_at) = app_token.created_at %} + {{ chrono_humanize::HumanTime::from(created_at.to_owned()) }} + {% endif %} + +
+ +
+
+
+ + +
+
+ + {% if is_apple %} + + {% endif %} +
+{% if let Some(hostname) = davx5_hostname %} +Configure + in DAVx5 +{% endif %} diff --git a/crates/frontend/public/templates/layouts/default.html b/crates/frontend/public/templates/layouts/default.html index f17efbf..17774ad 100644 --- a/crates/frontend/public/templates/layouts/default.html +++ b/crates/frontend/public/templates/layouts/default.html @@ -12,7 +12,8 @@ {% block header %}
- RustiCal + + {% block header_center %}{% endblock %}
diff --git a/crates/frontend/public/templates/pages/user.html b/crates/frontend/public/templates/pages/user.html index cb009ef..926e571 100644 --- a/crates/frontend/public/templates/pages/user.html +++ b/crates/frontend/public/templates/pages/user.html @@ -5,15 +5,15 @@ {% endblock %} - -{% block content %} -
- -

Welcome {{ user.id }}!

- -{% include "components/profile_section.html" %} -{% include "components/calendars_section.html" %} -{% include "components/addressbooks_section.html" %} - +{% block header_center %} + +{% endblock %} + +{% block content %} +{{ section|safe }} {% endblock %} diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 8929bb4..5f94fca 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -8,6 +8,7 @@ use axum::{ }; use headers::{ContentType, HeaderMapExt}; use http::{Method, StatusCode}; +use routes::{addressbooks::route_addressbooks, calendars::route_calendars}; use rustical_oidc::{OidcConfig, OidcServiceConfig, route_get_oidc_callback, route_post_oidc}; use rustical_store::{ AddressbookStore, CalendarStore, @@ -20,6 +21,7 @@ mod assets; mod config; pub mod nextcloud_login; mod oidc_user_store; +pub(crate) mod pages; mod routes; pub use config::FrontendConfig; @@ -56,6 +58,7 @@ pub fn frontend_router), ) // Calendar + .route("/user/{user}/calendar", get(route_calendars::)) .route( "/user/{user}/calendar/{calendar}", get(route_calendar::), @@ -65,6 +68,7 @@ pub fn frontend_router), ) // Addressbook + .route("/user/{user}/addressbook", get(route_addressbooks::)) .route( "/user/{user}/addressbook/{addressbook}", get(route_addressbook::), diff --git a/crates/frontend/src/pages/mod.rs b/crates/frontend/src/pages/mod.rs new file mode 100644 index 0000000..22d12a3 --- /dev/null +++ b/crates/frontend/src/pages/mod.rs @@ -0,0 +1 @@ +pub mod user; diff --git a/crates/frontend/src/pages/user.rs b/crates/frontend/src/pages/user.rs new file mode 100644 index 0000000..e395ea0 --- /dev/null +++ b/crates/frontend/src/pages/user.rs @@ -0,0 +1,14 @@ +use askama::Template; +use askama_web::WebTemplate; +use rustical_store::auth::Principal; + +pub trait Section: Template { + fn name() -> &'static str; +} + +#[derive(Template, WebTemplate)] +#[template(path = "pages/user.html")] +pub struct UserPage { + pub user: Principal, + pub section: S, +} diff --git a/crates/frontend/src/routes/addressbooks.rs b/crates/frontend/src/routes/addressbooks.rs new file mode 100644 index 0000000..2e41a6f --- /dev/null +++ b/crates/frontend/src/routes/addressbooks.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use askama::Template; +use askama_web::WebTemplate; +use axum::{Extension, extract::Path, response::IntoResponse}; +use http::StatusCode; +use rustical_store::{Addressbook, AddressbookStore, auth::Principal}; + +use crate::pages::user::{Section, UserPage}; + +impl Section for AddressbooksSection { + fn name() -> &'static str { + "addressbooks" + } +} + +#[derive(Template, WebTemplate)] +#[template(path = "components/sections/addressbooks_section.html")] +pub struct AddressbooksSection { + pub user: Principal, + pub addressbooks: Vec, + pub deleted_addressbooks: Vec, +} + +pub async fn route_addressbooks( + Path(user_id): Path, + Extension(addr_store): Extension>, + user: Principal, +) -> impl IntoResponse { + if user_id != user.id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + let mut addressbooks = vec![]; + for group in user.memberships() { + addressbooks.extend(addr_store.get_addressbooks(group).await.unwrap()); + } + + let mut deleted_addressbooks = vec![]; + for group in user.memberships() { + deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap()); + } + + UserPage { + section: AddressbooksSection { + user: user.clone(), + addressbooks, + deleted_addressbooks, + }, + user, + } + .into_response() +} diff --git a/crates/frontend/src/routes/calendars.rs b/crates/frontend/src/routes/calendars.rs new file mode 100644 index 0000000..7cebc29 --- /dev/null +++ b/crates/frontend/src/routes/calendars.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +use crate::pages::user::{Section, UserPage}; +use askama::Template; +use askama_web::WebTemplate; +use axum::{Extension, extract::Path, response::IntoResponse}; +use http::StatusCode; +use rustical_store::{Calendar, CalendarStore, auth::Principal}; + +impl Section for CalendarsSection { + fn name() -> &'static str { + "calendars" + } +} + +#[derive(Template, WebTemplate)] +#[template(path = "components/sections/calendars_section.html")] +pub struct CalendarsSection { + pub user: Principal, + pub calendars: Vec, + pub deleted_calendars: Vec, +} + +pub async fn route_calendars( + Path(user_id): Path, + Extension(cal_store): Extension>, + user: Principal, +) -> impl IntoResponse { + if user_id != user.id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + let mut calendars = vec![]; + for group in user.memberships() { + calendars.extend(cal_store.get_calendars(group).await.unwrap()); + } + + let mut deleted_calendars = vec![]; + for group in user.memberships() { + deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap()); + } + + UserPage { + section: CalendarsSection { + user: user.clone(), + calendars, + deleted_calendars, + }, + user, + } + .into_response() +} diff --git a/crates/frontend/src/routes/mod.rs b/crates/frontend/src/routes/mod.rs index e007c7c..f3ecc12 100644 --- a/crates/frontend/src/routes/mod.rs +++ b/crates/frontend/src/routes/mod.rs @@ -1,5 +1,7 @@ pub mod addressbook; +pub mod addressbooks; pub mod app_token; pub mod calendar; +pub mod calendars; pub mod login; pub mod user; diff --git a/crates/frontend/src/routes/user.rs b/crates/frontend/src/routes/user.rs index fb45cc8..c2bc7cb 100644 --- a/crates/frontend/src/routes/user.rs +++ b/crates/frontend/src/routes/user.rs @@ -11,19 +11,23 @@ use axum_extra::{TypedHeader, extract::Host}; use headers::UserAgent; use http::StatusCode; use rustical_store::{ - Addressbook, AddressbookStore, Calendar, CalendarStore, + AddressbookStore, CalendarStore, auth::{AppToken, AuthenticationProvider, Principal}, }; +use crate::pages::user::{Section, UserPage}; + +impl Section for ProfileSection { + fn name() -> &'static str { + "profile" + } +} + #[derive(Template, WebTemplate)] -#[template(path = "pages/user.html")] -pub struct UserPage { +#[template(path = "components/sections/profile_section.html")] +pub struct ProfileSection { pub user: Principal, pub app_tokens: Vec, - pub calendars: Vec, - pub deleted_calendars: Vec, - pub addressbooks: Vec, - pub deleted_addressbooks: Vec, pub is_apple: bool, pub davx5_hostname: Option, } @@ -69,14 +73,13 @@ pub async fn route_user_named< let davx5_hostname = user_agent.as_str().contains("Android").then_some(host); UserPage { - app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(), - calendars, - deleted_calendars, - addressbooks, - deleted_addressbooks, + section: ProfileSection { + user: user.clone(), + app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(), + is_apple, + davx5_hostname, + }, user, - is_apple, - davx5_hostname, } .into_response() }