diff --git a/src/context.rs b/src/context.rs index ca151fa..f2bd819 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1253,6 +1253,10 @@ impl Context { result } + // TODO: This functions immutably borrows site, but then obtains its own + // site_mut handle through get_site_mut() which could also be used. + // Refactoring this however is probably not so easy because it looks + // like we will run into parallel mutable/immutable borrow issues. /// Based on the passed (absolute) path determines from which site path /// downwards (e.g. from the root, from a subcollection, etc.) we need to /// update our site tree (picking up what changed in the filesystem). diff --git a/src/editor.rs b/src/editor.rs index 9175aaf..48b2689 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -42,7 +42,9 @@ pub enum Location<'a> { pub enum Subpage { DangerousActions, Edit, + Manifest, Media, + SiteManifest, SiteSettings, Subtitles } @@ -135,6 +137,13 @@ impl<'a> Location<'a> { } } + pub fn manifest(site_path: &SitePath) -> Location { + Location::SiteTree { + site_path, + subpage: Subpage::Manifest + } + } + pub fn media(site_path: &SitePath) -> Location { Location::SiteTree { site_path, @@ -149,6 +158,13 @@ impl<'a> Location<'a> { } } + pub fn site_manifest(site_path: &SitePath) -> Location { + Location::SiteTree { + site_path, + subpage: Subpage::SiteManifest + } + } + pub fn subtitles(site_path: &SitePath) -> Location { Location::SiteTree { site_path, diff --git a/src/editor/endpoints/collection.rs b/src/editor/endpoints/collection.rs index b1efa59..958ed94 100644 --- a/src/editor/endpoints/collection.rs +++ b/src/editor/endpoints/collection.rs @@ -1,28 +1,6 @@ // SPDX-FileCopyrightText: 2024-2025 Simon Repp // SPDX-License-Identifier: AGPL-3.0-or-later -use actix_web::HttpResponse; -use actix_web::http::header::ContentType; -use indoc::formatdoc; - -use crate::{ - Collection, - Context, - Field, - Language, - PlatformIntegration -}; -use crate::editor::{Checkbox, Input, Location, Select, Textarea}; -use crate::editor::routes; -use crate::editor::widgets::{ - add_link_form, - layout, - links, - resource_actions, - upload_widget -}; -use crate::icons; - mod add_link; mod create_playlist; mod create_subcollection; @@ -32,9 +10,11 @@ mod delete; mod delete_banner; mod delete_link; mod edit; +mod edit_manifest; mod r#move; mod update; mod update_banner; +mod update_manifest; mod upload_banner; pub use add_link::add_link; @@ -46,498 +26,9 @@ pub use delete::delete; pub use delete_banner::delete_banner; pub use delete_link::delete_link; pub use edit::edit; +pub use edit_manifest::edit_manifest; pub use r#move::r#move; pub use update::update; pub use update_banner::update_banner; +pub use update_manifest::update_manifest; pub use upload_banner::upload_banner; - -pub enum CollectionFormFeedback<'a> { - CreatePlaylist { - slug: Field, - title: Field - }, - CreateSubcollection { - slug: Field, - title: Field - }, - CreateVideo { - slug: Field, - title: Field - }, - None, - Update { - copy_link: Option, - description: &'a str, - download: Option, - embedding: Option, - feeds: Option, - offline: bool, - platform_integration: Option, - title: Field, - unlisted: bool, - video_order: &'a str - } -} - -pub fn edit_page( - collection: &Collection, - context: &Context, - form_feedback: CollectionFormFeedback, - language: &Language -) -> HttpResponse { - let site = context.get_site(); - let translations = &language.translations; - let t_not_saved = translations.not_saved; - - let errors = if collection.errors.is_empty() { - String::new() - } else { - collection.errors - .iter() - .map(|error| { - formatdoc!(r#" -
{error}
- "#) - }) - .collect::>() - .join("") - }; - - let t_upload_banner = translations.upload_banner; - let banner_upload_button = upload_widget(&routes::collection_upload_banner(collection), t_upload_banner); - let banner_form = match &collection.banner { - Some(banner) => { - let alt = if let Some(description) = &banner.description { - format!(r#"alt="{description}""#) - } else { - String::new() - }; - - // TODO: Could/should also use computed assets (also elsewhere, videos etc.) - let file_name = &banner.file_name; - let src = routes::poster(file_name, &collection.site_path); - - let mut description = Textarea::image_description("description", translations.banner_description); - - description - .id("banner_description") - .value_option(&banner.description); - - let t_delete_banner = translations.delete_banner; - let t_really_delete = translations.really_delete; - let t_update_description = translations.update_description; - - let delete_banner_action = routes::collection_delete_banner(collection); - let update_banner_action = routes::collection_update_banner(collection); - - format!(r#" - -
- {banner_upload_button} -
- -
-
-
- {description} - -
- "#) - } - None => banner_upload_button - }; - - let mut input_offline = Checkbox::new("offline", translations.offline); - let mut input_title = Input::text("title", translations.title); - let mut input_unlisted = Checkbox::new("unlisted", translations.unlisted); - let mut select_copy_link = Select::new("copy_link", translations.copy_link); - let mut select_download = Select::new("download", translations.downloads); - let mut select_embedding = Select::new("embedding", translations.embedding); - let mut select_feeds = Select::new("feeds", translations.feeds); - let mut select_platform_integration = Select::new("platform_integration", translations.automatic_link_previews_and_content_embedding_on_platforms); - let mut select_video_order = Select::new("video_order", translations.video_order); - let mut textarea_description = Textarea::markdown_subset(language, "description", translations.description); - - let t_disabled = translations.disabled; - let t_enabled = translations.enabled; - - let t_default_or_inherited = match collection.site_path.is_root_path() { - true => translations.default, - false => translations.inherited - }; - - let t_copy_link_inherited_enabled_disabled = if collection.copy_link_inherited() { t_enabled } else { t_disabled }; - select_copy_link.option("", format!("{t_copy_link_inherited_enabled_disabled} ({t_default_or_inherited})")); - select_copy_link.option("enabled", t_enabled); - select_copy_link.option("disabled", t_disabled); - - let t_download_inherited_enabled_disabled = if collection.download_inherited() { t_enabled } else { t_disabled }; - select_download.option("", format!("{t_download_inherited_enabled_disabled} ({t_default_or_inherited})")); - select_download.option("enabled", t_enabled); - select_download.option("disabled", t_disabled); - - let t_embedding_inherited_enabled_disabled = if collection.embedding_inherited() { t_enabled } else { t_disabled }; - select_embedding.option("", format!("{t_embedding_inherited_enabled_disabled} ({t_default_or_inherited})")); - select_embedding.option("enabled", t_enabled); - select_embedding.option("disabled", t_disabled); - - let t_feeds_inherited_enabled_disabled = if collection.feeds_inherited() { t_enabled } else { t_disabled }; - select_feeds.option("", format!("{t_feeds_inherited_enabled_disabled} ({t_default_or_inherited})")); - select_feeds.option("enabled", t_enabled); - select_feeds.option("disabled", t_disabled); - - let platform_integration_inherited = collection.platform_integration_inherited(); - let t_platform_integration_inherited_label = platform_integration_inherited.label(translations); - select_platform_integration.option("", format!("{t_platform_integration_inherited_label} ({t_default_or_inherited})")); - select_platform_integration.option("disabled", t_disabled); - select_platform_integration.option("link_previews", translations.link_previews_only); - select_platform_integration.option("content_embeds", translations.link_previews_and_content_embeds); - - // TODO: Same construction as in endpoints/playlist.rs, possibly encapsulate as shared helper - select_video_order.option("default", translations.default_sort_number_or_release_date_ascending); - select_video_order.option("release_date_asc", translations.release_date_ascending); - select_video_order.option("release_date_desc", translations.release_date_descending); - select_video_order.option("sort_number_asc", translations.sort_number_ascending); - select_video_order.option("sort_number_desc", translations.sort_number_descending); - select_video_order.option("title_asc", translations.title_ascending); - select_video_order.option("title_desc", translations.title_descending); - - textarea_description.id("collection_description"); - - if site.base_url.is_none() { - let failure_icon = icons::failure(translations.warning); - let t_public_address_not_configured_text = translations.public_address_not_configured_text; - let hint = format!("{failure_icon} {t_public_address_not_configured_text}"); - - select_embedding.hint(&hint); - select_feeds.hint(&hint); - select_platform_integration.hint(hint); - } - - let feedback_update = if let CollectionFormFeedback::Update { - copy_link, - description, - download, - embedding, - feeds, - offline, - platform_integration, - title, - unlisted, - video_order - } = &form_feedback { - let copy_link_value = match copy_link { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let download_value = match download { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let embedding_value = match embedding { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let feeds_value = match feeds { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let platform_integration_value = match platform_integration { - Some(platform_integration) => platform_integration.key(), - None => "" - }; - - input_offline.checked(*offline); - input_title.validated_value(title); - input_unlisted.checked(*unlisted); - select_copy_link.selected(copy_link_value); - select_download.selected(download_value); - select_embedding.selected(embedding_value); - select_feeds.selected(feeds_value); - select_platform_integration.selected(platform_integration_value); - select_video_order.selected(video_order); - textarea_description.value(description); - - format!(r#""#) - } else { - let copy_link = match collection.copy_link_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let download = match collection.download_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let embedding = match collection.embedding_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let feeds = match collection.feeds_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let platform_integration = match collection.platform_integration_local() { - Some(platform_integration) => platform_integration.key(), - None => "" - }; - - input_offline.checked(collection.offline_local()); - input_title.value_option(&collection.title); - input_unlisted.checked(collection.unlisted_local()); - select_copy_link.selected(copy_link); - select_download.selected(download); - select_embedding.selected(embedding); - select_feeds.selected(feeds); - select_platform_integration.selected(platform_integration); - select_video_order.selected(collection.video_order.key()); - textarea_description.value_option(&collection.description); - - String::new() - }; - - let t_upload_new_video = translations.upload_new_video; - let upload_url = routes::upload_video(&collection.site_path); - let video_upload_button = upload_widget(&upload_url, t_upload_new_video); - - let t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided = &translations.you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided; - - let new_playlist_form = { - let mut input_slug_create_playlist = Input::permalink( - "slug", - &collection.site_path, - &site, - translations - ); - let mut input_title_create_playlist = Input::text("title", translations.title); - - // TODO: Needs custom client-side validation - either title or slug must be given. - input_slug_create_playlist.id("slug_playlist"); - input_title_create_playlist.id("title_playlist"); - - let feedback = if let CollectionFormFeedback::CreatePlaylist { slug, title } = &form_feedback { - input_slug_create_playlist.validated_value(slug); - input_title_create_playlist.validated_value(title); - format!(r#""#) - } else { - String::new() - }; - - let t_create_playlist = translations.create_playlist; - let t_new_playlist = translations.new_playlist; - - let create_playlist_action = routes::collection_create_playlist(collection); - formatdoc!(r#" -
-

{t_new_playlist}

- -
-

- {t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided} -

- {input_title_create_playlist} - {input_slug_create_playlist} -
- {feedback} -
-
-
- "#) - }; - - let new_subcollection_form = { - let mut input_slug_create_subcollection = Input::permalink( - "slug", - &collection.site_path, - &site, - translations - ); - let mut input_title_create_subcollection = Input::text("title", translations.title); - - // TODO: Needs custom client-side validation - either title or slug must be given. - input_slug_create_subcollection.id("slug_subcollection"); - input_title_create_subcollection.id("title_subcollection"); - - let feedback_create_subcollection = if let CollectionFormFeedback::CreateSubcollection { slug, title } = &form_feedback { - input_slug_create_subcollection.validated_value(slug); - input_title_create_subcollection.validated_value(title); - format!(r#""#) - } else { - String::new() - }; - - let t_create_subcollection = translations.create_subcollection; - let t_new_subcollection = translations.new_subcollection; - - let create_subcollection_action = routes::collection_create_subcollection(collection); - formatdoc!(r#" -
-

{t_new_subcollection}

- -
-

- {t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided} -

- {input_title_create_subcollection} - {input_slug_create_subcollection} -
- {feedback_create_subcollection} -
-
-
- "#) - }; - - let new_video_form = { - let mut input_slug_create_video = Input::permalink( - "slug", - &collection.site_path, - &site, - translations - ); - let mut input_title_create_video = Input::text("title", translations.title); - - // TODO: Needs custom client-side validation - either title or slug must be given. - input_slug_create_video.id("slug_video"); - input_title_create_video.id("title_video"); - - let feedback = if let CollectionFormFeedback::CreateVideo { slug, title } = &form_feedback { - input_slug_create_video.validated_value(slug); - input_title_create_video.validated_value(title); - format!(r#""#) - } else { - String::new() - }; - - let t_create_video = translations.create_video; - let t_new_video = translations.new_video; - - let create_video_action = routes::collection_create_video(collection); - formatdoc!(r#" -
-

{t_new_video}

- -
-

- {t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided} -

- {input_title_create_video} - {input_slug_create_video} -
- {feedback} -
-
-
- "#) - }; - - let input_unlisted_conditionally_hidden = if !collection.site_path.is_root_path() { - input_unlisted.to_string() - } else if collection.unlisted_local() { - String::from(r#""#) - } else { - String::new() - }; - - let create_link_form = add_link_form(&routes::collection_add_link(collection), translations); - let links_form = links(&collection.links, &routes::collection_delete_link(collection), translations); - - let r_resource_actions = resource_actions( - context, - collection.offline(), - &site, - &collection.site_path, - translations - ); - - let collection_icon = icons::collection(translations.collection); - let collection_title = collection.title_or_slug_or_generic_label(translations); - - let t_banner_image = translations.banner_image; - let t_new_video = translations.new_video; - let t_update_collection = translations.update_collection; - - let update_action = routes::collection_edit(collection); - - let body = formatdoc!(r#" - {errors} - -
-

