mirror of
https://github.com/lennart-k/rustical.git
synced 2025-12-14 04:42:15 +00:00
Minor frontend improvements, feature to create calendar
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2796,6 +2796,7 @@ dependencies = [
|
|||||||
"mime_guess",
|
"mime_guess",
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
|
"rustical_ical",
|
||||||
"rustical_oidc",
|
"rustical_oidc",
|
||||||
"rustical_store",
|
"rustical_store",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ serde.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
rustical_store.workspace = true
|
rustical_store.workspace = true
|
||||||
|
rustical_ical.workspace = true
|
||||||
rust-embed.workspace = true
|
rust-embed.workspace = true
|
||||||
futures-core.workspace = true
|
futures-core.workspace = true
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
|
|||||||
@@ -159,14 +159,14 @@ table {
|
|||||||
display: grid;
|
display: grid;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
". color-chip"
|
". . color-chip"
|
||||||
"title color-chip"
|
"title comps color-chip"
|
||||||
"description color-chip"
|
"description . color-chip"
|
||||||
"subscription-url color-chip"
|
"subscription-url . color-chip"
|
||||||
"restore color-chip"
|
"restore . color-chip"
|
||||||
". color-chip";
|
". . color-chip";
|
||||||
grid-template-rows: 12px auto auto auto 12px;
|
grid-template-rows: 12px auto auto auto 12px;
|
||||||
grid-template-columns: auto 80px;
|
grid-template-columns: min-content auto 80px;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
@@ -180,14 +180,31 @@ table {
|
|||||||
.title {
|
.title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
|
margin-right: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comps {
|
||||||
|
grid-area: comps;
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin: 0 2px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--text-on-primary-color);
|
||||||
|
font-size: .8em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
grid-area: description;
|
grid-area: description;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-url {
|
.subscription-url {
|
||||||
grid-area: subscription-url;
|
grid-area: subscription-url;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-chip {
|
.color-chip {
|
||||||
|
|||||||
@@ -64,6 +64,11 @@
|
|||||||
<li class="collection-list-item" style="--color: {{ color }}">
|
<li class="collection-list-item" style="--color: {{ color }}">
|
||||||
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}">
|
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}">
|
||||||
<span class="title">{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}</span>
|
<span class="title">{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}</span>
|
||||||
|
<div class="comps">
|
||||||
|
{% for comp in calendar.components %}
|
||||||
|
<span>{{ comp }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
<span class="description">
|
<span class="description">
|
||||||
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
|
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
@@ -85,6 +90,11 @@
|
|||||||
<li class="collection-list-item" style="--color: {{ color }}">
|
<li class="collection-list-item" style="--color: {{ color }}">
|
||||||
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}">
|
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}">
|
||||||
<span class="title">{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}</span>
|
<span class="title">{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}</span>
|
||||||
|
<div class="comps">
|
||||||
|
{% for comp in calendar.components %}
|
||||||
|
<span>{{ comp }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
<span class="description">
|
<span class="description">
|
||||||
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
|
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
@@ -97,6 +107,49 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<h3>Create calendar</h3>
|
||||||
|
<form action="/frontend/user/{{ user.id }}/calendar" method="POST">
|
||||||
|
<label>
|
||||||
|
href
|
||||||
|
<input type="text" name="id" />
|
||||||
|
</label>
|
||||||
|
<br>
|
||||||
|
<label>
|
||||||
|
Displayname
|
||||||
|
<input type="text" name="displayname" />
|
||||||
|
</label>
|
||||||
|
<br>
|
||||||
|
<label>
|
||||||
|
Description
|
||||||
|
<input type="text" name="description" />
|
||||||
|
</label>
|
||||||
|
<br>
|
||||||
|
<label>
|
||||||
|
Color
|
||||||
|
<input type="color" name="color" />
|
||||||
|
</label>
|
||||||
|
<br>
|
||||||
|
<label>
|
||||||
|
Subscription URL
|
||||||
|
<input type="text" name="subscription_url" />
|
||||||
|
</label>
|
||||||
|
<br>
|
||||||
|
<label>
|
||||||
|
Support VEVENT
|
||||||
|
<input type="checkbox" name="comp_event" checked />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Support VTODO
|
||||||
|
<input type="checkbox" name="comp_todo" checked />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Support VJOURNAL
|
||||||
|
<input type="checkbox" name="comp_journal" />
|
||||||
|
</label>
|
||||||
|
<br>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ use crate::{
|
|||||||
routes::{
|
routes::{
|
||||||
addressbook::{route_addressbook, route_addressbook_restore},
|
addressbook::{route_addressbook, route_addressbook_restore},
|
||||||
app_token::{route_delete_app_token, route_post_app_token},
|
app_token::{route_delete_app_token, route_post_app_token},
|
||||||
calendar::{route_calendar, route_calendar_restore},
|
calendar::{route_calendar, route_calendar_restore, route_create_calendar},
|
||||||
login::{route_get_login, route_post_login, route_post_logout},
|
login::{route_get_login, route_post_login, route_post_logout},
|
||||||
user::{route_get_home, route_root, route_user_named},
|
user::{route_get_home, route_root, route_user_named},
|
||||||
},
|
},
|
||||||
@@ -66,6 +66,7 @@ pub fn frontend_router<
|
|||||||
post(route_delete_app_token::<AP>),
|
post(route_delete_app_token::<AP>),
|
||||||
)
|
)
|
||||||
// Calendar
|
// Calendar
|
||||||
|
.route("/user/{user}/calendar", post(route_create_calendar::<CS>))
|
||||||
.route(
|
.route(
|
||||||
"/user/{user}/calendar/{calendar}",
|
"/user/{user}/calendar/{calendar}",
|
||||||
get(route_calendar::<CS>),
|
get(route_calendar::<CS>),
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ use std::sync::Arc;
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use askama_web::WebTemplate;
|
use askama_web::WebTemplate;
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension,
|
Extension, Form,
|
||||||
extract::Path,
|
extract::Path,
|
||||||
response::{IntoResponse, Redirect, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use axum_extra::TypedHeader;
|
use axum_extra::TypedHeader;
|
||||||
use headers::Referer;
|
use headers::Referer;
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
|
use rustical_ical::CalendarObjectType;
|
||||||
use rustical_store::{Calendar, CalendarStore, auth::User};
|
use rustical_store::{Calendar, CalendarStore, auth::User};
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
|
||||||
#[derive(Template, WebTemplate)]
|
#[derive(Template, WebTemplate)]
|
||||||
#[template(path = "pages/calendar.html")]
|
#[template(path = "pages/calendar.html")]
|
||||||
@@ -32,6 +34,82 @@ pub async fn route_calendar<C: CalendarStore>(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn empty_to_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let val: Option<String> = Deserialize::deserialize(deserializer)?;
|
||||||
|
Ok(val.filter(|val| !val.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct PutCalendarForm {
|
||||||
|
id: String,
|
||||||
|
#[serde(deserialize_with = "empty_to_none")]
|
||||||
|
displayname: Option<String>,
|
||||||
|
#[serde(deserialize_with = "empty_to_none")]
|
||||||
|
description: Option<String>,
|
||||||
|
#[serde(deserialize_with = "empty_to_none")]
|
||||||
|
color: Option<String>,
|
||||||
|
#[serde(deserialize_with = "empty_to_none")]
|
||||||
|
subscription_url: Option<String>,
|
||||||
|
comp_event: Option<String>,
|
||||||
|
comp_todo: Option<String>,
|
||||||
|
comp_journal: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn route_create_calendar<C: CalendarStore>(
|
||||||
|
Path(owner): Path<String>,
|
||||||
|
Extension(store): Extension<Arc<C>>,
|
||||||
|
user: User,
|
||||||
|
Form(PutCalendarForm {
|
||||||
|
id,
|
||||||
|
displayname,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
subscription_url,
|
||||||
|
comp_event,
|
||||||
|
comp_todo,
|
||||||
|
comp_journal,
|
||||||
|
}): Form<PutCalendarForm>,
|
||||||
|
) -> Result<Response, rustical_store::Error> {
|
||||||
|
if !user.is_principal(&owner) {
|
||||||
|
return Ok(StatusCode::UNAUTHORIZED.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!id.is_empty());
|
||||||
|
|
||||||
|
let mut comps = vec![];
|
||||||
|
if comp_event.is_some() {
|
||||||
|
comps.push(CalendarObjectType::Event);
|
||||||
|
}
|
||||||
|
if comp_todo.is_some() {
|
||||||
|
comps.push(CalendarObjectType::Todo);
|
||||||
|
}
|
||||||
|
if comp_journal.is_some() {
|
||||||
|
comps.push(CalendarObjectType::Journal);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cal = Calendar {
|
||||||
|
id: id.to_owned(),
|
||||||
|
displayname,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
subscription_url,
|
||||||
|
principal: user.id,
|
||||||
|
components: comps,
|
||||||
|
order: 0,
|
||||||
|
timezone_id: None,
|
||||||
|
timezone: None,
|
||||||
|
synctoken: 0,
|
||||||
|
deleted_at: None,
|
||||||
|
push_topic: uuid::Uuid::new_v4().to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store.insert_calendar(cal).await?;
|
||||||
|
Ok(Redirect::to(&id).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn route_calendar_restore<CS: CalendarStore>(
|
pub async fn route_calendar_restore<CS: CalendarStore>(
|
||||||
Path((owner, cal_id)): Path<(String, String)>,
|
Path((owner, cal_id)): Path<(String, String)>,
|
||||||
Extension(store): Extension<Arc<CS>>,
|
Extension(store): Extension<Arc<CS>>,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::CalDateTime;
|
|||||||
use crate::Error;
|
use crate::Error;
|
||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use derive_more::Display;
|
||||||
use ical::{
|
use ical::{
|
||||||
generator::{Emitter, IcalCalendar},
|
generator::{Emitter, IcalCalendar},
|
||||||
parser::{Component, ical::component::IcalTimeZone},
|
parser::{Component, ical::component::IcalTimeZone},
|
||||||
@@ -11,7 +12,7 @@ use serde::Serialize;
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{collections::HashMap, io::BufReader};
|
use std::{collections::HashMap, io::BufReader};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Display)]
|
||||||
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
|
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
|
||||||
pub enum CalendarObjectType {
|
pub enum CalendarObjectType {
|
||||||
Event = 0,
|
Event = 0,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use tracing::warn;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
@@ -16,6 +18,7 @@ impl From<sqlx::Error> for Error {
|
|||||||
sqlx::Error::RowNotFound => Error::StoreError(rustical_store::Error::NotFound),
|
sqlx::Error::RowNotFound => Error::StoreError(rustical_store::Error::NotFound),
|
||||||
sqlx::Error::Database(err) => {
|
sqlx::Error::Database(err) => {
|
||||||
if err.is_unique_violation() {
|
if err.is_unique_violation() {
|
||||||
|
warn!("{err:?}");
|
||||||
Error::StoreError(rustical_store::Error::AlreadyExists)
|
Error::StoreError(rustical_store::Error::AlreadyExists)
|
||||||
} else {
|
} else {
|
||||||
Error::SqlxError(sqlx::Error::Database(err))
|
Error::SqlxError(sqlx::Error::Database(err))
|
||||||
|
|||||||
Reference in New Issue
Block a user