Introduce manifest edit pages in the editor

This commit is contained in:
Simon Repp
2025-05-03 11:34:39 +02:00
parent f1d3f80e1f
commit b7127722d9
32 changed files with 2270 additions and 1530 deletions
+4
View File
@@ -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).
+16
View File
@@ -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,
+4 -513
View File
@@ -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<bool>,
description: &'a str,
download: Option<bool>,
embedding: Option<bool>,
feeds: Option<bool>,
offline: bool,
platform_integration: Option<PlatformIntegration>,
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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"
<img {alt} class="thumbnail" src="{src}">
<div class="button_group">
{banner_upload_button}
<form action="{delete_banner_action}" data-confirm data-confirm-label="{t_delete_banner}" data-confirm-text="{t_really_delete}" method="post">
<button class="danger">{t_delete_banner}</button>
</form>
</div>
<form action="{update_banner_action}" data-warn-discard method="post">
{description}
<button class="action">{t_update_description}</button>
</form>
"#)
}
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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"
<div class="form_group">
<h2>{t_new_playlist}</h2>
<form action="{create_playlist_action}" autocomplete="off" data-warn-discard method="post">
<p>
{t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided}
</p>
{input_title_create_playlist}
{input_slug_create_playlist}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_create_playlist}</button> {feedback}
</div>
</form>
</div>
"#)
};
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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"
<div class="form_group">
<h2>{t_new_subcollection}</h2>
<form action="{create_subcollection_action}" autocomplete="off" data-warn-discard method="post">
<p>
{t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided}
</p>
{input_title_create_subcollection}
{input_slug_create_subcollection}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_create_subcollection}</button> {feedback_create_subcollection}
</div>
</form>
</div>
"#)
};
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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"
<div class="form_group">
<h2>{t_new_video}</h2>
<form action="{create_video_action}" autocomplete="off" data-warn-discard method="post">
<p>
{t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided}
</p>
{input_title_create_video}
{input_slug_create_video}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_create_video}</button> {feedback}
</div>
</form>
</div>
"#)
};
let input_unlisted_conditionally_hidden = if !collection.site_path.is_root_path() {
input_unlisted.to_string()
} else if collection.unlisted_local() {
String::from(r#"<input name="unlisted" type="hidden" value="checked">"#)
} 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}
<div class="form_group">
<h2 class="with_button">
{collection_icon}
<span>{collection_title}</span>
</h2>
{r_resource_actions}
</div>
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{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}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_collection}</button> {feedback_update}
</div>
</form>
</div>
{links_form}
{create_link_form}
{new_subcollection_form}
{new_playlist_form}
{new_video_form}
<div class="form_group">
<h2>{t_new_video}</h2>
{video_upload_button}
</div>
<div class="form_group">
<h2>{t_banner_image}</h2>
<p style="background: var(--danger); border-radius: .5rem; color: var(--l-1); padding: 1rem;">
Heads up: Banner images are not yet used in the layout,<br>but this will be included in a future version of Hyper 8
</p>
{banner_form}
</div>
"#);
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)
}
@@ -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 {
@@ -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 {
@@ -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 {
+511 -4
View File
@@ -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<bool>,
description: &'a str,
download: Option<bool>,
embedding: Option<bool>,
feeds: Option<bool>,
offline: bool,
platform_integration: Option<PlatformIntegration>,
title: Field,
unlisted: bool,
video_order: &'a str
}
}
pub async fn edit(
context: Data<Arc<Context>>,
@@ -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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"
<img {alt} class="thumbnail" src="{src}">
<div class="button_group">
{banner_upload_button}
<form action="{delete_banner_action}" data-confirm data-confirm-label="{t_delete_banner}" data-confirm-text="{t_really_delete}" method="post">
<button class="danger">{t_delete_banner}</button>
</form>
</div>
<form action="{update_banner_action}" data-warn-discard method="post">
{description}
<button class="action">{t_update_description}</button>
</form>
"#)
}
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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"
<div class="form_group">
<h2>{t_new_playlist}</h2>
<form action="{create_playlist_action}" autocomplete="off" data-warn-discard method="post">
<p>
{t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided}
</p>
{input_title_create_playlist}
{input_slug_create_playlist}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_create_playlist}</button> {feedback}
</div>
</form>
</div>
"#)
};
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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"
<div class="form_group">
<h2>{t_new_subcollection}</h2>
<form action="{create_subcollection_action}" autocomplete="off" data-warn-discard method="post">
<p>
{t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided}
</p>
{input_title_create_subcollection}
{input_slug_create_subcollection}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_create_subcollection}</button> {feedback_create_subcollection}
</div>
</form>
</div>
"#)
};
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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"
<div class="form_group">
<h2>{t_new_video}</h2>
<form action="{create_video_action}" autocomplete="off" data-warn-discard method="post">
<p>
{t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided}
</p>
{input_title_create_video}
{input_slug_create_video}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_create_video}</button> {feedback}
</div>
</form>
</div>
"#)
};
let input_unlisted_conditionally_hidden = if !collection.site_path.is_root_path() {
input_unlisted.to_string()
} else if collection.unlisted_local() {
String::from(r#"<input name="unlisted" type="hidden" value="checked">"#)
} 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}
<div class="form_group">
<h2 class="with_button">
{collection_icon}
<span>{collection_title}</span>
</h2>
{r_resource_actions}
</div>
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{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}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_collection}</button> {feedback_update}
</div>
</form>
</div>
{links_form}
{create_link_form}
{new_subcollection_form}
{new_playlist_form}
{new_video_form}
<div class="form_group">
<h2>{t_new_video}</h2>
{video_upload_button}
</div>
<div class="form_group">
<h2>{t_banner_image}</h2>
<p style="background: var(--danger); border-radius: .5rem; color: var(--l-1); padding: 1rem;">
Heads up: Banner images are not yet used in the layout,<br>but this will be included in a future version of Hyper 8
</p>
{banner_form}
</div>
"#);
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)
}
@@ -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<Arc<Context>>,
site_path: ActixPath<SitePath>
) -> 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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"
<h2>{t_collection_manifest}</h2>
{errors}
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{textarea_content}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_manifest}</button>
</div>
</form>
</div>
"#);
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)
}
+2 -2
View File
@@ -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 {
@@ -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<Arc<Context>>,
form: Form<UpdateForm>,
site_path: ActixPath<SitePath>
) -> Either<Redirect, HttpResponse> {
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))
}
}
+4 -415
View File
@@ -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<bool>,
description: &'a str,
download: Option<bool>,
embedding: Option<bool>,
feeds: Option<bool>,
offline: bool,
order: &'a str,
platform_integration: Option<PlatformIntegration>,
title: Field,
unlisted: bool
}
}
pub fn edit_page(
context: &Context,
form_feedback: Option<PlaylistFormFeedback>,
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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"
<img {alt} class="thumbnail" src="{src}">
<div class="button_group">
{banner_upload_button}
<form action="{delete_banner_action}" data-confirm data-confirm-label="{t_delete_banner}" data-confirm-text="{t_really_delete}" method="post">
<button class="danger">{t_delete_banner}</button>
</form>
</div>
<form action="{update_banner_action}" data-warn-discard method="post">
{description}
<button class="action">{t_update_description}</button>
</form>
"#)
}
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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"<input name="unlisted" type="hidden" value="checked">"#)
} 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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"
<div class="form_group">
<h2>{t_new_video}</h2>
<form action="{create_video_action}" autocomplete="off" data-warn-discard method="post">
<p>
{t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided}
</p>
{input_title_create_video}
{input_slug_create_video}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_create_video}</button> {feedback}
</div>
</form>
</div>
"#)
};
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}
<div class="form_group">
<h2 class="with_button">
{playlist_icon}
<span>{playlist_title}</span>
</h2>
{r_resource_actions}
</div>
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{input_title}
{textarea_description}
{select_order}
{select_copy_link}
{select_download}
{select_embedding}
{select_feeds}
{select_platform_integration}
{input_offline}
{input_unlisted_conditionally_hidden}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_playlist}</button> {feedback_update}
</div>
</form>
</div>
{links_form}
{create_link_form}
{new_video_form}
<div class="form_group">
<h2>{t_new_video}</h2>
{video_upload_button}
</div>
<div class="form_group">
<h2>{t_banner_image}</h2>
<p style="background: var(--danger); border-radius: .5rem; color: var(--l-1); padding: 1rem;">
Heads up: Banner images are not yet used in the layout,<br>but this will be included in a future version of Hyper 8
</p>
{banner_form}
</div>
"#);
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)
}
@@ -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 {
+412 -3
View File
@@ -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<bool>,
description: &'a str,
download: Option<bool>,
embedding: Option<bool>,
feeds: Option<bool>,
offline: bool,
order: &'a str,
platform_integration: Option<PlatformIntegration>,
title: Field,
unlisted: bool
}
}
pub async fn edit(
context: Data<Arc<Context>>,
@@ -22,3 +58,376 @@ pub async fn edit(
None => not_found(&context, &language, &site_path)
}
}
pub fn edit_page(
context: &Context,
form_feedback: Option<PlaylistFormFeedback>,
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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"
<img {alt} class="thumbnail" src="{src}">
<div class="button_group">
{banner_upload_button}
<form action="{delete_banner_action}" data-confirm data-confirm-label="{t_delete_banner}" data-confirm-text="{t_really_delete}" method="post">
<button class="danger">{t_delete_banner}</button>
</form>
</div>
<form action="{update_banner_action}" data-warn-discard method="post">
{description}
<button class="action">{t_update_description}</button>
</form>
"#)
}
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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"<input name="unlisted" type="hidden" value="checked">"#)
} 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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"
<div class="form_group">
<h2>{t_new_video}</h2>
<form action="{create_video_action}" autocomplete="off" data-warn-discard method="post">
<p>
{t_you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided}
</p>
{input_title_create_video}
{input_slug_create_video}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_create_video}</button> {feedback}
</div>
</form>
</div>
"#)
};
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}
<div class="form_group">
<h2 class="with_button">
{playlist_icon}
<span>{playlist_title}</span>
</h2>
{r_resource_actions}
</div>
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{input_title}
{textarea_description}
{select_order}
{select_copy_link}
{select_download}
{select_embedding}
{select_feeds}
{select_platform_integration}
{input_offline}
{input_unlisted_conditionally_hidden}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_playlist}</button> {feedback_update}
</div>
</form>
</div>
{links_form}
{create_link_form}
{new_video_form}
<div class="form_group">
<h2>{t_new_video}</h2>
{video_upload_button}
</div>
<div class="form_group">
<h2>{t_banner_image}</h2>
<p style="background: var(--danger); border-radius: .5rem; color: var(--l-1); padding: 1rem;">
Heads up: Banner images are not yet used in the layout,<br>but this will be included in a future version of Hyper 8
</p>
{banner_form}
</div>
"#);
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)
}
@@ -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<Arc<Context>>,
site_path: ActixPath<SitePath>
) -> 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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"
<h2>{t_playlist_manifest}</h2>
{errors}
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{textarea_content}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_manifest}</button>
</div>
</form>
</div>
"#);
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)
}
+2 -2
View File
@@ -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 {
@@ -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<Arc<Context>>,
form: Form<UpdateForm>,
site_path: ActixPath<SitePath>
) -> Either<Redirect, HttpResponse> {
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))
}
}
+9 -270
View File
@@ -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<String>,
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<UpdateFormFeedback>,
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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"
<div class="form_group">
<h2>{t_site_settings}</h2>
{errors}
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
<h3>{t_general}</h3>
{input_language}
{input_poster_aspect}
{select_theme}
{clean_urls_checkbox}
<h3>{t_deployment}</h3>
<p>{t_deployment_is_currently_supported_text}</p>
{input_base_url}
{input_deployment_path}
{input_deployment_server}
{input_deployment_user}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_site}</button> {feedback}
</div>
</form>
</div>
"#);
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<Arc<Context>>) -> HttpResponse {
let language = context.get_editor_language();
let site = context.get_site();
edit_page(&context, None, &language, &site)
}
pub async fn update(
context: Data<Arc<Context>>,
form: Form<SiteForm>
) -> Either<Redirect, HttpResponse> {
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())
}
}
pub use edit::edit;
pub use edit_manifest::edit_manifest;
pub use update::update;
pub use update_manifest::update_manifest;
+158
View File
@@ -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<Arc<Context>>) -> 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<UpdateFormFeedback>,
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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"
<div class="form_group">
<h2>{t_site_settings}</h2>
{errors}
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
<h3>{t_general}</h3>
{input_language}
{input_poster_aspect}
{select_theme}
{clean_urls_checkbox}
<h3>{t_deployment}</h3>
<p>{t_deployment_is_currently_supported_text}</p>
{input_base_url}
{input_deployment_path}
{input_deployment_server}
{input_deployment_user}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_site}</button> {feedback}
</div>
</form>
</div>
"#);
let html = layout(
&body,
context,
language,
Location::site(&SitePath::root_path()),
&site,
t_site_settings
);
HttpResponse::Ok()
.content_type(ContentType::html())
.body(html)
}
@@ -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<Arc<Context>>) -> 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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"
<h2>{t_site_manifest}</h2>
{errors}
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{textarea_content}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_manifest}</button>
</div>
</form>
</div>
"#);
let html = layout(
&body,
context,
language,
Location::site_manifest(&SitePath::root_path()),
&site,
site.title()
);
HttpResponse::Ok()
.content_type(ContentType::html())
.body(html)
}
+127
View File
@@ -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<String>,
deployment_path: String,
deployment_server: String,
deployment_user: String,
language: String,
poster_aspect: String,
theme: String
}
pub async fn update(
context: Data<Arc<Context>>,
form: Form<UpdateForm>
) -> Either<Redirect, HttpResponse> {
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())
}
}
@@ -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<Arc<Context>>,
form: Form<UpdateForm>,
site_path: ActixPath<SitePath>
) -> Either<Redirect, HttpResponse> {
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))
}
}
+4 -278
View File
@@ -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<bool>,
pub description: &'a str,
pub download: Option<bool>,
pub embedding: Option<bool>,
pub offline: bool,
pub platform_integration: Option<PlatformIntegration>,
pub release_date: Field,
pub sort_number: Field,
pub title: Field,
pub unlisted: bool
}
pub fn edit_page(
context: &Context,
form_feedback: Option<UpdateFormFeedback>,
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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"<input name="unlisted" type="hidden" value="checked">"#)
} 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}
<div class="form_group">
<h2 class="with_button">
{video_icon}
<span>{video_title}</span>
</h2>
{r_resource_actions}
</div>
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{input_title}
{textarea_description}
<div class="form_split divide_3_1">
{input_release_date}
{input_sort_number}
</div>
{select_copy_link}
{select_download}
{select_embedding}
{select_platform_integration}
{input_offline}
{input_unlisted_conditionally_hidden}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_video}</button> {feedback_update}
</div>
</form>
</div>
{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)
}
+276 -3
View File
@@ -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<bool>,
pub description: &'a str,
pub download: Option<bool>,
pub embedding: Option<bool>,
pub offline: bool,
pub platform_integration: Option<PlatformIntegration>,
pub release_date: Field,
pub sort_number: Field,
pub title: Field,
pub unlisted: bool
}
pub async fn edit(
context: Data<Arc<Context>>,
@@ -24,3 +53,247 @@ pub async fn edit(
edit_page(&context, None, &language, &video)
}
pub fn edit_page(
context: &Context,
form_feedback: Option<UpdateFormFeedback>,
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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"<span class="feedback">{t_not_saved}</span>"#)
} 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#"<input name="unlisted" type="hidden" value="checked">"#)
} 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}
<div class="form_group">
<h2 class="with_button">
{video_icon}
<span>{video_title}</span>
</h2>
{r_resource_actions}
</div>
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{input_title}
{textarea_description}
<div class="form_split divide_3_1">
{input_release_date}
{input_sort_number}
</div>
{select_copy_link}
{select_download}
{select_embedding}
{select_platform_integration}
{input_offline}
{input_unlisted_conditionally_hidden}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_video}</button> {feedback_update}
</div>
</form>
</div>
{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)
}
+100
View File
@@ -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<Arc<Context>>,
site_path: ActixPath<SitePath>
) -> 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#"
<pre class="error">{error}</pre>
"#)
})
.collect::<Vec<String>>()
.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#"
<h2>{t_video_manifest}</h2>
{errors}
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{textarea_content}
<div style="align-items: center; column-gap: .5rem; display: flex;">
<button class="action">{t_update_manifest}</button>
</div>
</form>
</div>
"#);
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)
}
+2 -2
View File
@@ -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 {
@@ -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<Arc<Context>>,
form: Form<UpdateForm>,
site_path: ActixPath<SitePath>
) -> Either<Redirect, HttpResponse> {
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))
}
}
+15 -1
View File
@@ -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())
}
+21 -1
View File
@@ -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))
+5
View File
@@ -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);
+97 -28
View File
@@ -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#"
<a class="{site_manifest_active} subpage" href="{site_manifest_url}" style="padding-left: {padding_left};">
{code_icon} {t_site_manifest}
</a>
"#);
let site_settings = formatdoc!(r#"
<a class="{site_settings_active} subpage" href="{site_settings_url}" style="padding-left: {padding_left};">
{logo} {t_site_settings}
</a>
"#)
"#);
(site_manifest, site_settings)
} else {
String::new()
(String::new(), String::new())
};
formatdoc!(r#"
<div class="active">
{node}
<div class="subpages">
<a class="{general_active} subpage" href="{edit_url}" style="padding-left: {padding_left};">
{general_icon} {t_general}
<a class="{edit_active} subpage" href="{edit_url}" style="padding-left: {padding_left};">
{edit_icon} {t_collection_settings}
</a>
{site_settings}
<a class="{dangerous_actions_active} subpage" href="{dangerous_actions_url}" style="padding-left: {padding_left};">
{danger_icon} {t_dangerous_actions}
</a>
<a class="{manifest_active} subpage" href="{edit_manifest_url}" style="padding-left: {padding_left};">
{code_icon} {t_collection_manifest}
</a>
{site_manifest}
</div>
</div>
"#)
@@ -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#"
<a class="{site_manifest_active} subpage" href="{site_manifest_url}" style="padding-left: {padding_left};">
{code_icon} {t_site_manifest}
</a>
"#);
let site_settings = formatdoc!(r#"
<a class="{site_settings_active} subpage" href="{site_settings_url}" style="padding-left: {padding_left};">
{logo} {t_site_settings}
</a>
"#)
"#);
(site_manifest, site_settings)
} else {
String::new()
(String::new(), String::new())
};
formatdoc!(r#"
<div class="active">
{node}
<div class="subpages">
<a class="{general_active} subpage" href="{edit_url}" style="padding-left: {padding_left};">
{general_icon} {t_general}
<a class="{edit_active} subpage" href="{edit_url}" style="padding-left: {padding_left};">
{edit_icon} {t_playlist_settings}
</a>
{site_settings}
<a class="{dangerous_actions_active} subpage" href="{dangerous_actions_url}" style="padding-left: {padding_left};">
{danger_icon} {t_dangerous_actions}
</a>
<a class="{manifest_active} subpage" href="{edit_manifest_url}" style="padding-left: {padding_left};">
{code_icon} {t_playlist_manifest}
</a>
{site_manifest}
</div>
</div>
"#)
@@ -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#"
<a class="{site_manifest_active} subpage" href="{site_manifest_url}" style="padding-left: {padding_left};">
{code_icon} {t_site_manifest}
</a>
"#);
let site_settings = formatdoc!(r#"
<a class="{site_settings_active} subpage" href="{site_settings_url}" style="padding-left: {padding_left};">
{logo} {t_site_settings}
</a>
"#)
"#);
(site_manifest, site_settings)
} else {
String::new()
(String::new(), String::new())
};
formatdoc!(r#"
<div class="active">
{node}
<div class="subpages">
<a class="{general_active} subpage" href="{edit_url}" style="padding-left: {padding_left};">
{general_icon} {t_general}
<a class="{edit_active} subpage" href="{edit_url}" style="padding-left: {padding_left};">
{edit_icon} {t_video_settings}
</a>
{site_settings}
<a class="{media_active} subpage" href="{media_url}" style="padding-left: {padding_left};">
{media_icon} {t_media}
</a>
<a class="{subtitles_active} subpage" href="{subtitles_url}" style="padding-left: {padding_left};">
{subtitles_icon} {t_subtitles}
</a>
{site_settings}
<a class="{dangerous_actions_active} subpage" href="{dangerous_actions_url}" style="padding-left: {padding_left};">
{danger_icon} {t_dangerous_actions}
</a>
<a class="{manifest_active} subpage" href="{edit_manifest_url}" style="padding-left: {padding_left};">
{code_icon} {t_video_manifest}
</a>
{site_manifest}
</div>
</div>
"#)
+15
View File
@@ -10,6 +10,13 @@ pub const ATOM: &str = indoc!(r#"
</svg>
"#);
/// The stylized string "</>"
pub const CODE: &str = indoc!(r#"
<svg aria-hidden="true" width="1em" height="1em" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="m36.361 12.1c-1.3444 0.053521-2.5572 0.96996-2.9238 2.3379l-0.43945 1.6445-4.0469 15.102-3.3223 12.402-1.1641 4.3418c-0.45108 1.6837 0.5488 3.414 2.2324 3.8652 1.6837 0.45107 3.4141-0.54684 3.8652-2.2305l1.4668-5.4746 3.0195-11.271 3.3223-12.4 1.1641-4.3438c0.45108-1.6836-0.54684-3.4141-2.2305-3.8652-0.31569-0.084577-0.63311-0.11977-0.94336-0.10742zm9.5898 6.1074c-0.80776 0-1.6142 0.30759-2.2305 0.92383-1.2325 1.2326-1.2325 3.2323 0 4.4648l8.0156 8.0156c0.25619 0.25619 0.25619 0.52701 0 0.7832l-8.0098 8.0098c-1.2325 1.2326-1.2325 3.2303 0 4.4629 1.2326 1.2325 3.2323 1.2325 4.4648 0l8.0078-8.0078c0.16573-0.16573 0.32178-0.33898 0.4668-0.51758 2.1752-2.679 2.0192-6.7093-0.4668-9.1953l-8.0156-8.0156c-0.61628-0.61624-1.4247-0.92383-2.2324-0.92383zm-27.91 0.001953c-0.80776 0-1.6161 0.30759-2.2324 0.92383l-8.0078 8.0078c-2.6517 2.6517-2.6517 7.0612 0 9.7129l8.0156 8.0156c1.2326 1.2325 3.2303 1.2325 4.4629 0 1.2325-1.2326 1.2325-3.2323 0-4.4648l-8.0156-8.0156c-0.06405-0.06405-0.11251-0.12782-0.14453-0.19336-0.09607-0.19661-0.047612-0.3977 0.14453-0.58984l8.0098-8.0098c1.2325-1.2326 1.2325-3.2303 0-4.4629-0.61628-0.61624-1.4247-0.92383-2.2324-0.92383z"/>
</svg>
"#);
/// 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#"
</svg>
"#);
/// 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#"
<svg aria-hidden="true" width="1em" height="1em" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="m15.867 8.8477c-3.7501 0-6.8672 3.1171-6.8672 6.8672v32.531c0 3.7501 3.1171 6.8672 6.8672 6.8672h32.523c3.7501 0 6.8672-3.1171 6.8672-6.8672v-11.336c-5.4e-5 -1.743-1.4132-3.1562-3.1562-3.1562-1.743 5.5e-5 -3.1562 1.4132-3.1562 3.1562v11.336c0 0.36231-0.19238 0.55469-0.55469 0.55469h-32.523c-0.36231 0-0.55469-0.19238-0.55469-0.55469v-32.531c0-0.36231 0.19238-0.55469 0.55469-0.55469h11.326c1.743-5.5e-5 3.1562-1.4132 3.1562-3.1562-5.5e-5 -1.743-1.4132-3.1562-3.1562-3.1562h-11.326zm33.049 0.0078126-6.3438 6.3438a6.343 6.343 0 0 0 6.3438 6.3418l6.3418-6.3418a6.343 6.343 0 0 0-6.3418-6.3438zm-8.3438 8.3438-13.656 13.656-1.9434 8.4863 8.2852-2.1426 13.658-13.658c-3.503 4.31e-4 -6.3431-2.8388-6.3438-6.3418z"/>
</svg>
"#);
pub fn embed() -> String {
formatdoc!(r#"
<svg aria-hidden="true" width="1em" height="1em" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
+24
View File
@@ -30,6 +30,8 @@ pub struct Translations {
pub close: &'static str,
pub codec: &'static str,
pub collection: &'static str,
pub collection_manifest: &'static str,
pub collection_settings: &'static str,
pub configure: &'static str,
pub configure_deployment: &'static str,
pub convert_to_collection: &'static str,
@@ -151,6 +153,8 @@ pub struct Translations {
pub plain_rss_description: &'static str,
pub play: &'static str,
pub playlist: &'static str,
pub playlist_manifest: &'static str,
pub playlist_settings: &'static str,
pub playlists: &'static str,
pub podcast_rss_description: &'static str,
pub poster_aspect_label: &'static str,
@@ -176,6 +180,7 @@ pub struct Translations {
pub resolution: &'static str,
pub resource_not_found: &'static str,
pub sec: &'static str,
pub site_manifest: &'static str,
pub site_order: &'static str,
pub site_settings: &'static str,
pub site_setup: &'static str,
@@ -201,6 +206,7 @@ pub struct Translations {
unsupported_file_extension_in_xxx: &'static str,
pub update_collection: &'static str,
pub update_description: &'static str,
pub update_manifest: &'static str,
pub update_permalink: &'static str,
pub update_playlist: &'static str,
pub update_site: &'static str,
@@ -212,9 +218,11 @@ pub struct Translations {
pub url: &'static str,
pub version: &'static str,
pub video: &'static str,
pub video_manifest: &'static str,
pub video_not_yet_available: &'static str,
pub video_order: &'static str,
pub video_player_widget_for_xxx: &'static str,
pub video_settings: &'static str,
pub videos: &'static str,
pub vp9_missing_browser_support_not_recommended: &'static str,
pub warm: &'static str,
@@ -252,6 +260,8 @@ impl Translations {
close: "Schließen",
codec: "Codec",
collection: "Collection",
collection_manifest: "Collection Manifest",
collection_settings: "Collection Einstellungen",
configure: "Konfigurieren",
configure_deployment: "Deployment konfigurieren",
convert_to_collection: "Zu Collection umwandeln",
@@ -373,6 +383,8 @@ impl Translations {
plain_rss_description: "Ein simpler bild- und textbasierter RSS-Feed der nur zu den Video und Audio-Inhalten auf der Website linkt.",
play: "Abspielen",
playlist: "Playlist",
playlist_manifest: "Playlist Manifest",
playlist_settings: "Playlist Einstellungen",
playlists: "Playlisten",
podcast_rss_description: "Ein Podcast-Client-kompatibler RSS-Feed der Video und Audio-Inhalte in einem einzigen Format direkt über den Feed verbreitet.",
poster_aspect_label: "Poster Seitenverhältnis (z.b. 16/9, 4:3, 1.778)",
@@ -398,6 +410,7 @@ impl Translations {
resolution: "Auflösung",
resource_not_found: "Resource nicht gefunden",
sec: "sek",
site_manifest: "Seitenmanifest",
site_order: "Seitenreihenfolge",
site_settings: "Seiteneinstellungen",
site_setup: "Seite einrichten",
@@ -423,6 +436,7 @@ impl Translations {
unsupported_file_extension_in_xxx: "Nicht unterstützte Dateiendung in {file_name}",
update_collection: "Collection speichern",
update_description: "Beschreibung speichern",
update_manifest: "Manifest speichern",
update_permalink: "Permalink speichern",
update_playlist: "Playlist speichern",
update_site: "Seite speichern",
@@ -434,9 +448,11 @@ impl Translations {
url: "URL",
version: "Version",
video: "Video",
video_manifest: "Video Manifest",
video_not_yet_available: "Video noch nicht verfügbar.",
video_order: "Video Reihenfolge",
video_player_widget_for_xxx: r#"Videoplayer-Widget für "{title}""#,
video_settings: "Video Einstellungen",
videos: "Videos",
vp9_missing_browser_support_not_recommended: "VP9 Fehlende Browserunterstützung (Nicht empfohlen)",
warm: "Warm",
@@ -474,6 +490,8 @@ impl Translations {
close: "Close",
codec: "Codec",
collection: "Collection",
collection_manifest: "Collection manifest",
collection_settings: "Collection settings",
configure: "Configure",
configure_deployment: "Configure Deployment",
convert_to_collection: "Convert to collection",
@@ -595,6 +613,8 @@ impl Translations {
plain_rss_description: "A simple, image and text based RSS feed that only links to video and audio content on the website.",
play: "Play",
playlist: "Playlist",
playlist_manifest: "Playlist manifest",
playlist_settings: "Playlist settings",
playlists: "Playlists",
podcast_rss_description: "A podcast client compatible RSS feed that distributes video and audio content in a single format only directly over the feed.",
poster_aspect_label: "Poster Aspect Ratio (e.g. 16/9, 4:3, 1.778)",
@@ -620,6 +640,7 @@ impl Translations {
resolution: "Resolution",
resource_not_found: "Resource not found",
sec: "sec",
site_manifest: "Site manifest",
site_order: "Site Order",
site_settings: "Site settings",
site_setup: "Site Setup",
@@ -645,6 +666,7 @@ impl Translations {
unsupported_file_extension_in_xxx: "Unsupported file extension in {file_name}",
update_collection: "Update Collection",
update_description: "Update Description",
update_manifest: "Update Manifest",
update_permalink: "Update Permalink",
update_playlist: "Update Playlist",
update_site: "Update Site",
@@ -656,9 +678,11 @@ impl Translations {
url: "URL",
version: "Version",
video: "Video",
video_manifest: "Video manifest",
video_not_yet_available: "Video not yet available.",
video_order: "Video Order",
video_player_widget_for_xxx: r#"Video player widget for "{title}""#,
video_settings: "Video settings",
videos: "Videos",
vp9_missing_browser_support_not_recommended: "VP9 Missing browser support (Not recommended)",
warm: "Warm",