- {collection_icon} - {collection_title} -

- {r_resource_actions} -
- -
-
- {input_title} - {textarea_description} - {select_video_order} - {select_copy_link} - {select_download} - {select_embedding} - {select_feeds} - {select_platform_integration} - {input_offline} - {input_unlisted_conditionally_hidden} - -
- {feedback_update} -
-
-
- - {links_form} - - {create_link_form} - - {new_subcollection_form} - - {new_playlist_form} - - {new_video_form} - -
-

{t_new_video}

- {video_upload_button} -
- -
-

{t_banner_image}

-

- Heads up: Banner images are not yet used in the layout,
but this will be included in a future version of Hyper 8 -

- {banner_form} -
- "#); - - let html = layout( - &body, - context, - language, - Location::edit(&collection.site_path), - &site, - collection.title_or_slug_or_generic_label(translations) - ); - - HttpResponse::Ok() - .content_type(ContentType::html()) - .body(html) -} diff --git a/src/editor/endpoints/collection/create_playlist.rs b/src/editor/endpoints/collection/create_playlist.rs index 67be3d0..c15d8ff 100644 --- a/src/editor/endpoints/collection/create_playlist.rs +++ b/src/editor/endpoints/collection/create_playlist.rs @@ -21,8 +21,8 @@ use crate::editor::routes; use crate::editor::widgets::not_found; use crate::util::validate_slug; -use super::edit_page; -use super::CollectionFormFeedback; +use super::edit::edit_page; +use super::edit::CollectionFormFeedback; #[derive(Deserialize)] pub struct CreatePlaylistForm { diff --git a/src/editor/endpoints/collection/create_subcollection.rs b/src/editor/endpoints/collection/create_subcollection.rs index f9a7ba3..02f8ee9 100644 --- a/src/editor/endpoints/collection/create_subcollection.rs +++ b/src/editor/endpoints/collection/create_subcollection.rs @@ -20,8 +20,8 @@ use crate::editor::routes; use crate::editor::widgets::not_found; use crate::util::validate_slug; -use super::edit_page; -use super::CollectionFormFeedback; +use super::edit::edit_page; +use super::edit::CollectionFormFeedback; #[derive(Deserialize)] pub struct CreateSubcollectionForm { diff --git a/src/editor/endpoints/collection/create_video.rs b/src/editor/endpoints/collection/create_video.rs index e127a02..2c1e11d 100644 --- a/src/editor/endpoints/collection/create_video.rs +++ b/src/editor/endpoints/collection/create_video.rs @@ -21,8 +21,8 @@ use crate::editor::routes; use crate::editor::widgets::not_found; use crate::util::validate_slug; -use super::edit_page; -use super::CollectionFormFeedback; +use super::edit::edit_page; +use super::edit::CollectionFormFeedback; #[derive(Deserialize)] pub struct CreateVideoForm { diff --git a/src/editor/endpoints/collection/edit.rs b/src/editor/endpoints/collection/edit.rs index 76bc259..c52b8d2 100644 --- a/src/editor/endpoints/collection/edit.rs +++ b/src/editor/endpoints/collection/edit.rs @@ -4,13 +4,57 @@ use std::sync::Arc; use actix_web::HttpResponse; +use actix_web::http::header::ContentType; use actix_web::web::{Data, Path as ActixPath}; +use indoc::formatdoc; -use crate::{Context, SitePath}; -use crate::editor::widgets::not_found; +use crate::{ + Collection, + Context, + Field, + Language, + PlatformIntegration, + SitePath +}; +use crate::editor::{Checkbox, Input, Location, Select, Textarea}; +use crate::editor::routes; +use crate::editor::widgets::{ + add_link_form, + layout, + links, + not_found, + resource_actions, + upload_widget +}; +use crate::icons; -use super::edit_page; -use super::CollectionFormFeedback; +pub enum CollectionFormFeedback<'a> { + CreatePlaylist { + slug: Field, + title: Field + }, + CreateSubcollection { + slug: Field, + title: Field + }, + CreateVideo { + slug: Field, + title: Field + }, + None, + Update { + copy_link: Option, + description: &'a str, + download: Option, + embedding: Option, + feeds: Option, + offline: bool, + platform_integration: Option, + title: Field, + unlisted: bool, + video_order: &'a str + } +} pub async fn edit( context: Data>, @@ -23,3 +67,466 @@ pub async fn edit( None => not_found(&context, &language, &site_path) } } + +pub fn edit_page( + collection: &Collection, + context: &Context, + form_feedback: CollectionFormFeedback, + language: &Language +) -> HttpResponse { + let site = context.get_site(); + let translations = &language.translations; + let t_not_saved = translations.not_saved; + + let errors = if collection.errors.is_empty() { + String::new() + } else { + collection.errors + .iter() + .map(|error| { + formatdoc!(r#" +
{error}
+ "#) + }) + .collect::>() + .join("") + }; + + let t_upload_banner = translations.upload_banner; + let banner_upload_button = upload_widget(&routes::collection_upload_banner(collection), t_upload_banner); + let banner_form = match &collection.banner { + Some(banner) => { + let alt = if let Some(description) = &banner.description { + format!(r#"alt="{description}""#) + } else { + String::new() + }; + + // TODO: Could/should also use computed assets (also elsewhere, videos etc.) + let file_name = &banner.file_name; + let src = routes::poster(file_name, &collection.site_path); + + let mut description = Textarea::image_description("description", translations.banner_description); + + description + .id("banner_description") + .value_option(&banner.description); + + let t_delete_banner = translations.delete_banner; + let t_really_delete = translations.really_delete; + let t_update_description = translations.update_description; + + let delete_banner_action = routes::collection_delete_banner(collection); + let update_banner_action = routes::collection_update_banner(collection); + + format!(r#" + +
+ {banner_upload_button} +
+ +
+
+
+ {description} + +
+ "#) + } + None => banner_upload_button + }; + + let mut input_offline = Checkbox::new("offline", translations.offline); + let mut input_title = Input::text("title", translations.title); + let mut input_unlisted = Checkbox::new("unlisted", translations.unlisted); + let mut select_copy_link = Select::new("copy_link", translations.copy_link); + let mut select_download = Select::new("download", translations.downloads); + let mut select_embedding = Select::new("embedding", translations.embedding); + let mut select_feeds = Select::new("feeds", translations.feeds); + let mut select_platform_integration = Select::new("platform_integration", translations.automatic_link_previews_and_content_embedding_on_platforms); + let mut select_video_order = Select::new("video_order", translations.video_order); + let mut textarea_description = Textarea::markdown_subset(language, "description", translations.description); + + let t_disabled = translations.disabled; + let t_enabled = translations.enabled; + + let t_default_or_inherited = match collection.site_path.is_root_path() { + true => translations.default, + false => translations.inherited + }; + + let t_copy_link_inherited_enabled_disabled = if collection.copy_link_inherited() { t_enabled } else { t_disabled }; + select_copy_link.option("", format!("{t_copy_link_inherited_enabled_disabled} ({t_default_or_inherited})")); + select_copy_link.option("enabled", t_enabled); + select_copy_link.option("disabled", t_disabled); + + let t_download_inherited_enabled_disabled = if collection.download_inherited() { t_enabled } else { t_disabled }; + select_download.option("", format!("{t_download_inherited_enabled_disabled} ({t_default_or_inherited})")); + select_download.option("enabled", t_enabled); + select_download.option("disabled", t_disabled); + + let t_embedding_inherited_enabled_disabled = if collection.embedding_inherited() { t_enabled } else { t_disabled }; + select_embedding.option("", format!("{t_embedding_inherited_enabled_disabled} ({t_default_or_inherited})")); + select_embedding.option("enabled", t_enabled); + select_embedding.option("disabled", t_disabled); + + let t_feeds_inherited_enabled_disabled = if collection.feeds_inherited() { t_enabled } else { t_disabled }; + select_feeds.option("", format!("{t_feeds_inherited_enabled_disabled} ({t_default_or_inherited})")); + select_feeds.option("enabled", t_enabled); + select_feeds.option("disabled", t_disabled); + + let platform_integration_inherited = collection.platform_integration_inherited(); + let t_platform_integration_inherited_label = platform_integration_inherited.label(translations); + select_platform_integration.option("", format!("{t_platform_integration_inherited_label} ({t_default_or_inherited})")); + select_platform_integration.option("disabled", t_disabled); + select_platform_integration.option("link_previews", translations.link_previews_only); + select_platform_integration.option("content_embeds", translations.link_previews_and_content_embeds); + + // TODO: Same construction as in endpoints/playlist.rs, possibly encapsulate as shared helper + select_video_order.option("default", translations.default_sort_number_or_release_date_ascending); + select_video_order.option("release_date_asc", translations.release_date_ascending); + select_video_order.option("release_date_desc", translations.release_date_descending); + select_video_order.option("sort_number_asc", translations.sort_number_ascending); + select_video_order.option("sort_number_desc", translations.sort_number_descending); + select_video_order.option("title_asc", translations.title_ascending); + select_video_order.option("title_desc", translations.title_descending); + + textarea_description.id("collection_description"); + + if site.base_url.is_none() { + let failure_icon = icons::failure(translations.warning); + let t_public_address_not_configured_text = translations.public_address_not_configured_text; + let hint = format!("{failure_icon} {t_public_address_not_configured_text}"); + + select_embedding.hint(&hint); + select_feeds.hint(&hint); + select_platform_integration.hint(hint); + } + + let feedback_update = if let CollectionFormFeedback::Update { + copy_link, + description, + download, + embedding, + feeds, + offline, + platform_integration, + title, + unlisted, + video_order + } = &form_feedback { + let copy_link_value = match copy_link { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let download_value = match download { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let embedding_value = match embedding { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let feeds_value = match feeds { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let platform_integration_value = match platform_integration { + Some(platform_integration) => platform_integration.key(), + None => "" + }; + + input_offline.checked(*offline); + input_title.validated_value(title); + input_unlisted.checked(*unlisted); + select_copy_link.selected(copy_link_value); + select_download.selected(download_value); + select_embedding.selected(embedding_value); + select_feeds.selected(feeds_value); + select_platform_integration.selected(platform_integration_value); + select_video_order.selected(video_order); + textarea_description.value(description); + + format!(r#""#) + } else { + let copy_link = match collection.copy_link_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let download = match collection.download_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let embedding = match collection.embedding_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let feeds = match collection.feeds_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let platform_integration = match collection.platform_integration_local() { + Some(platform_integration) => platform_integration.key(), + None => "" + }; + + input_offline.checked(collection.offline_local()); + input_title.value_option(&collection.title); + input_unlisted.checked(collection.unlisted_local()); + select_copy_link.selected(copy_link); + select_download.selected(download); + select_embedding.selected(embedding); + select_feeds.selected(feeds); + select_platform_integration.selected(platform_integration); + select_video_order.selected(collection.video_order.key()); + textarea_description.value_option(&collection.description); + + String::new() + }; + + let t_upload_new_video = translations.upload_new_video; + let upload_url = routes::upload_video(&collection.site_path); + let video_upload_button = upload_widget(&upload_url, t_upload_new_video); + + let t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided = &translations.you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided; + + let new_playlist_form = { + let mut input_slug_create_playlist = Input::permalink( + "slug", + &collection.site_path, + &site, + translations + ); + let mut input_title_create_playlist = Input::text("title", translations.title); + + // TODO: Needs custom client-side validation - either title or slug must be given. + input_slug_create_playlist.id("slug_playlist"); + input_title_create_playlist.id("title_playlist"); + + let feedback = if let CollectionFormFeedback::CreatePlaylist { slug, title } = &form_feedback { + input_slug_create_playlist.validated_value(slug); + input_title_create_playlist.validated_value(title); + format!(r#""#) + } else { + String::new() + }; + + let t_create_playlist = translations.create_playlist; + let t_new_playlist = translations.new_playlist; + + let create_playlist_action = routes::collection_create_playlist(collection); + formatdoc!(r#" +
+

{t_new_playlist}

+ +
+

+ {t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided} +

+ {input_title_create_playlist} + {input_slug_create_playlist} +
+ {feedback} +
+
+
+ "#) + }; + + let new_subcollection_form = { + let mut input_slug_create_subcollection = Input::permalink( + "slug", + &collection.site_path, + &site, + translations + ); + let mut input_title_create_subcollection = Input::text("title", translations.title); + + // TODO: Needs custom client-side validation - either title or slug must be given. + input_slug_create_subcollection.id("slug_subcollection"); + input_title_create_subcollection.id("title_subcollection"); + + let feedback_create_subcollection = if let CollectionFormFeedback::CreateSubcollection { slug, title } = &form_feedback { + input_slug_create_subcollection.validated_value(slug); + input_title_create_subcollection.validated_value(title); + format!(r#""#) + } else { + String::new() + }; + + let t_create_subcollection = translations.create_subcollection; + let t_new_subcollection = translations.new_subcollection; + + let create_subcollection_action = routes::collection_create_subcollection(collection); + formatdoc!(r#" +
+

{t_new_subcollection}

+ +
+

+ {t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided} +

+ {input_title_create_subcollection} + {input_slug_create_subcollection} +
+ {feedback_create_subcollection} +
+
+
+ "#) + }; + + let new_video_form = { + let mut input_slug_create_video = Input::permalink( + "slug", + &collection.site_path, + &site, + translations + ); + let mut input_title_create_video = Input::text("title", translations.title); + + // TODO: Needs custom client-side validation - either title or slug must be given. + input_slug_create_video.id("slug_video"); + input_title_create_video.id("title_video"); + + let feedback = if let CollectionFormFeedback::CreateVideo { slug, title } = &form_feedback { + input_slug_create_video.validated_value(slug); + input_title_create_video.validated_value(title); + format!(r#""#) + } else { + String::new() + }; + + let t_create_video = translations.create_video; + let t_new_video = translations.new_video; + + let create_video_action = routes::collection_create_video(collection); + formatdoc!(r#" +
+

