mirror of
https://codeberg.org/simonrepp/hyper8.git
synced 2026-08-14 13:45:27 +02:00
Introduce manifest edit pages in the editor
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
"#)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user