{t_new_video}

+ +
+

+ {t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided} +

+ {input_title_create_video} + {input_slug_create_video} +
+ {feedback} +
+
+
+ "#) + }; + + let input_unlisted_conditionally_hidden = if !collection.site_path.is_root_path() { + input_unlisted.to_string() + } else if collection.unlisted_local() { + String::from(r#""#) + } else { + String::new() + }; + + let create_link_form = add_link_form(&routes::collection_add_link(collection), translations); + let links_form = links(&collection.links, &routes::collection_delete_link(collection), translations); + + let r_resource_actions = resource_actions( + context, + collection.offline(), + &site, + &collection.site_path, + translations + ); + + let collection_icon = icons::collection(translations.collection); + let collection_title = collection.title_or_slug_or_generic_label(translations); + + let t_banner_image = translations.banner_image; + let t_new_video = translations.new_video; + let t_update_collection = translations.update_collection; + + let update_action = routes::collection_edit(collection); + + let body = formatdoc!(r#" + {errors} + +
+

+ {collection_icon} + {collection_title} +

+ {r_resource_actions} +
+ +
+
+ {input_title} + {textarea_description} + {select_video_order} + {select_copy_link} + {select_download} + {select_embedding} + {select_feeds} + {select_platform_integration} + {input_offline} + {input_unlisted_conditionally_hidden} + +
+ {feedback_update} +
+
+
+ + {links_form} + + {create_link_form} + + {new_subcollection_form} + + {new_playlist_form} + + {new_video_form} + +
+

{t_new_video}

+ {video_upload_button} +
+ +
+

{t_banner_image}

+

+ Heads up: Banner images are not yet used in the layout,
but this will be included in a future version of Hyper 8 +

+ {banner_form} +
+ "#); + + let html = layout( + &body, + context, + language, + Location::edit(&collection.site_path), + &site, + collection.title_or_slug_or_generic_label(translations) + ); + + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(html) +} diff --git a/src/editor/endpoints/collection/edit_manifest.rs b/src/editor/endpoints/collection/edit_manifest.rs new file mode 100644 index 0000000..1c3f4ae --- /dev/null +++ b/src/editor/endpoints/collection/edit_manifest.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2025 Simon Repp +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fs; +use std::sync::Arc; + +use actix_web::HttpResponse; +use actix_web::http::header::ContentType; +use actix_web::web::{Data, Path as ActixPath}; +use indoc::formatdoc; + +use crate::{ + Collection, + Context, + Language, + SitePath +}; +use crate::editor::{Location, Textarea}; +use crate::editor::routes; +use crate::editor::widgets::{layout, not_found}; +use crate::util::string_from_pathbuf; + +pub async fn edit_manifest( + context: Data>, + site_path: ActixPath +) -> HttpResponse { + let language = context.get_editor_language(); + + match context.get_collection(&site_path) { + Some(collection) => edit_manifest_page(&collection, &context, &language), + None => not_found(&context, &language, &site_path) + } +} + +pub fn edit_manifest_page( + collection: &Collection, + context: &Context, + language: &Language +) -> HttpResponse { + let site = context.get_site(); + let translations = &language.translations; + + let errors = if collection.errors.is_empty() { + String::new() + } else { + collection.errors + .iter() + .map(|error| { + formatdoc!(r#" +
{error}
+ "#) + }) + .collect::>() + .join("") + }; + + let manifest_path = context.site_dir + .join(collection.site_path.filesystem_relative()) + .join("collection.eno"); + + let content = fs::read_to_string(&manifest_path).unwrap(); + let manifest_path_str = string_from_pathbuf(manifest_path); + + let mut textarea_content = Textarea::manifest("content", &manifest_path_str); + + textarea_content.value(&content); + + let update_action = routes::collection_manifest(collection); + + let t_collection_manifest = translations.collection_manifest; + let t_update_manifest = translations.update_manifest; + let body = formatdoc!(r#" +

{t_collection_manifest}

+ + {errors} + +
+
+ {textarea_content} + +
+ +
+
+
+ "#); + + let html = layout( + &body, + context, + language, + Location::manifest(&collection.site_path), + &site, + collection.title_or_slug_or_generic_label(translations) + ); + + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(html) +} diff --git a/src/editor/endpoints/collection/update.rs b/src/editor/endpoints/collection/update.rs index e94073b..f5a13dc 100644 --- a/src/editor/endpoints/collection/update.rs +++ b/src/editor/endpoints/collection/update.rs @@ -19,8 +19,8 @@ use crate::{ use crate::editor::routes; use crate::editor::widgets::not_found; -use super::edit_page; -use super::CollectionFormFeedback; +use super::edit::edit_page; +use super::edit::CollectionFormFeedback; #[derive(Deserialize)] pub struct UpdateForm { diff --git a/src/editor/endpoints/collection/update_manifest.rs b/src/editor/endpoints/collection/update_manifest.rs new file mode 100644 index 0000000..fdef49a --- /dev/null +++ b/src/editor/endpoints/collection/update_manifest.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025 Simon Repp +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fs; +use std::sync::Arc; + +use actix_web::{Either, HttpResponse}; +use actix_web::web::{Data, Form, Path as ActixPath, Redirect}; +use serde_derive::Deserialize; + +use crate::{Context, SitePath}; +use crate::editor::routes; +use crate::editor::widgets::not_found; + +#[derive(Deserialize)] +pub struct UpdateForm { + content: String +} + +pub async fn update_manifest( + context: Data>, + form: Form, + site_path: ActixPath +) -> Either { + let language = context.get_editor_language(); + + match context.get_collection(&site_path) { + Some(collection) => { + let collection_dir = context.site_dir.join(collection.site_path.filesystem_relative()); + let manifest_path = collection_dir.join("collection.eno"); + + fs::write(manifest_path, &form.content).unwrap(); + + let site = context.get_site(); + context.update_from_filesystem(&collection_dir, &site); + + let redirect_url = routes::collection_manifest(&collection); + Either::Left(Redirect::to(redirect_url).see_other()) + } + None => return Either::Right(not_found(&context, &language, &site_path)) + } +} diff --git a/src/editor/endpoints/playlist.rs b/src/editor/endpoints/playlist.rs index 70529a7..14bb456 100644 --- a/src/editor/endpoints/playlist.rs +++ b/src/editor/endpoints/playlist.rs @@ -1,29 +1,6 @@ // SPDX-FileCopyrightText: 2024-2025 Simon Repp // SPDX-License-Identifier: AGPL-3.0-or-later -use actix_web::HttpResponse; -use actix_web::http::header::ContentType; - -use indoc::formatdoc; - -use crate::{ - Context, - Field, - Language, - PlatformIntegration, - Playlist -}; -use crate::editor::{Checkbox, Input, Location, Select, Textarea}; -use crate::editor::routes; -use crate::editor::widgets::{ - add_link_form, - layout, - links, - resource_actions, - upload_widget -}; -use crate::icons; - mod add_link; mod create_video; mod dangerous_actions; @@ -31,9 +8,11 @@ mod delete; mod delete_banner; mod delete_link; mod edit; +mod edit_manifest; mod r#move; mod update; mod update_banner; +mod update_manifest; mod upload_banner; pub use add_link::add_link; @@ -43,399 +22,9 @@ pub use delete::delete; pub use delete_banner::delete_banner; pub use delete_link::delete_link; pub use edit::edit; +pub use edit_manifest::edit_manifest; pub use r#move::r#move; pub use update::update; pub use update_banner::update_banner; +pub use update_manifest::update_manifest; pub use upload_banner::upload_banner; - -pub enum PlaylistFormFeedback<'a> { - CreateVideo { - slug: Field, - title: Field - }, - Update { - copy_link: Option, - description: &'a str, - download: Option, - embedding: Option, - feeds: Option, - offline: bool, - order: &'a str, - platform_integration: Option, - title: Field, - unlisted: bool - } -} - -pub fn edit_page( - context: &Context, - form_feedback: Option, - language: &Language, - playlist: &Playlist -) -> HttpResponse { - let site = context.get_site(); - let translations = &language.translations; - - let t_not_saved = translations.not_saved; - - let errors = if playlist.errors.is_empty() { - String::new() - } else { - playlist.errors - .iter() - .map(|error| { - formatdoc!(r#" -
{error}
- "#) - }) - .collect::>() - .join("") - }; - - let t_upload_banner = translations.upload_banner; - let banner_upload_button = upload_widget(&routes::playlist_upload_banner(playlist), t_upload_banner); - let banner_form = match &playlist.banner { - Some(banner) => { - let alt = if let Some(description) = &banner.description { - format!(r#"alt="{description}""#) - } else { - String::new() - }; - - // TODO: Could/should also use computed assets (also elsewhere, videos etc.) - let file_name = &banner.file_name; - let src = routes::poster(file_name, &playlist.site_path); - - let mut description = Textarea::image_description("description", translations.banner_description); - - description - .id("banner_description") - .value_option(&banner.description); - - let t_delete_banner = translations.delete_banner; - let t_really_delete = translations.really_delete; - let t_update_description = translations.update_description; - - let delete_banner_action = routes::playlist_delete_banner(playlist); - let update_banner_action = routes::playlist_update_banner(playlist); - format!(r#" - -
- {banner_upload_button} -
- -
-
-
- {description} - -
- "#) - } - None => banner_upload_button - }; - - let mut input_offline = Checkbox::new("offline", translations.offline); - let mut input_title = Input::text("title", translations.title); - let mut input_unlisted = Checkbox::new("unlisted", translations.unlisted); - let mut select_copy_link = Select::new("copy_link", translations.copy_link); - let mut select_download = Select::new("download", translations.downloads); - let mut select_embedding = Select::new("embedding", translations.embedding); - let mut select_feeds = Select::new("feeds", translations.feeds); - let mut select_order = Select::new("order", translations.order); - let mut select_platform_integration = Select::new("platform_integration", translations.automatic_link_previews_and_content_embedding_on_platforms); - let mut textarea_description = Textarea::markdown_subset(language, "description", translations.description); - - let t_disabled = translations.disabled; - let t_enabled = translations.enabled; - - let t_default_or_inherited = match playlist.site_path.is_root_path() { - true => translations.default, - false => translations.inherited - }; - - let t_copy_link_disabled_enabled = if playlist.copy_link_inherited() { t_enabled } else { t_disabled }; - select_copy_link.option("", format!("{t_copy_link_disabled_enabled} ({t_default_or_inherited})")); - select_copy_link.option("enabled", t_enabled); - select_copy_link.option("disabled", t_disabled); - - let t_download_disabled_enabled = if playlist.download_inherited() { t_enabled } else { t_disabled }; - select_download.option("", format!("{t_download_disabled_enabled} ({t_default_or_inherited})")); - select_download.option("enabled", t_enabled); - select_download.option("disabled", t_disabled); - - let t_embedding_disabled_enabled = if playlist.embedding_inherited() { t_enabled } else { t_disabled }; - select_embedding.option("", format!("{t_embedding_disabled_enabled} ({t_default_or_inherited})")); - select_embedding.option("enabled", t_enabled); - select_embedding.option("disabled", t_disabled); - - let t_feeds_disabled_enabled = if playlist.feeds_inherited() { t_enabled } else { t_disabled }; - select_feeds.option("", format!("{t_feeds_disabled_enabled} ({t_default_or_inherited})")); - select_feeds.option("enabled", t_enabled); - select_feeds.option("disabled", t_disabled); - - select_order.option("default", translations.default_sort_number_or_release_date_ascending); - select_order.option("release_date_asc", translations.release_date_ascending); - select_order.option("release_date_desc", translations.release_date_descending); - select_order.option("sort_number_asc", translations.sort_number_ascending); - select_order.option("sort_number_desc", translations.sort_number_descending); - select_order.option("title_asc", translations.title_ascending); - select_order.option("title_desc", translations.title_descending); - - let platform_integration_inherited = playlist.platform_integration_inherited(); - let t_platform_integration_inherited_label = platform_integration_inherited.label(translations); - select_platform_integration.option("", format!("{t_platform_integration_inherited_label} ({t_default_or_inherited})")); - select_platform_integration.option("disabled", t_disabled); - select_platform_integration.option("link_previews", translations.link_previews_only); - select_platform_integration.option("content_embeds", translations.link_previews_and_content_embeds); - - textarea_description.id("playlist_description"); - - if site.base_url.is_none() { - let failure_icon = icons::failure(translations.warning); - let t_public_address_not_configured_text = translations.public_address_not_configured_text; - let hint = format!("{failure_icon} {t_public_address_not_configured_text}"); - - select_embedding.hint(&hint); - select_feeds.hint(&hint); - select_platform_integration.hint(hint); - } - - let feedback_update = if let Some(PlaylistFormFeedback::Update { - copy_link, - description, - download, - embedding, - feeds, - offline, - order, - platform_integration, - title, - unlisted - }) = &form_feedback { - let copy_link_value = match copy_link { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let download_value = match download { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let embedding_value = match embedding { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let feeds_value = match feeds { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let platform_integration_value = match platform_integration { - Some(platform_integration) => platform_integration.key(), - None => "" - }; - - input_offline.checked(*offline); - input_title.validated_value(title); - input_unlisted.checked(*unlisted); - select_copy_link.selected(copy_link_value); - select_download.selected(download_value); - select_embedding.selected(embedding_value); - select_feeds.selected(feeds_value); - select_order.selected(order); - select_platform_integration.selected(platform_integration_value); - textarea_description.value(description); - - format!(r#""#) - } else { - let copy_link = match playlist.copy_link_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let download = match playlist.download_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let embedding = match playlist.embedding_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let feeds = match playlist.feeds_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let platform_integration = match playlist.platform_integration_local() { - Some(platform_integration) => platform_integration.key(), - None => "" - }; - - input_offline.checked(playlist.offline_local()); - input_title.value_option(&playlist.title); - input_unlisted.checked(playlist.unlisted_local()); - select_copy_link.selected(copy_link); - select_download.selected(download); - select_embedding.selected(embedding); - select_feeds.selected(feeds); - select_order.selected(playlist.order.key()); - select_platform_integration.selected(platform_integration); - textarea_description.value_option(&playlist.description); - - String::new() - }; - - let t_upload_new_video = translations.upload_new_video; - let upload_url = routes::upload_video(&playlist.site_path); - let video_upload_button = upload_widget(&upload_url, t_upload_new_video); - - let input_unlisted_conditionally_hidden = if !playlist.site_path.is_root_path() { - input_unlisted.to_string() - } else if playlist.unlisted_local() { - String::from(r#""#) - } else { - String::new() - }; - - let new_video_form = { - let mut input_slug_create_video = Input::permalink( - "slug", - &playlist.site_path, - &site, - translations - ); - let mut input_title_create_video = Input::text("title", translations.title); - - // TODO: Needs custom client-side validation - either title or slug must be given. - input_slug_create_video.id("slug_video"); - input_title_create_video.id("title_video"); - - let feedback = if let Some(PlaylistFormFeedback::CreateVideo { slug, title }) = &form_feedback { - input_slug_create_video.validated_value(slug); - input_title_create_video.validated_value(title); - format!(r#""#) - } else { - String::new() - }; - - let t_create_video = translations.create_video; - let t_new_video = translations.new_video; - let t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided = &translations.you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided; - - let create_video_action = routes::playlist_create_video(playlist); - formatdoc!(r#" -
-

{t_new_video}

- -
-

- {t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided} -

- {input_title_create_video} - {input_slug_create_video} -
- {feedback} -
-
-
- "#) - }; - - let create_link_form = add_link_form(&routes::playlist_add_link(playlist), translations); - let links_form = links(&playlist.links, &routes::playlist_delete_link(playlist), translations); - - let r_resource_actions = resource_actions( - context, - playlist.offline(), - &site, - &playlist.site_path, - translations - ); - - let playlist_icon = icons::playlist(translations.playlist); - let playlist_title = playlist.title_or_slug_or_generic_label(translations); - - let t_banner_image = translations.banner_image; - - let t_new_video = translations.new_video; - let t_update_playlist = translations.update_playlist; - - let update_action = routes::playlist_edit(playlist); - - let body = formatdoc!(r#" - {errors} - -
-

- {playlist_icon} - {playlist_title} -

- {r_resource_actions} -
- -
-
- {input_title} - {textarea_description} - {select_order} - {select_copy_link} - {select_download} - {select_embedding} - {select_feeds} - {select_platform_integration} - {input_offline} - {input_unlisted_conditionally_hidden} - -
- {feedback_update} -
-
-
- - {links_form} - - {create_link_form} - - {new_video_form} - -
-

{t_new_video}

- - {video_upload_button} -
- -
-

{t_banner_image}

-

- Heads up: Banner images are not yet used in the layout,
but this will be included in a future version of Hyper 8 -

- {banner_form} -
- "#); - - let html = layout( - &body, - context, - language, - Location::edit(&playlist.site_path), - &site, - playlist.title_or_slug_or_generic_label(translations) - ); - - HttpResponse::Ok() - .content_type(ContentType::html()) - .body(html) -} diff --git a/src/editor/endpoints/playlist/create_video.rs b/src/editor/endpoints/playlist/create_video.rs index 3fc6e68..73bfc90 100644 --- a/src/editor/endpoints/playlist/create_video.rs +++ b/src/editor/endpoints/playlist/create_video.rs @@ -21,8 +21,8 @@ use crate::editor::routes; use crate::editor::widgets::not_found; use crate::util::validate_slug; -use super::edit_page; -use super::PlaylistFormFeedback; +use super::edit::edit_page; +use super::edit::PlaylistFormFeedback; #[derive(Deserialize)] pub struct CreateVideoForm { diff --git a/src/editor/endpoints/playlist/edit.rs b/src/editor/endpoints/playlist/edit.rs index a210e3e..49f93b1 100644 --- a/src/editor/endpoints/playlist/edit.rs +++ b/src/editor/endpoints/playlist/edit.rs @@ -4,12 +4,48 @@ use std::sync::Arc; use actix_web::HttpResponse; +use actix_web::http::header::ContentType; use actix_web::web::{Data, Path as ActixPath}; +use indoc::formatdoc; -use crate::{Context, SitePath}; -use crate::editor::widgets::not_found; +use crate::{ + Context, + Field, + Language, + PlatformIntegration, + Playlist, + SitePath +}; +use crate::editor::{Checkbox, Input, Location, Select, Textarea}; +use crate::editor::routes; +use crate::editor::widgets::{ + add_link_form, + layout, + links, + not_found, + resource_actions, + upload_widget +}; +use crate::icons; -use super::edit_page; +pub enum PlaylistFormFeedback<'a> { + CreateVideo { + slug: Field, + title: Field + }, + Update { + copy_link: Option, + description: &'a str, + download: Option, + embedding: Option, + feeds: Option, + offline: bool, + order: &'a str, + platform_integration: Option, + title: Field, + unlisted: bool + } +} pub async fn edit( context: Data>, @@ -22,3 +58,376 @@ pub async fn edit( None => not_found(&context, &language, &site_path) } } + +pub fn edit_page( + context: &Context, + form_feedback: Option, + language: &Language, + playlist: &Playlist +) -> HttpResponse { + let site = context.get_site(); + let translations = &language.translations; + + let t_not_saved = translations.not_saved; + + let errors = if playlist.errors.is_empty() { + String::new() + } else { + playlist.errors + .iter() + .map(|error| { + formatdoc!(r#" +
{error}
+ "#) + }) + .collect::>() + .join("") + }; + + let t_upload_banner = translations.upload_banner; + let banner_upload_button = upload_widget(&routes::playlist_upload_banner(playlist), t_upload_banner); + let banner_form = match &playlist.banner { + Some(banner) => { + let alt = if let Some(description) = &banner.description { + format!(r#"alt="{description}""#) + } else { + String::new() + }; + + // TODO: Could/should also use computed assets (also elsewhere, videos etc.) + let file_name = &banner.file_name; + let src = routes::poster(file_name, &playlist.site_path); + + let mut description = Textarea::image_description("description", translations.banner_description); + + description + .id("banner_description") + .value_option(&banner.description); + + let t_delete_banner = translations.delete_banner; + let t_really_delete = translations.really_delete; + let t_update_description = translations.update_description; + + let delete_banner_action = routes::playlist_delete_banner(playlist); + let update_banner_action = routes::playlist_update_banner(playlist); + format!(r#" + +
+ {banner_upload_button} +
+ +
+
+
+ {description} + +
+ "#) + } + None => banner_upload_button + }; + + let mut input_offline = Checkbox::new("offline", translations.offline); + let mut input_title = Input::text("title", translations.title); + let mut input_unlisted = Checkbox::new("unlisted", translations.unlisted); + let mut select_copy_link = Select::new("copy_link", translations.copy_link); + let mut select_download = Select::new("download", translations.downloads); + let mut select_embedding = Select::new("embedding", translations.embedding); + let mut select_feeds = Select::new("feeds", translations.feeds); + let mut select_order = Select::new("order", translations.order); + let mut select_platform_integration = Select::new("platform_integration", translations.automatic_link_previews_and_content_embedding_on_platforms); + let mut textarea_description = Textarea::markdown_subset(language, "description", translations.description); + + let t_disabled = translations.disabled; + let t_enabled = translations.enabled; + + let t_default_or_inherited = match playlist.site_path.is_root_path() { + true => translations.default, + false => translations.inherited + }; + + let t_copy_link_disabled_enabled = if playlist.copy_link_inherited() { t_enabled } else { t_disabled }; + select_copy_link.option("", format!("{t_copy_link_disabled_enabled} ({t_default_or_inherited})")); + select_copy_link.option("enabled", t_enabled); + select_copy_link.option("disabled", t_disabled); + + let t_download_disabled_enabled = if playlist.download_inherited() { t_enabled } else { t_disabled }; + select_download.option("", format!("{t_download_disabled_enabled} ({t_default_or_inherited})")); + select_download.option("enabled", t_enabled); + select_download.option("disabled", t_disabled); + + let t_embedding_disabled_enabled = if playlist.embedding_inherited() { t_enabled } else { t_disabled }; + select_embedding.option("", format!("{t_embedding_disabled_enabled} ({t_default_or_inherited})")); + select_embedding.option("enabled", t_enabled); + select_embedding.option("disabled", t_disabled); + + let t_feeds_disabled_enabled = if playlist.feeds_inherited() { t_enabled } else { t_disabled }; + select_feeds.option("", format!("{t_feeds_disabled_enabled} ({t_default_or_inherited})")); + select_feeds.option("enabled", t_enabled); + select_feeds.option("disabled", t_disabled); + + select_order.option("default", translations.default_sort_number_or_release_date_ascending); + select_order.option("release_date_asc", translations.release_date_ascending); + select_order.option("release_date_desc", translations.release_date_descending); + select_order.option("sort_number_asc", translations.sort_number_ascending); + select_order.option("sort_number_desc", translations.sort_number_descending); + select_order.option("title_asc", translations.title_ascending); + select_order.option("title_desc", translations.title_descending); + + let platform_integration_inherited = playlist.platform_integration_inherited(); + let t_platform_integration_inherited_label = platform_integration_inherited.label(translations); + select_platform_integration.option("", format!("{t_platform_integration_inherited_label} ({t_default_or_inherited})")); + select_platform_integration.option("disabled", t_disabled); + select_platform_integration.option("link_previews", translations.link_previews_only); + select_platform_integration.option("content_embeds", translations.link_previews_and_content_embeds); + + textarea_description.id("playlist_description"); + + if site.base_url.is_none() { + let failure_icon = icons::failure(translations.warning); + let t_public_address_not_configured_text = translations.public_address_not_configured_text; + let hint = format!("{failure_icon} {t_public_address_not_configured_text}"); + + select_embedding.hint(&hint); + select_feeds.hint(&hint); + select_platform_integration.hint(hint); + } + + let feedback_update = if let Some(PlaylistFormFeedback::Update { + copy_link, + description, + download, + embedding, + feeds, + offline, + order, + platform_integration, + title, + unlisted + }) = &form_feedback { + let copy_link_value = match copy_link { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let download_value = match download { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let embedding_value = match embedding { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let feeds_value = match feeds { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let platform_integration_value = match platform_integration { + Some(platform_integration) => platform_integration.key(), + None => "" + }; + + input_offline.checked(*offline); + input_title.validated_value(title); + input_unlisted.checked(*unlisted); + select_copy_link.selected(copy_link_value); + select_download.selected(download_value); + select_embedding.selected(embedding_value); + select_feeds.selected(feeds_value); + select_order.selected(order); + select_platform_integration.selected(platform_integration_value); + textarea_description.value(description); + + format!(r#""#) + } else { + let copy_link = match playlist.copy_link_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let download = match playlist.download_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let embedding = match playlist.embedding_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let feeds = match playlist.feeds_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let platform_integration = match playlist.platform_integration_local() { + Some(platform_integration) => platform_integration.key(), + None => "" + }; + + input_offline.checked(playlist.offline_local()); + input_title.value_option(&playlist.title); + input_unlisted.checked(playlist.unlisted_local()); + select_copy_link.selected(copy_link); + select_download.selected(download); + select_embedding.selected(embedding); + select_feeds.selected(feeds); + select_order.selected(playlist.order.key()); + select_platform_integration.selected(platform_integration); + textarea_description.value_option(&playlist.description); + + String::new() + }; + + let t_upload_new_video = translations.upload_new_video; + let upload_url = routes::upload_video(&playlist.site_path); + let video_upload_button = upload_widget(&upload_url, t_upload_new_video); + + let input_unlisted_conditionally_hidden = if !playlist.site_path.is_root_path() { + input_unlisted.to_string() + } else if playlist.unlisted_local() { + String::from(r#""#) + } else { + String::new() + }; + + let new_video_form = { + let mut input_slug_create_video = Input::permalink( + "slug", + &playlist.site_path, + &site, + translations + ); + let mut input_title_create_video = Input::text("title", translations.title); + + // TODO: Needs custom client-side validation - either title or slug must be given. + input_slug_create_video.id("slug_video"); + input_title_create_video.id("title_video"); + + let feedback = if let Some(PlaylistFormFeedback::CreateVideo { slug, title }) = &form_feedback { + input_slug_create_video.validated_value(slug); + input_title_create_video.validated_value(title); + format!(r#""#) + } else { + String::new() + }; + + let t_create_video = translations.create_video; + let t_new_video = translations.new_video; + let t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided = &translations.you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided; + + let create_video_action = routes::playlist_create_video(playlist); + formatdoc!(r#" +
+

{t_new_video}

+ +
+

+ {t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided} +

+ {input_title_create_video} + {input_slug_create_video} +
+ {feedback} +
+
+
+ "#) + }; + + let create_link_form = add_link_form(&routes::playlist_add_link(playlist), translations); + let links_form = links(&playlist.links, &routes::playlist_delete_link(playlist), translations); + + let r_resource_actions = resource_actions( + context, + playlist.offline(), + &site, + &playlist.site_path, + translations + ); + + let playlist_icon = icons::playlist(translations.playlist); + let playlist_title = playlist.title_or_slug_or_generic_label(translations); + + let t_banner_image = translations.banner_image; + + let t_new_video = translations.new_video; + let t_update_playlist = translations.update_playlist; + + let update_action = routes::playlist_edit(playlist); + + let body = formatdoc!(r#" + {errors} + +
+

+ {playlist_icon} + {playlist_title} +

+ {r_resource_actions} +
+ +
+
+ {input_title} + {textarea_description} + {select_order} + {select_copy_link} + {select_download} + {select_embedding} + {select_feeds} + {select_platform_integration} + {input_offline} + {input_unlisted_conditionally_hidden} + +
+ {feedback_update} +
+
+
+ + {links_form} + + {create_link_form} + + {new_video_form} + +
+

{t_new_video}

+ + {video_upload_button} +
+ +
+

{t_banner_image}

+

+ Heads up: Banner images are not yet used in the layout,
but this will be included in a future version of Hyper 8 +

+ {banner_form} +
+ "#); + + let html = layout( + &body, + context, + language, + Location::edit(&playlist.site_path), + &site, + playlist.title_or_slug_or_generic_label(translations) + ); + + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(html) +} diff --git a/src/editor/endpoints/playlist/edit_manifest.rs b/src/editor/endpoints/playlist/edit_manifest.rs new file mode 100644 index 0000000..7d63c9d --- /dev/null +++ b/src/editor/endpoints/playlist/edit_manifest.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2025 Simon Repp +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fs; +use std::sync::Arc; + +use actix_web::HttpResponse; +use actix_web::http::header::ContentType; +use actix_web::web::{Data, Path as ActixPath}; +use indoc::formatdoc; + +use crate::{ + Context, + Language, + Playlist, + SitePath +}; +use crate::editor::{Location, Textarea}; +use crate::editor::routes; +use crate::editor::widgets::{layout, not_found}; +use crate::util::string_from_pathbuf; + +pub async fn edit_manifest( + context: Data>, + site_path: ActixPath +) -> HttpResponse { + let language = context.get_editor_language(); + + match context.get_playlist(&site_path) { + Some(playlist) => edit_manifest_page(&context, &language, &playlist), + None => not_found(&context, &language, &site_path) + } +} + +pub fn edit_manifest_page( + context: &Context, + language: &Language, + playlist: &Playlist +) -> HttpResponse { + let site = context.get_site(); + let translations = &language.translations; + + let errors = if playlist.errors.is_empty() { + String::new() + } else { + playlist.errors + .iter() + .map(|error| { + formatdoc!(r#" +
{error}
+ "#) + }) + .collect::>() + .join("") + }; + + let manifest_path = context.site_dir + .join(playlist.site_path.filesystem_relative()) + .join("playlist.eno"); + + let content = fs::read_to_string(&manifest_path).unwrap(); + let manifest_path_str = string_from_pathbuf(manifest_path); + + let mut textarea_content = Textarea::manifest("content", &manifest_path_str); + + textarea_content.value(&content); + + let update_action = routes::playlist_manifest(playlist); + + let t_playlist_manifest = translations.playlist_manifest; + let t_update_manifest = translations.update_manifest; + let body = formatdoc!(r#" +

{t_playlist_manifest}

+ + {errors} + +
+
+ {textarea_content} + +
+ +
+
+
+ "#); + + let html = layout( + &body, + context, + language, + Location::manifest(&playlist.site_path), + &site, + playlist.title_or_slug_or_generic_label(translations) + ); + + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(html) +} diff --git a/src/editor/endpoints/playlist/update.rs b/src/editor/endpoints/playlist/update.rs index 538a0e5..17c40b0 100644 --- a/src/editor/endpoints/playlist/update.rs +++ b/src/editor/endpoints/playlist/update.rs @@ -19,8 +19,8 @@ use crate::{ use crate::editor::routes; use crate::editor::widgets::not_found; -use super::edit_page; -use super::PlaylistFormFeedback; +use super::edit::edit_page; +use super::edit::PlaylistFormFeedback; #[derive(Deserialize)] pub struct PlaylistUpdateForm { diff --git a/src/editor/endpoints/playlist/update_manifest.rs b/src/editor/endpoints/playlist/update_manifest.rs new file mode 100644 index 0000000..b98050a --- /dev/null +++ b/src/editor/endpoints/playlist/update_manifest.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025 Simon Repp +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fs; +use std::sync::Arc; + +use actix_web::{Either, HttpResponse}; +use actix_web::web::{Data, Form, Path as ActixPath, Redirect}; +use serde_derive::Deserialize; + +use crate::{Context, SitePath}; +use crate::editor::routes; +use crate::editor::widgets::not_found; + +#[derive(Deserialize)] +pub struct UpdateForm { + content: String +} + +pub async fn update_manifest( + context: Data>, + form: Form, + site_path: ActixPath +) -> Either { + let language = context.get_editor_language(); + + match context.get_playlist(&site_path) { + Some(playlist) => { + let playlist_dir = context.site_dir.join(playlist.site_path.filesystem_relative()); + let manifest_path = playlist_dir.join("playlist.eno"); + + fs::write(manifest_path, &form.content).unwrap(); + + let site = context.get_site(); + context.update_from_filesystem(&playlist_dir, &site); + + let redirect_url = routes::playlist_manifest(&playlist); + Either::Left(Redirect::to(redirect_url).see_other()) + } + None => return Either::Right(not_found(&context, &language, &site_path)) + } +} diff --git a/src/editor/endpoints/site.rs b/src/editor/endpoints/site.rs index 702c3dd..ed0df57 100644 --- a/src/editor/endpoints/site.rs +++ b/src/editor/endpoints/site.rs @@ -1,273 +1,12 @@ -// SPDX-FileCopyrightText: 2024 Simon Repp +// SPDX-FileCopyrightText: 2024-2025 Simon Repp // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::Arc; +mod edit; +mod edit_manifest; +mod update; +mod update_manifest; -use actix_web::HttpResponse; -use actix_web::http::header::ContentType; -use actix_web::web::{Data, Either, Form, Redirect}; -use indoc::formatdoc; -use serde_derive::Deserialize; - -use crate::{ - AspectRatio, - Context, - Field, - Language, - Site, - SiteContent, - SitePath, - SiteUrl, - Theme -}; -use crate::editor::{Checkbox, Input, Location, Select}; -use crate::editor::routes; -use crate::editor::widgets::layout; - -#[derive(Deserialize)] -pub struct SiteForm { - base_url: String, - clean_urls: Option, - deployment_path: String, - deployment_server: String, - deployment_user: String, - language: String, - poster_aspect: String, - theme: String -} - -pub struct UpdateFormFeedback { - pub base_url: Field, - pub clean_urls: bool, - pub deployment_path: Field, - pub deployment_server: Field, - pub deployment_user: Field, - pub language: Field, - pub poster_aspect: Field, - pub theme: Theme -} - -fn edit_page( - context: &Context, - form_feedback: Option, - language: &Language, - site: &Site -) -> HttpResponse { - let translations = &language.translations; - - let errors = if site.errors.is_empty() { - String::new() - } else { - site.errors - .iter() - .map(|error| { - formatdoc!(r#" -
{error}
- "#) - }) - .collect::>() - .join("") - }; - - let mut clean_urls_checkbox = Checkbox::new("clean_urls", translations.clean_urls_label); - let mut input_base_url = Input::url("base_url", translations.public_address_label); - let mut input_deployment_path = Input::text("deployment_path", translations.deployment_path_label); - let mut input_deployment_server = Input::text("deployment_server", translations.deployment_server_label); - let mut input_deployment_user = Input::text("deployment_user", translations.deployment_user_label); - let mut input_language = Input::text("language", translations.language_label); - let mut input_poster_aspect = Input::text("poster_aspect", translations.poster_aspect_label); - - let mut select_theme = Select::new("theme", translations.theme); - for (key, label) in Theme::variants(translations) { - select_theme.option(key, label); - } - - // TODO: For better usability, include detailed hints (help texts, possibly toggeable, hidden by default) - // that give very clear indication about what base url, deployment path/server/user etc. are and - // what to pay attention to. - let t_not_saved = translations.not_saved; - let feedback = if let Some(unsaved) = &form_feedback { - clean_urls_checkbox.checked(unsaved.clean_urls); - input_base_url.validated_value(&unsaved.base_url); - input_deployment_path.validated_value(&unsaved.deployment_path); - input_deployment_server.validated_value(&unsaved.deployment_server); - input_deployment_user.validated_value(&unsaved.deployment_user); - input_language.validated_value(&unsaved.language); - input_poster_aspect.validated_value(&unsaved.poster_aspect); - select_theme.selected(unsaved.theme.key); - - format!(r#""#) - } else { - let language = if site.language.default { None } else { Some(&site.language.code) }; - let poster_aspect = match &site.poster_aspect { - AspectRatio::Custom { representation, .. } => Some(representation), - AspectRatio::Default => None - }; - - clean_urls_checkbox.checked(site.clean_urls); - input_base_url.value_option(&site.base_url.as_ref().map(|url| url.representation.clone())); - input_deployment_path.value_option(&site.deploy_config.path); - input_deployment_server.value_option(&site.deploy_config.server); - input_deployment_user.value_option(&site.deploy_config.user); - input_language.value_option(&language); - input_poster_aspect.value_option(&poster_aspect); - select_theme.selected(site.theme.key); - - String::new() - }; - - let t_deployment_is_currently_supported_text = translations.deployment_is_currently_supported_text; - let t_general = translations.general; - let t_site_settings = translations.site_settings; - let t_deployment = translations.deployment; - let t_update_site = translations.update_site; - - let update_action = routes::SITE_EDIT; - - let body = formatdoc!(r#" -
-

{t_site_settings}

- - {errors} - -
-

{t_general}

- {input_language} - {input_poster_aspect} - {select_theme} - {clean_urls_checkbox} - -

{t_deployment}

-

{t_deployment_is_currently_supported_text}

- {input_base_url} - {input_deployment_path} - {input_deployment_server} - {input_deployment_user} - -
- {feedback} -
-
-
- "#); - - let html = layout( - &body, - context, - language, - Location::site(&SitePath::root_path()), - &site, - t_site_settings - ); - - HttpResponse::Ok() - .content_type(ContentType::html()) - .body(html) -} - -pub async fn edit(context: Data>) -> HttpResponse { - let language = context.get_editor_language(); - - let site = context.get_site(); - edit_page(&context, None, &language, &site) -} - -pub async fn update( - context: Data>, - form: Form -) -> Either { - let language = context.get_editor_language(); - - let base_url_trimmed = form.base_url.trim(); - let clean_urls = form.clean_urls.is_some(); - let deployment_path_trimmed = form.deployment_path.trim(); - let deployment_server_trimmed = form.deployment_server.trim(); - let deployment_user_trimmed = form.deployment_user.trim(); - let language_trimmed = form.language.trim(); - let poster_aspect_trimmed = form.poster_aspect.trim(); - let theme = match Theme::from_key(&form.theme) { - Ok(theme) => theme, - Err(_) => Theme::default() - }; - - let base_url = if base_url_trimmed.is_empty() { - None - } else { - Some(SiteUrl::parse(base_url_trimmed)) - }; - - let poster_aspect = if poster_aspect_trimmed.is_empty() { - Ok(AspectRatio::Default) - } else { - AspectRatio::from_representation(poster_aspect_trimmed) - .map_err(|err| err.to_string()) - }; - - if base_url.as_ref().is_some_and(|base_url| base_url.is_err()) || poster_aspect.is_err() { - let site = context.get_site(); - - let form_feedback = UpdateFormFeedback { - base_url: Field::validated(base_url_trimmed, base_url.and_then(|base_url| base_url.err().map(|err| err.to_string()))), - clean_urls, - deployment_path: Field::valid(deployment_path_trimmed), - deployment_server: Field::valid(deployment_server_trimmed), - deployment_user: Field::valid(deployment_user_trimmed), - language: Field::valid(language_trimmed), - poster_aspect: Field::validated(poster_aspect_trimmed, poster_aspect.err()), - theme - }; - - Either::Right(edit_page(&context, Some(form_feedback), &language, &site)) - } else { - let deployment_path = if deployment_path_trimmed.is_empty() { - None - } else { - Some(deployment_path_trimmed.to_string()) - }; - - let deployment_server = if deployment_server_trimmed.is_empty() { - None - } else { - Some(deployment_server_trimmed.to_string()) - }; - - let deployment_user = if deployment_user_trimmed.is_empty() { - None - } else { - Some(deployment_user_trimmed.to_string()) - }; - - let language = if language_trimmed.is_empty() { - Language::default() - } else { - Language::from_code(language_trimmed) - }; - - let mutation = |site_mut: &mut Site| { - site_mut.base_url = base_url.map(|base_url| base_url.unwrap()); - site_mut.clean_urls = clean_urls; - site_mut.deploy_config.path = deployment_path; - site_mut.deploy_config.server = deployment_server; - site_mut.deploy_config.user = deployment_user; - site_mut.language = language; - site_mut.poster_aspect = poster_aspect.unwrap(); - site_mut.theme = theme; - site_mut.write_manifest(&context); - }; - - let site = context.update_site(mutation); - - let redirect_url = match site.content { - SiteContent::Collection(_) => "/collection/", - SiteContent::Playlist(_) => "/playlist/", - SiteContent::Video(_) => "/video/", - _ => todo!() - }; - - // Banner/Poster assets need recomputation when site.poster_aspect - // changes (which could have happened here). - context.compute_job_queue(); - - Either::Left(Redirect::to(redirect_url).see_other()) - } -} \ No newline at end of file +pub use edit::edit; +pub use edit_manifest::edit_manifest; +pub use update::update; +pub use update_manifest::update_manifest; diff --git a/src/editor/endpoints/site/edit.rs b/src/editor/endpoints/site/edit.rs new file mode 100644 index 0000000..3ea777b --- /dev/null +++ b/src/editor/endpoints/site/edit.rs @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2024-2025 Simon Repp +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use actix_web::HttpResponse; +use actix_web::http::header::ContentType; +use actix_web::web::Data; +use indoc::formatdoc; + +use crate::{ + AspectRatio, + Context, + Field, + Language, + Site, + SitePath, + Theme +}; +use crate::editor::{Checkbox, Input, Location, Select}; +use crate::editor::routes; +use crate::editor::widgets::layout; + +pub struct UpdateFormFeedback { + pub base_url: Field, + pub clean_urls: bool, + pub deployment_path: Field, + pub deployment_server: Field, + pub deployment_user: Field, + pub language: Field, + pub poster_aspect: Field, + pub theme: Theme +} + +pub async fn edit(context: Data>) -> HttpResponse { + let language = context.get_editor_language(); + + let site = context.get_site(); + edit_page(&context, None, &language, &site) +} + +pub fn edit_page( + context: &Context, + form_feedback: Option, + language: &Language, + site: &Site +) -> HttpResponse { + let translations = &language.translations; + + let errors = if site.errors.is_empty() { + String::new() + } else { + site.errors + .iter() + .map(|error| { + formatdoc!(r#" +
{error}
+ "#) + }) + .collect::>() + .join("") + }; + + let mut clean_urls_checkbox = Checkbox::new("clean_urls", translations.clean_urls_label); + let mut input_base_url = Input::url("base_url", translations.public_address_label); + let mut input_deployment_path = Input::text("deployment_path", translations.deployment_path_label); + let mut input_deployment_server = Input::text("deployment_server", translations.deployment_server_label); + let mut input_deployment_user = Input::text("deployment_user", translations.deployment_user_label); + let mut input_language = Input::text("language", translations.language_label); + let mut input_poster_aspect = Input::text("poster_aspect", translations.poster_aspect_label); + + let mut select_theme = Select::new("theme", translations.theme); + for (key, label) in Theme::variants(translations) { + select_theme.option(key, label); + } + + // TODO: For better usability, include detailed hints (help texts, possibly toggeable, hidden by default) + // that give very clear indication about what base url, deployment path/server/user etc. are and + // what to pay attention to. + let t_not_saved = translations.not_saved; + let feedback = if let Some(unsaved) = &form_feedback { + clean_urls_checkbox.checked(unsaved.clean_urls); + input_base_url.validated_value(&unsaved.base_url); + input_deployment_path.validated_value(&unsaved.deployment_path); + input_deployment_server.validated_value(&unsaved.deployment_server); + input_deployment_user.validated_value(&unsaved.deployment_user); + input_language.validated_value(&unsaved.language); + input_poster_aspect.validated_value(&unsaved.poster_aspect); + select_theme.selected(unsaved.theme.key); + + format!(r#""#) + } else { + let language = if site.language.default { None } else { Some(&site.language.code) }; + let poster_aspect = match &site.poster_aspect { + AspectRatio::Custom { representation, .. } => Some(representation), + AspectRatio::Default => None + }; + + clean_urls_checkbox.checked(site.clean_urls); + input_base_url.value_option(&site.base_url.as_ref().map(|url| url.representation.clone())); + input_deployment_path.value_option(&site.deploy_config.path); + input_deployment_server.value_option(&site.deploy_config.server); + input_deployment_user.value_option(&site.deploy_config.user); + input_language.value_option(&language); + input_poster_aspect.value_option(&poster_aspect); + select_theme.selected(site.theme.key); + + String::new() + }; + + let t_deployment_is_currently_supported_text = translations.deployment_is_currently_supported_text; + let t_general = translations.general; + let t_site_settings = translations.site_settings; + let t_deployment = translations.deployment; + let t_update_site = translations.update_site; + + let update_action = routes::SITE_EDIT; + + let body = formatdoc!(r#" +
+

{t_site_settings}

+ + {errors} + +
+

{t_general}

+ {input_language} + {input_poster_aspect} + {select_theme} + {clean_urls_checkbox} + +

{t_deployment}

+

{t_deployment_is_currently_supported_text}

+ {input_base_url} + {input_deployment_path} + {input_deployment_server} + {input_deployment_user} + +
+ {feedback} +
+
+
+ "#); + + let html = layout( + &body, + context, + language, + Location::site(&SitePath::root_path()), + &site, + t_site_settings + ); + + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(html) +} diff --git a/src/editor/endpoints/site/edit_manifest.rs b/src/editor/endpoints/site/edit_manifest.rs new file mode 100644 index 0000000..39b1895 --- /dev/null +++ b/src/editor/endpoints/site/edit_manifest.rs @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2025 Simon Repp +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fs; +use std::sync::Arc; + +use actix_web::HttpResponse; +use actix_web::http::header::ContentType; +use actix_web::web::Data; +use indoc::formatdoc; + +use crate::{ + Context, + Language, + SitePath +}; +use crate::editor::{Location, Textarea}; +use crate::editor::routes; +use crate::editor::widgets::layout; +use crate::util::string_from_pathbuf; + +pub async fn edit_manifest(context: Data>) -> HttpResponse { + let language = context.get_editor_language(); + edit_manifest_page(&context, &language) +} + +pub fn edit_manifest_page(context: &Context, language: &Language) -> HttpResponse { + let site = context.get_site(); + let translations = &language.translations; + + let errors = if site.errors.is_empty() { + String::new() + } else { + site.errors + .iter() + .map(|error| { + formatdoc!(r#" +
{error}
+ "#) + }) + .collect::>() + .join("") + }; + + let manifest_path = context.site_dir.join("site.eno"); + + let content = fs::read_to_string(&manifest_path).unwrap(); + let manifest_path_str = string_from_pathbuf(manifest_path); + + let mut textarea_content = Textarea::manifest("content", &manifest_path_str); + + textarea_content.value(&content); + + let update_action = routes::SITE_MANIFEST; + + let t_site_manifest = translations.site_manifest; + let t_update_manifest = translations.update_manifest; + let body = formatdoc!(r#" +

{t_site_manifest}

+ + {errors} + +
+
+ {textarea_content} + +
+ +
+
+
+ "#); + + let html = layout( + &body, + context, + language, + Location::site_manifest(&SitePath::root_path()), + &site, + site.title() + ); + + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(html) +} diff --git a/src/editor/endpoints/site/update.rs b/src/editor/endpoints/site/update.rs new file mode 100644 index 0000000..ff46998 --- /dev/null +++ b/src/editor/endpoints/site/update.rs @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2024 Simon Repp +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use actix_web::HttpResponse; +use actix_web::web::{Data, Either, Form, Redirect}; +use serde_derive::Deserialize; + +use crate::{ + AspectRatio, + Context, + Field, + Language, + Site, + SiteUrl, + Theme +}; +use crate::editor::routes; + +use super::edit::edit_page; +use super::edit::UpdateFormFeedback; + +#[derive(Deserialize)] +pub struct UpdateForm { + base_url: String, + clean_urls: Option, + deployment_path: String, + deployment_server: String, + deployment_user: String, + language: String, + poster_aspect: String, + theme: String +} + +pub async fn update( + context: Data>, + form: Form +) -> Either { + let language = context.get_editor_language(); + + let base_url_trimmed = form.base_url.trim(); + let clean_urls = form.clean_urls.is_some(); + let deployment_path_trimmed = form.deployment_path.trim(); + let deployment_server_trimmed = form.deployment_server.trim(); + let deployment_user_trimmed = form.deployment_user.trim(); + let language_trimmed = form.language.trim(); + let poster_aspect_trimmed = form.poster_aspect.trim(); + let theme = match Theme::from_key(&form.theme) { + Ok(theme) => theme, + Err(_) => Theme::default() + }; + + let base_url = if base_url_trimmed.is_empty() { + None + } else { + Some(SiteUrl::parse(base_url_trimmed)) + }; + + let poster_aspect = if poster_aspect_trimmed.is_empty() { + Ok(AspectRatio::Default) + } else { + AspectRatio::from_representation(poster_aspect_trimmed) + .map_err(|err| err.to_string()) + }; + + if base_url.as_ref().is_some_and(|base_url| base_url.is_err()) || poster_aspect.is_err() { + let site = context.get_site(); + + let form_feedback = UpdateFormFeedback { + base_url: Field::validated(base_url_trimmed, base_url.and_then(|base_url| base_url.err().map(|err| err.to_string()))), + clean_urls, + deployment_path: Field::valid(deployment_path_trimmed), + deployment_server: Field::valid(deployment_server_trimmed), + deployment_user: Field::valid(deployment_user_trimmed), + language: Field::valid(language_trimmed), + poster_aspect: Field::validated(poster_aspect_trimmed, poster_aspect.err()), + theme + }; + + Either::Right(edit_page(&context, Some(form_feedback), &language, &site)) + } else { + let deployment_path = if deployment_path_trimmed.is_empty() { + None + } else { + Some(deployment_path_trimmed.to_string()) + }; + + let deployment_server = if deployment_server_trimmed.is_empty() { + None + } else { + Some(deployment_server_trimmed.to_string()) + }; + + let deployment_user = if deployment_user_trimmed.is_empty() { + None + } else { + Some(deployment_user_trimmed.to_string()) + }; + + let language = if language_trimmed.is_empty() { + Language::default() + } else { + Language::from_code(language_trimmed) + }; + + let mutation = |site_mut: &mut Site| { + site_mut.base_url = base_url.map(|base_url| base_url.unwrap()); + site_mut.clean_urls = clean_urls; + site_mut.deploy_config.path = deployment_path; + site_mut.deploy_config.server = deployment_server; + site_mut.deploy_config.user = deployment_user; + site_mut.language = language; + site_mut.poster_aspect = poster_aspect.unwrap(); + site_mut.theme = theme; + site_mut.write_manifest(&context); + }; + + context.update_site(mutation); + + // Banner/Poster assets need recomputation when site.poster_aspect + // changes (which could have happened here). + context.compute_job_queue(); + + Either::Left(Redirect::to(routes::SITE_EDIT).see_other()) + } +} diff --git a/src/editor/endpoints/site/update_manifest.rs b/src/editor/endpoints/site/update_manifest.rs new file mode 100644 index 0000000..fdef49a --- /dev/null +++ b/src/editor/endpoints/site/update_manifest.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025 Simon Repp +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fs; +use std::sync::Arc; + +use actix_web::{Either, HttpResponse}; +use actix_web::web::{Data, Form, Path as ActixPath, Redirect}; +use serde_derive::Deserialize; + +use crate::{Context, SitePath}; +use crate::editor::routes; +use crate::editor::widgets::not_found; + +#[derive(Deserialize)] +pub struct UpdateForm { + content: String +} + +pub async fn update_manifest( + context: Data>, + form: Form, + site_path: ActixPath +) -> Either { + let language = context.get_editor_language(); + + match context.get_collection(&site_path) { + Some(collection) => { + let collection_dir = context.site_dir.join(collection.site_path.filesystem_relative()); + let manifest_path = collection_dir.join("collection.eno"); + + fs::write(manifest_path, &form.content).unwrap(); + + let site = context.get_site(); + context.update_from_filesystem(&collection_dir, &site); + + let redirect_url = routes::collection_manifest(&collection); + Either::Left(Redirect::to(redirect_url).see_other()) + } + None => return Either::Right(not_found(&context, &language, &site_path)) + } +} diff --git a/src/editor/endpoints/video.rs b/src/editor/endpoints/video.rs index 1a3b540..f7ae896 100644 --- a/src/editor/endpoints/video.rs +++ b/src/editor/endpoints/video.rs @@ -1,27 +1,6 @@ // SPDX-FileCopyrightText: 2024-2025 Simon Repp // SPDX-License-Identifier: AGPL-3.0-or-later -use actix_web::HttpResponse; -use actix_web::http::header::ContentType; -use indoc::formatdoc; - -use crate::{ - Context, - Field, - Language, - PlatformIntegration, - Video -}; -use crate::editor::{Checkbox, Input, Location, Select, Textarea}; -use crate::editor::routes; -use crate::editor::widgets::{ - add_link_form, - layout, - links, - resource_actions -}; -use crate::icons; - mod add_link; mod dangerous_actions; mod delete; @@ -30,12 +9,14 @@ mod delete_poster; mod delete_version; mod downsize; mod edit; +mod edit_manifest; mod media; mod r#move; mod poster; mod random_poster; mod subtitles; mod update; +mod update_manifest; mod update_poster; mod upload_poster; mod upload_video; @@ -48,269 +29,14 @@ pub use delete_poster::delete_poster; pub use delete_version::delete_version; pub use downsize::downsize; pub use edit::edit; +pub use edit_manifest::edit_manifest; pub use media::media; pub use r#move::r#move; pub use poster::poster; pub use random_poster::random_poster; pub use subtitles::subtitles; pub use update::update; +pub use update_manifest::update_manifest; pub use update_poster::update_poster; pub use upload_poster::upload_poster; pub use upload_video::upload_video; - -pub struct UpdateFormFeedback<'a> { - pub copy_link: Option, - pub description: &'a str, - pub download: Option, - pub embedding: Option, - pub offline: bool, - pub platform_integration: Option, - pub release_date: Field, - pub sort_number: Field, - pub title: Field, - pub unlisted: bool -} - -pub fn edit_page( - context: &Context, - form_feedback: Option, - language: &Language, - video: &Video -) -> HttpResponse { - let site = context.get_site(); - let translations = &language.translations; - let t_not_saved = translations.not_saved; - - let errors = if video.errors.is_empty() { - String::new() - } else { - video.errors - .iter() - .map(|error| { - formatdoc!(r#" -
{error}
- "#) - }) - .collect::>() - .join("") - }; - - let mut input_offline = Checkbox::new("offline", translations.offline); - let mut input_release_date = Input::date("release_date", translations.release_date); - let mut input_sort_number = Input::number("sort_number", translations.sort_number); - let mut input_title = Input::text("title", translations.title); - let mut input_unlisted = Checkbox::new("unlisted", translations.unlisted); - let mut select_copy_link = Select::new("copy_link", translations.copy_link); - let mut select_download = Select::new("download", translations.downloads); - let mut select_embedding = Select::new("embedding", translations.embedding); - let mut select_platform_integration = Select::new("platform_integration", translations.automatic_link_previews_and_content_embedding_on_platforms); - let mut textarea_description = Textarea::markdown_subset(language, "description", translations.description); - - let t_disabled = translations.disabled; - let t_enabled = translations.enabled; - - let t_default_or_inherited = match video.site_path.is_root_path() { - true => translations.default, - false => translations.inherited - }; - - let t_copy_link_disabled_enabled = if video.copy_link_inherited() { t_enabled } else { t_disabled }; - select_copy_link.option("", format!("{t_copy_link_disabled_enabled} ({t_default_or_inherited})")); - select_copy_link.option("enabled", t_enabled); - select_copy_link.option("disabled", t_disabled); - - let t_download_disabled_enabled = if video.download_inherited() { t_enabled } else { t_disabled }; - select_download.option("", format!("{t_download_disabled_enabled} ({t_default_or_inherited})")); - select_download.option("enabled", t_enabled); - select_download.option("disabled", t_disabled); - - let t_embedding_disabled_enabled = if video.embedding_inherited() { t_enabled } else { t_disabled }; - select_embedding.option("", format!("{t_embedding_disabled_enabled} ({t_default_or_inherited})")); - select_embedding.option("enabled", t_enabled); - select_embedding.option("disabled", t_disabled); - - let platform_integration_inherited = video.platform_integration_inherited(); - let t_platform_integration_inherited_label = platform_integration_inherited.label(translations); - select_platform_integration.option("", format!("{t_platform_integration_inherited_label} ({t_default_or_inherited})")); - select_platform_integration.option("disabled", t_disabled); - select_platform_integration.option("link_previews", translations.link_previews_only); - select_platform_integration.option("content_embeds", translations.link_previews_and_content_embeds); - - textarea_description.id("video_description"); - - if site.base_url.is_none() { - let failure_icon = icons::failure(translations.warning); - let t_public_address_not_configured_text = translations.public_address_not_configured_text; - let hint = format!("{failure_icon} {t_public_address_not_configured_text}"); - - select_embedding.hint(&hint); - select_platform_integration.hint(hint); - } - - let feedback_update = if let Some(UpdateFormFeedback { - copy_link, - description, - download, - embedding, - offline, - platform_integration, - release_date, - sort_number, - title, - unlisted - }) = &form_feedback { - let copy_link_value = match copy_link { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let download_value = match download { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let embedding_value = match embedding { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let platform_integration_value = match platform_integration { - Some(platform_integration) => platform_integration.key(), - None => "" - }; - - input_offline.checked(*offline); - input_release_date.validated_value(release_date); - input_sort_number.validated_value(sort_number); - input_title.validated_value(title); - input_unlisted.checked(*unlisted); - select_copy_link.selected(copy_link_value); - select_download.selected(download_value); - select_embedding.selected(embedding_value); - select_platform_integration.selected(platform_integration_value); - textarea_description.value(description); - - format!(r#""#) - } else { - let copy_link = match video.copy_link_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let download = match video.download_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let embedding = match video.embedding_local() { - Some(true) => "enabled", - Some(false) => "disabled", - None => "" - }; - - let platform_integration = match video.platform_integration_local() { - Some(platform_integration) => platform_integration.key(), - None => "" - }; - - let release_date = video.release_date.as_ref().map(|date| date.to_string()); - let sort_number = video.sort_number.as_ref().map(|number| number.to_string()); - - input_offline.checked(video.offline_local()); - input_release_date.value_option(&release_date); - input_sort_number.value_option(&sort_number); - input_title.value_option(&video.title); - input_unlisted.checked(video.unlisted_local()); - select_copy_link.selected(copy_link); - select_download.selected(download); - select_embedding.selected(embedding); - select_platform_integration.selected(platform_integration); - textarea_description.value_option(&video.description); - - String::new() - }; - - let input_unlisted_conditionally_hidden = if !video.site_path.is_root_path() { - input_unlisted.to_string() - } else if video.unlisted_local() { - String::from(r#""#) - } else { - String::new() - }; - - let create_link_form = add_link_form(&routes::video_add_link(video), translations); - let links_form = links(&video.links, &routes::video_delete_link(video), translations); - - let r_resource_actions = resource_actions( - context, - video.offline(), - &site, - &video.site_path, - translations - ); - - let video_icon = icons::video(translations.video); - let video_title = video.title_or_slug_or_generic_label(translations); - - let t_update_video = translations.update_video; - - let update_action = routes::video_edit(video); - - let body = formatdoc!(r#" - {errors} - -
-

- {video_icon} - {video_title} -

- {r_resource_actions} -
- -
-
- {input_title} - {textarea_description} -
- {input_release_date} - {input_sort_number} -
- {select_copy_link} - {select_download} - {select_embedding} - {select_platform_integration} - {input_offline} - {input_unlisted_conditionally_hidden} - -
- {feedback_update} -
-
-
- - {links_form} - - {create_link_form} - "#); - - let t_general = translations.general; - let video_title = video.title_or_slug_or_generic_label(translations); - let head_title = format!("{t_general} – {video_title}"); - let html = layout( - &body, - context, - language, - Location::edit(&video.site_path), - &site, - &head_title - ); - - HttpResponse::Ok() - .content_type(ContentType::html()) - .body(html) -} diff --git a/src/editor/endpoints/video/edit.rs b/src/editor/endpoints/video/edit.rs index 8fa7944..b52d675 100644 --- a/src/editor/endpoints/video/edit.rs +++ b/src/editor/endpoints/video/edit.rs @@ -4,12 +4,41 @@ use std::sync::Arc; use actix_web::HttpResponse; +use actix_web::http::header::ContentType; use actix_web::web::{Data, Path as ActixPath}; +use indoc::formatdoc; -use crate::{Context, SitePath}; -use crate::editor::widgets::not_found; +use crate::{ + Context, + Field, + Language, + PlatformIntegration, + SitePath, + Video +}; +use crate::editor::{Checkbox, Input, Location, Select, Textarea}; +use crate::editor::routes; +use crate::editor::widgets::{ + add_link_form, + layout, + links, + not_found, + resource_actions +}; +use crate::icons; -use super::edit_page; +pub struct UpdateFormFeedback<'a> { + pub copy_link: Option, + pub description: &'a str, + pub download: Option, + pub embedding: Option, + pub offline: bool, + pub platform_integration: Option, + pub release_date: Field, + pub sort_number: Field, + pub title: Field, + pub unlisted: bool +} pub async fn edit( context: Data>, @@ -24,3 +53,247 @@ pub async fn edit( edit_page(&context, None, &language, &video) } + +pub fn edit_page( + context: &Context, + form_feedback: Option, + language: &Language, + video: &Video +) -> HttpResponse { + let site = context.get_site(); + let translations = &language.translations; + let t_not_saved = translations.not_saved; + + let errors = if video.errors.is_empty() { + String::new() + } else { + video.errors + .iter() + .map(|error| { + formatdoc!(r#" +
{error}
+ "#) + }) + .collect::>() + .join("") + }; + + let mut input_offline = Checkbox::new("offline", translations.offline); + let mut input_release_date = Input::date("release_date", translations.release_date); + let mut input_sort_number = Input::number("sort_number", translations.sort_number); + let mut input_title = Input::text("title", translations.title); + let mut input_unlisted = Checkbox::new("unlisted", translations.unlisted); + let mut select_copy_link = Select::new("copy_link", translations.copy_link); + let mut select_download = Select::new("download", translations.downloads); + let mut select_embedding = Select::new("embedding", translations.embedding); + let mut select_platform_integration = Select::new("platform_integration", translations.automatic_link_previews_and_content_embedding_on_platforms); + let mut textarea_description = Textarea::markdown_subset(language, "description", translations.description); + + let t_disabled = translations.disabled; + let t_enabled = translations.enabled; + + let t_default_or_inherited = match video.site_path.is_root_path() { + true => translations.default, + false => translations.inherited + }; + + let t_copy_link_disabled_enabled = if video.copy_link_inherited() { t_enabled } else { t_disabled }; + select_copy_link.option("", format!("{t_copy_link_disabled_enabled} ({t_default_or_inherited})")); + select_copy_link.option("enabled", t_enabled); + select_copy_link.option("disabled", t_disabled); + + let t_download_disabled_enabled = if video.download_inherited() { t_enabled } else { t_disabled }; + select_download.option("", format!("{t_download_disabled_enabled} ({t_default_or_inherited})")); + select_download.option("enabled", t_enabled); + select_download.option("disabled", t_disabled); + + let t_embedding_disabled_enabled = if video.embedding_inherited() { t_enabled } else { t_disabled }; + select_embedding.option("", format!("{t_embedding_disabled_enabled} ({t_default_or_inherited})")); + select_embedding.option("enabled", t_enabled); + select_embedding.option("disabled", t_disabled); + + let platform_integration_inherited = video.platform_integration_inherited(); + let t_platform_integration_inherited_label = platform_integration_inherited.label(translations); + select_platform_integration.option("", format!("{t_platform_integration_inherited_label} ({t_default_or_inherited})")); + select_platform_integration.option("disabled", t_disabled); + select_platform_integration.option("link_previews", translations.link_previews_only); + select_platform_integration.option("content_embeds", translations.link_previews_and_content_embeds); + + textarea_description.id("video_description"); + + if site.base_url.is_none() { + let failure_icon = icons::failure(translations.warning); + let t_public_address_not_configured_text = translations.public_address_not_configured_text; + let hint = format!("{failure_icon} {t_public_address_not_configured_text}"); + + select_embedding.hint(&hint); + select_platform_integration.hint(hint); + } + + let feedback_update = if let Some(UpdateFormFeedback { + copy_link, + description, + download, + embedding, + offline, + platform_integration, + release_date, + sort_number, + title, + unlisted + }) = &form_feedback { + let copy_link_value = match copy_link { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let download_value = match download { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let embedding_value = match embedding { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let platform_integration_value = match platform_integration { + Some(platform_integration) => platform_integration.key(), + None => "" + }; + + input_offline.checked(*offline); + input_release_date.validated_value(release_date); + input_sort_number.validated_value(sort_number); + input_title.validated_value(title); + input_unlisted.checked(*unlisted); + select_copy_link.selected(copy_link_value); + select_download.selected(download_value); + select_embedding.selected(embedding_value); + select_platform_integration.selected(platform_integration_value); + textarea_description.value(description); + + format!(r#""#) + } else { + let copy_link = match video.copy_link_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let download = match video.download_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let embedding = match video.embedding_local() { + Some(true) => "enabled", + Some(false) => "disabled", + None => "" + }; + + let platform_integration = match video.platform_integration_local() { + Some(platform_integration) => platform_integration.key(), + None => "" + }; + + let release_date = video.release_date.as_ref().map(|date| date.to_string()); + let sort_number = video.sort_number.as_ref().map(|number| number.to_string()); + + input_offline.checked(video.offline_local()); + input_release_date.value_option(&release_date); + input_sort_number.value_option(&sort_number); + input_title.value_option(&video.title); + input_unlisted.checked(video.unlisted_local()); + select_copy_link.selected(copy_link); + select_download.selected(download); + select_embedding.selected(embedding); + select_platform_integration.selected(platform_integration); + textarea_description.value_option(&video.description); + + String::new() + }; + + let input_unlisted_conditionally_hidden = if !video.site_path.is_root_path() { + input_unlisted.to_string() + } else if video.unlisted_local() { + String::from(r#""#) + } else { + String::new() + }; + + let create_link_form = add_link_form(&routes::video_add_link(video), translations); + let links_form = links(&video.links, &routes::video_delete_link(video), translations); + + let r_resource_actions = resource_actions( + context, + video.offline(), + &site, + &video.site_path, + translations + ); + + let video_icon = icons::video(translations.video); + let video_title = video.title_or_slug_or_generic_label(translations); + + let t_update_video = translations.update_video; + + let update_action = routes::video_edit(video); + + let body = formatdoc!(r#" + {errors} + +
+

+ {video_icon} + {video_title} +

+ {r_resource_actions} +
+ +
+
+ {input_title} + {textarea_description} +
+ {input_release_date} + {input_sort_number} +
+ {select_copy_link} + {select_download} + {select_embedding} + {select_platform_integration} + {input_offline} + {input_unlisted_conditionally_hidden} + +
+ {feedback_update} +
+
+
+ + {links_form} + + {create_link_form} + "#); + + let t_general = translations.general; + let video_title = video.title_or_slug_or_generic_label(translations); + let head_title = format!("{t_general} – {video_title}"); + let html = layout( + &body, + context, + language, + Location::edit(&video.site_path), + &site, + &head_title + ); + + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(html) +} diff --git a/src/editor/endpoints/video/edit_manifest.rs b/src/editor/endpoints/video/edit_manifest.rs new file mode 100644 index 0000000..4e7de47 --- /dev/null +++ b/src/editor/endpoints/video/edit_manifest.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2025 Simon Repp +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fs; +use std::sync::Arc; + +use actix_web::HttpResponse; +use actix_web::http::header::ContentType; +use actix_web::web::{Data, Path as ActixPath}; +use indoc::formatdoc; + +use crate::{ + Context, + Language, + Video, + SitePath +}; +use crate::editor::{Location, Textarea}; +use crate::editor::routes; +use crate::editor::widgets::{layout, not_found}; +use crate::util::string_from_pathbuf; + +pub async fn edit_manifest( + context: Data>, + site_path: ActixPath +) -> HttpResponse { + let language = context.get_editor_language(); + + match context.get_video(&site_path) { + Some(video) => edit_manifest_page(&context, &language, &video), + None => not_found(&context, &language, &site_path) + } +} + +pub fn edit_manifest_page( + context: &Context, + language: &Language, + video: &Video +) -> HttpResponse { + let site = context.get_site(); + let translations = &language.translations; + + let errors = if video.errors.is_empty() { + String::new() + } else { + video.errors + .iter() + .map(|error| { + formatdoc!(r#" +
{error}
+ "#) + }) + .collect::>() + .join("") + }; + + let manifest_path = context.site_dir + .join(video.site_path.filesystem_relative()) + .join("video.eno"); + + let content = fs::read_to_string(&manifest_path).unwrap(); + let manifest_path_str = string_from_pathbuf(manifest_path); + + let mut textarea_content = Textarea::manifest("content", &manifest_path_str); + + textarea_content.value(&content); + + let update_action = routes::video_manifest(video); + + let t_video_manifest = translations.video_manifest; + let t_update_manifest = translations.update_manifest; + let body = formatdoc!(r#" +

{t_video_manifest}

+ + {errors} + +
+
+ {textarea_content} + +
+ +
+
+
+ "#); + + let html = layout( + &body, + context, + language, + Location::manifest(&video.site_path), + &site, + video.title_or_slug_or_generic_label(translations) + ); + + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(html) +} diff --git a/src/editor/endpoints/video/update.rs b/src/editor/endpoints/video/update.rs index 02fb408..52da0cf 100644 --- a/src/editor/endpoints/video/update.rs +++ b/src/editor/endpoints/video/update.rs @@ -18,8 +18,8 @@ use crate::{ use crate::editor::routes; use crate::editor::widgets::not_found; -use super::edit_page; -use super::UpdateFormFeedback; +use super::edit::edit_page; +use super::edit::UpdateFormFeedback; #[derive(Deserialize)] pub struct VideoUpdateForm { diff --git a/src/editor/endpoints/video/update_manifest.rs b/src/editor/endpoints/video/update_manifest.rs new file mode 100644 index 0000000..d516744 --- /dev/null +++ b/src/editor/endpoints/video/update_manifest.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025 Simon Repp +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fs; +use std::sync::Arc; + +use actix_web::{Either, HttpResponse}; +use actix_web::web::{Data, Form, Path as ActixPath, Redirect}; +use serde_derive::Deserialize; + +use crate::{Context, SitePath}; +use crate::editor::routes; +use crate::editor::widgets::not_found; + +#[derive(Deserialize)] +pub struct UpdateForm { + content: String +} + +pub async fn update_manifest( + context: Data>, + form: Form, + site_path: ActixPath +) -> Either { + let language = context.get_editor_language(); + + match context.get_video(&site_path) { + Some(video) => { + let video_dir = context.site_dir.join(video.site_path.filesystem_relative()); + let manifest_path = video_dir.join("video.eno"); + + fs::write(manifest_path, &form.content).unwrap(); + + let site = context.get_site(); + context.update_from_filesystem(&video_dir, &site); + + let redirect_url = routes::video_manifest(&video); + Either::Left(Redirect::to(redirect_url).see_other()) + } + None => return Either::Right(not_found(&context, &language, &site_path)) + } +} diff --git a/src/editor/routes.rs b/src/editor/routes.rs index 3528745..4836df1 100644 --- a/src/editor/routes.rs +++ b/src/editor/routes.rs @@ -47,6 +47,10 @@ pub fn collection_edit(collection: &Collection) -> String { format!("/collection/edit/{}", collection.site_path.normalized()) } +pub fn collection_manifest(collection: &Collection) -> String { + format!("/collection/manifest/{}", collection.site_path.normalized()) +} + pub fn collection_move(collection: &Collection) -> String { format!("/collection/move/{}", collection.site_path.normalized()) } @@ -103,6 +107,10 @@ pub fn playlist_edit(playlist: &Playlist) -> String { format!("/playlist/edit/{}", playlist.site_path.normalized()) } +pub fn playlist_manifest(playlist: &Playlist) -> String { + format!("/playlist/manifest/{}", playlist.site_path.normalized()) +} + pub fn playlist_move(playlist: &Playlist) -> String { format!("/playlist/move/{}", playlist.site_path.normalized()) } @@ -124,7 +132,9 @@ pub fn poster(file_name: &str, site_path: &SitePath) -> String { format!("/poster/{}{}", site_path.normalized(), file_name) } -pub const SITE_EDIT: &str = "/site/edit"; +pub const SITE_EDIT: &str = "/site/edit/"; + +pub const SITE_MANIFEST: &str = "/site/manifest/"; // TODO: This is nested in the video endpoint, but currently this is called // from a collection, playlist and video context, so semantically it would @@ -166,6 +176,10 @@ pub fn video_edit(video: &Video) -> String { format!("/video/edit/{}", video.site_path.normalized()) } +pub fn video_manifest(video: &Video) -> String { + format!("/video/manifest/{}", video.site_path.normalized()) +} + pub fn video_media(video: &Video) -> String { format!("/video/media/{}", video.site_path.normalized()) } diff --git a/src/editor/server.rs b/src/editor/server.rs index 4ddd5de..a04e837 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -68,6 +68,11 @@ pub async fn start( .route(get().to(collection::edit)) .route(post().to(collection::update)) ) + .service( + resource("/collection/manifest/{site_path:.*}") + .route(get().to(collection::edit_manifest)) + .route(post().to(collection::update_manifest)) + ) .route("/collection/move/{site_path:.*}", post().to(collection::r#move)) .route("/collection/update-banner/{site_path:.*}", post().to(collection::update_banner)) .route("/collection/upload-banner/{site_path:.*}", post().to(collection::upload_banner)) @@ -88,6 +93,11 @@ pub async fn start( .route(get().to(playlist::edit)) .route(post().to(playlist::update)) ) + .service( + resource("/playlist/manifest/{site_path:.*}") + .route(get().to(playlist::edit_manifest)) + .route(post().to(playlist::update_manifest)) + ) .route("/playlist/move/{site_path:.*}", post().to(playlist::r#move)) .route("/playlist/update-banner/{site_path:.*}", post().to(playlist::update_banner)) .route("/playlist/upload-banner/{site_path:.*}", post().to(playlist::upload_banner)) @@ -103,10 +113,15 @@ pub async fn start( .route("/processing", get().to(processing)) .route("/scripts.js", get().to(scripts)) .service( - resource("/site/edit") + resource("/site/edit/") .route(get().to(site::edit)) .route(post().to(site::update)) ) + .service( + resource("/site/manifest/") + .route(get().to(site::edit_manifest)) + .route(post().to(site::update_manifest)) + ) .route("/splash_logo.svg", get().to(splash_logo)) .route("/styles.css", get().to(styles)) .route("/styles.css", get().to(styles)) @@ -122,6 +137,11 @@ pub async fn start( .route(get().to(video::edit)) .route(post().to(video::update)) ) + .service( + resource("/video/manifest/{site_path:.*}") + .route(get().to(video::edit_manifest)) + .route(post().to(video::update_manifest)) + ) .route("/video/media/{site_path:.*}", get().to(video::media)) .route("/video/move/{site_path:.*}", post().to(video::r#move)) .route("/video/random-poster/{site_path:.*}", post().to(video::random_poster)) diff --git a/src/editor/textarea.rs b/src/editor/textarea.rs index f00979c..97f1e3e 100644 --- a/src/editor/textarea.rs +++ b/src/editor/textarea.rs @@ -28,6 +28,11 @@ impl<'a> Textarea<'a> { Textarea::new(None, key, label, 5) } + /// Initialize a textarea for a manifest + pub fn manifest(key: &'a str, label: &'a str) -> Textarea<'a> { + Textarea::new(None, key, label, 16) + } + /// Initialize a textarea for markdown subset text pub fn markdown_subset(language: &Language, key: &'a str, label: &'a str) -> Textarea<'a> { let hint = Some(language.translations.markdown_inline_links_are_supported_standalone_urls_are_automatically_linked); diff --git a/src/editor/widgets/tree_view.rs b/src/editor/widgets/tree_view.rs index 0a3ac4c..049432a 100644 --- a/src/editor/widgets/tree_view.rs +++ b/src/editor/widgets/tree_view.rs @@ -179,41 +179,64 @@ fn collection_node( let padding_left = format!("{}rem", padding_left_rem); let dangerous_actions_url = routes::collection_dangerous_actions(collection); + let edit_manifest_url = routes::collection_manifest(collection); let t_dangerous_actions = translations.dangerous_actions; - let t_general = translations.general; + let t_collection_manifest = translations.collection_manifest; + let t_collection_settings = translations.collection_settings; + let code_icon = icons::CODE; let danger_icon = icons::DANGER; - let general_icon = icons::configure(None); + let edit_icon = icons::EDIT; let dangerous_actions_active = if *subpage == Subpage::DangerousActions { "active" } else { "" }; - let general_active = if *subpage == Subpage::Edit { "active" } else { "" }; + let edit_active = if *subpage == Subpage::Edit { "active" } else { "" }; + let manifest_active = if *subpage == Subpage::Manifest { "active" } else { "" }; - let site_settings = if collection.site_path.is_root_path() { + let (site_manifest, site_settings) = if collection.site_path.is_root_path() { let logo = icons::logo(None); + + let t_site_manifest = translations.site_manifest; let t_site_settings = translations.site_settings; + + let site_manifest_url = routes::SITE_MANIFEST; let site_settings_url = routes::SITE_EDIT; + + let site_manifest_active = if *subpage == Subpage::SiteManifest { "active" } else { "" }; let site_settings_active = if *subpage == Subpage::SiteSettings { "active" } else { "" }; - formatdoc!(r#" + + let site_manifest = formatdoc!(r#" + + {code_icon} {t_site_manifest} + + "#); + + let site_settings = formatdoc!(r#" {logo} {t_site_settings} - "#) + "#); + + (site_manifest, site_settings) } else { - String::new() + (String::new(), String::new()) }; formatdoc!(r#" "#) @@ -273,41 +296,64 @@ fn playlist_node( let padding_left = format!("{}rem", padding_left_rem); let dangerous_actions_url = routes::playlist_dangerous_actions(playlist); + let edit_manifest_url = routes::playlist_manifest(playlist); let t_dangerous_actions = translations.dangerous_actions; - let t_general = translations.general; + let t_playlist_manifest = translations.playlist_manifest; + let t_playlist_settings = translations.playlist_settings; + let code_icon = icons::CODE; let danger_icon = icons::DANGER; - let general_icon = icons::configure(None); + let edit_icon = icons::EDIT; let dangerous_actions_active = if *subpage == Subpage::DangerousActions { "active" } else { "" }; - let general_active = if *subpage == Subpage::Edit { "active" } else { "" }; + let edit_active = if *subpage == Subpage::Edit { "active" } else { "" }; + let manifest_active = if *subpage == Subpage::Manifest { "active" } else { "" }; - let site_settings = if playlist.site_path.is_root_path() { + let (site_manifest, site_settings) = if playlist.site_path.is_root_path() { let logo = icons::logo(None); + + let t_site_manifest = translations.site_manifest; let t_site_settings = translations.site_settings; + + let site_manifest_url = routes::SITE_MANIFEST; let site_settings_url = routes::SITE_EDIT; + + let site_manifest_active = if *subpage == Subpage::SiteManifest { "active" } else { "" }; let site_settings_active = if *subpage == Subpage::SiteSettings { "active" } else { "" }; - formatdoc!(r#" + + let site_manifest = formatdoc!(r#" + + {code_icon} {t_site_manifest} + + "#); + + let site_settings = formatdoc!(r#" {logo} {t_site_settings} - "#) + "#); + + (site_manifest, site_settings) } else { - String::new() + (String::new(), String::new()) }; formatdoc!(r#" "#) @@ -557,55 +603,78 @@ fn video_node( let padding_left = format!("{}rem", padding_left_rem); let dangerous_actions_url = routes::video_dangerous_actions(video); + let edit_manifest_url = routes::video_manifest(video); let media_url = routes::video_media(video); let subtitles_url = routes::video_subtitles(video); let t_dangerous_actions = translations.dangerous_actions; - let t_general = translations.general; let t_media = translations.media; let t_subtitles = translations.subtitles; + let t_video_manifest = translations.video_manifest; + let t_video_settings = translations.video_settings; + let code_icon = icons::CODE; let danger_icon = icons::DANGER; - let general_icon = icons::configure(None); + let edit_icon = icons::EDIT; let media_icon = icons::storage(None); let subtitles_icon = icons::subtitles(None); let dangerous_actions_active = if *subpage == Subpage::DangerousActions { "active" } else { "" }; - let general_active = if *subpage == Subpage::Edit { "active" } else { "" }; + let edit_active = if *subpage == Subpage::Edit { "active" } else { "" }; + let manifest_active = if *subpage == Subpage::Manifest { "active" } else { "" }; let media_active = if *subpage == Subpage::Media { "active" } else { "" }; let subtitles_active = if *subpage == Subpage::Subtitles { "active" } else { "" }; - let site_settings = if video.site_path.is_root_path() { + let (site_manifest, site_settings) = if video.site_path.is_root_path() { let logo = icons::logo(None); + + let t_site_manifest = translations.site_manifest; let t_site_settings = translations.site_settings; + + let site_manifest_url = routes::SITE_MANIFEST; let site_settings_url = routes::SITE_EDIT; + + let site_manifest_active = if *subpage == Subpage::SiteManifest { "active" } else { "" }; let site_settings_active = if *subpage == Subpage::SiteSettings { "active" } else { "" }; - formatdoc!(r#" + + let site_manifest = formatdoc!(r#" + + {code_icon} {t_site_manifest} + + "#); + + let site_settings = formatdoc!(r#" {logo} {t_site_settings} - "#) + "#); + + (site_manifest, site_settings) } else { - String::new() + (String::new(), String::new()) }; formatdoc!(r#" "#) diff --git a/src/icons.rs b/src/icons.rs index a820cf2..681a3dc 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -10,6 +10,13 @@ pub const ATOM: &str = indoc!(r#" "#); +/// The stylized string "" +pub const CODE: &str = indoc!(r#" + +"#); + /// A "grid of grids" where a square is technically split into 16 squares, but /// visually appears to be split into 4 squares, with each "sub-square" /// itself having been split into 4 squares again. @@ -112,6 +119,14 @@ pub const DOWNLOAD: &str = indoc!(r#" "#); +/// Visually represented as a rounded outline of a square, whose top-right +/// corner is left open, a stylized pen pointing inwards towards the center. +pub const EDIT: &str = indoc!(r#" + +"#); + pub fn embed() -> String { formatdoc!(r#"