Added Cloudflare R2 media storage support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m45s
Build and Publish / Build and Publish Docker Image (push) Successful in 1m45s
This commit is contained in:
+94
-20
@@ -346,10 +346,31 @@ struct SettingsTemplate<'a> {
|
||||
auth_sso_checked: bool,
|
||||
client_notifications_checked: bool,
|
||||
r2_enabled_checked: bool,
|
||||
telegram_bot_token_configured: bool,
|
||||
turnstile_secret_configured: bool,
|
||||
oidc_client_secret_configured: bool,
|
||||
vapid_private_key_configured: bool,
|
||||
r2_secret_configured: bool,
|
||||
push_subscribers: Vec<PushSubscriberItem>,
|
||||
}
|
||||
|
||||
fn setting_has_value(settings: &[Setting], key: &str) -> bool {
|
||||
settings
|
||||
.iter()
|
||||
.find(|setting| setting.key == key)
|
||||
.is_some_and(|setting| !setting.value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn preserved_secret_value(old_value: &str, submitted: &str, clear: bool) -> String {
|
||||
if !submitted.trim().is_empty() {
|
||||
submitted.trim().to_string()
|
||||
} else if clear {
|
||||
String::new()
|
||||
} else {
|
||||
old_value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PushSubscriberItem {
|
||||
client_name: String,
|
||||
@@ -1312,11 +1333,12 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
|
||||
.find(|setting| setting.key == crate::uploads::R2_ENABLED_KEY)
|
||||
.map(|setting| setting.value == "true")
|
||||
.unwrap_or(false);
|
||||
let r2_secret_configured = settings
|
||||
.iter()
|
||||
.find(|setting| setting.key == crate::uploads::R2_SECRET_ACCESS_KEY_KEY)
|
||||
.map(|setting| !setting.value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
let telegram_bot_token_configured = setting_has_value(&settings, "telegram_bot_token");
|
||||
let turnstile_secret_configured = setting_has_value(&settings, "turnstile_secret_key");
|
||||
let oidc_client_secret_configured = setting_has_value(&settings, "oidc_client_secret");
|
||||
let vapid_private_key_configured = setting_has_value(&settings, "vapid_private_key");
|
||||
let r2_secret_configured =
|
||||
setting_has_value(&settings, crate::uploads::R2_SECRET_ACCESS_KEY_KEY);
|
||||
let body = SettingsTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
@@ -1328,6 +1350,10 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
|
||||
auth_sso_checked,
|
||||
client_notifications_checked,
|
||||
r2_enabled_checked,
|
||||
telegram_bot_token_configured,
|
||||
turnstile_secret_configured,
|
||||
oidc_client_secret_configured,
|
||||
vapid_private_key_configured,
|
||||
r2_secret_configured,
|
||||
push_subscribers: load_push_subscribers(&db).await?,
|
||||
}
|
||||
@@ -1417,6 +1443,16 @@ struct SettingsForm {
|
||||
r2_access_key_id: String,
|
||||
r2_secret_access_key: String,
|
||||
#[serde(default)]
|
||||
clear_telegram_bot_token: Option<String>,
|
||||
#[serde(default)]
|
||||
clear_turnstile_secret_key: Option<String>,
|
||||
#[serde(default)]
|
||||
clear_oidc_client_secret: Option<String>,
|
||||
#[serde(default)]
|
||||
clear_vapid_private_key: Option<String>,
|
||||
#[serde(default)]
|
||||
clear_r2_secret_access_key: Option<String>,
|
||||
#[serde(default)]
|
||||
auth_password_enabled: Option<String>,
|
||||
#[serde(default)]
|
||||
auth_sso_enabled: Option<String>,
|
||||
@@ -1441,16 +1477,36 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
.map(|setting| setting.value.as_str())
|
||||
.unwrap_or("")
|
||||
};
|
||||
let telegram_bot_token = preserved_secret_value(
|
||||
old_value("telegram_bot_token"),
|
||||
&form.telegram_bot_token,
|
||||
form.clear_telegram_bot_token.is_some(),
|
||||
);
|
||||
let turnstile_secret_key = preserved_secret_value(
|
||||
old_value("turnstile_secret_key"),
|
||||
&form.turnstile_secret_key,
|
||||
form.clear_turnstile_secret_key.is_some(),
|
||||
);
|
||||
let oidc_client_secret = preserved_secret_value(
|
||||
old_value("oidc_client_secret"),
|
||||
&form.oidc_client_secret,
|
||||
form.clear_oidc_client_secret.is_some(),
|
||||
);
|
||||
let vapid_private_key = preserved_secret_value(
|
||||
old_value("vapid_private_key"),
|
||||
&form.vapid_private_key,
|
||||
form.clear_vapid_private_key.is_some(),
|
||||
);
|
||||
let r2_secret_access_key = preserved_secret_value(
|
||||
old_value(crate::uploads::R2_SECRET_ACCESS_KEY_KEY),
|
||||
&form.r2_secret_access_key,
|
||||
form.clear_r2_secret_access_key.is_some(),
|
||||
);
|
||||
let had_vapid_keys = !old_value("vapid_public_key").trim().is_empty()
|
||||
|| !old_value("vapid_private_key").trim().is_empty();
|
||||
let vapid_keys_changed = had_vapid_keys
|
||||
&& (old_value("vapid_public_key").trim() != form.vapid_public_key.trim()
|
||||
|| old_value("vapid_private_key").trim() != form.vapid_private_key.trim());
|
||||
let r2_secret_access_key = if form.r2_secret_access_key.trim().is_empty() {
|
||||
old_value(crate::uploads::R2_SECRET_ACCESS_KEY_KEY).to_string()
|
||||
} else {
|
||||
form.r2_secret_access_key.trim().to_string()
|
||||
};
|
||||
|| old_value("vapid_private_key").trim() != vapid_private_key.trim());
|
||||
let requested_r2_enabled = form.r2_enabled.is_some();
|
||||
let r2_config_valid = crate::uploads::R2Config::fields_are_valid(
|
||||
&form.r2_account_id,
|
||||
@@ -1463,20 +1519,20 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
(requested_r2_enabled && !r2_config_valid).then_some(lang.t().settings_r2_error_incomplete);
|
||||
|
||||
for (key, value) in [
|
||||
("telegram_bot_token", form.telegram_bot_token),
|
||||
("telegram_bot_token", telegram_bot_token),
|
||||
("contact_info", form.contact_info),
|
||||
("pricing_info", form.pricing_info),
|
||||
("timezone", form.timezone),
|
||||
("site_domain", form.site_domain),
|
||||
("seo_keywords", form.seo_keywords),
|
||||
("turnstile_site_key", form.turnstile_site_key),
|
||||
("turnstile_secret_key", form.turnstile_secret_key),
|
||||
("turnstile_secret_key", turnstile_secret_key),
|
||||
("oidc_issuer_url", form.oidc_issuer_url),
|
||||
("oidc_client_id", form.oidc_client_id),
|
||||
("oidc_client_secret", form.oidc_client_secret),
|
||||
("oidc_client_secret", oidc_client_secret),
|
||||
("oidc_allowed_groups", form.oidc_allowed_groups),
|
||||
("vapid_public_key", form.vapid_public_key),
|
||||
("vapid_private_key", form.vapid_private_key),
|
||||
("vapid_private_key", vapid_private_key),
|
||||
("vapid_subject", form.vapid_subject),
|
||||
(
|
||||
crate::uploads::R2_ACCOUNT_ID_KEY,
|
||||
@@ -1574,11 +1630,12 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
.find(|setting| setting.key == crate::uploads::R2_ENABLED_KEY)
|
||||
.map(|setting| setting.value == "true")
|
||||
.unwrap_or(false);
|
||||
let r2_secret_configured = settings
|
||||
.iter()
|
||||
.find(|setting| setting.key == crate::uploads::R2_SECRET_ACCESS_KEY_KEY)
|
||||
.map(|setting| !setting.value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
let telegram_bot_token_configured = setting_has_value(&settings, "telegram_bot_token");
|
||||
let turnstile_secret_configured = setting_has_value(&settings, "turnstile_secret_key");
|
||||
let oidc_client_secret_configured = setting_has_value(&settings, "oidc_client_secret");
|
||||
let vapid_private_key_configured = setting_has_value(&settings, "vapid_private_key");
|
||||
let r2_secret_configured =
|
||||
setting_has_value(&settings, crate::uploads::R2_SECRET_ACCESS_KEY_KEY);
|
||||
let rendered = SettingsTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
@@ -1590,6 +1647,10 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
auth_sso_checked,
|
||||
client_notifications_checked,
|
||||
r2_enabled_checked,
|
||||
telegram_bot_token_configured,
|
||||
turnstile_secret_configured,
|
||||
oidc_client_secret_configured,
|
||||
vapid_private_key_configured,
|
||||
r2_secret_configured,
|
||||
push_subscribers: load_push_subscribers(&db).await?,
|
||||
}
|
||||
@@ -3192,3 +3253,16 @@ pub fn admin_router() -> Router {
|
||||
Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"),
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::preserved_secret_value;
|
||||
|
||||
#[test]
|
||||
fn keeps_masks_and_updates_secrets_consistently() {
|
||||
assert_eq!(preserved_secret_value("stored", "", false), "stored");
|
||||
assert_eq!(preserved_secret_value("stored", " new ", false), "new");
|
||||
assert_eq!(preserved_secret_value("stored", "", true), "");
|
||||
assert_eq!(preserved_secret_value("stored", "new", true), "new");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +45,33 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let source_exists = local_file_exists(&source_path).await?;
|
||||
let target_exists = local_file_exists(&target_path).await?;
|
||||
let target_thumbnail_exists = local_file_exists(&target_thumbnail_path).await?;
|
||||
let mut temporary_source_dir = None;
|
||||
|
||||
if !source_exists && !target_exists {
|
||||
let conversion_source = if target_exists {
|
||||
target_path.clone()
|
||||
} else if source_exists {
|
||||
source_path.clone()
|
||||
} else if r2.exists(&original_db_path).await? {
|
||||
let directory = std::env::temp_dir()
|
||||
.join(format!("web-petting-normalize-{}", uuid::Uuid::new_v4()));
|
||||
tokio::fs::create_dir(&directory).await?;
|
||||
let extension = original_db_path.rsplit('.').next().unwrap_or("mov");
|
||||
let temporary_source = directory.join(format!("source.{extension}"));
|
||||
println!("Downloading missing PVC source from R2 for media {media_id}");
|
||||
if let Err(error) = r2
|
||||
.download_to_path(&original_db_path, &temporary_source)
|
||||
.await
|
||||
{
|
||||
eprintln!("Could not download media {media_id} from R2: {error}");
|
||||
let _ = tokio::fs::remove_dir_all(&directory).await;
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
temporary_source_dir = Some(directory);
|
||||
temporary_source
|
||||
} else {
|
||||
eprintln!(
|
||||
"Missing PVC source for media {media_id}: {}",
|
||||
"Missing source for media {media_id}: {}",
|
||||
source_path.display()
|
||||
);
|
||||
if media.status == "active" {
|
||||
@@ -57,34 +80,34 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
missing_archived += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if original_db_path == target_db_path || target_exists {
|
||||
if !target_thumbnail_exists
|
||||
&& let Err(error) =
|
||||
uploads::ensure_video_thumbnail(&uploads::Storage::Local, &target_db_path)
|
||||
.await
|
||||
{
|
||||
eprintln!("Could not create preview for media {media_id}: {error}");
|
||||
failed += 1;
|
||||
continue;
|
||||
if let Some(parent) = target_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let preparation = if original_db_path == target_db_path || target_exists {
|
||||
if target_thumbnail_exists {
|
||||
Ok(())
|
||||
} else {
|
||||
uploads::create_video_thumbnail_file(&conversion_source, &target_thumbnail_path)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
if let Some(parent) = target_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
println!("Converting media {media_id}: {original_db_path}");
|
||||
if let Err(error) = uploads::create_compact_video_files(
|
||||
&source_path,
|
||||
uploads::create_compact_video_files(
|
||||
&conversion_source,
|
||||
&target_path,
|
||||
&target_thumbnail_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("Could not convert media {media_id}: {error}");
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(directory) = temporary_source_dir {
|
||||
let _ = tokio::fs::remove_dir_all(directory).await;
|
||||
}
|
||||
if let Err(error) = preparation {
|
||||
eprintln!("Could not prepare media {media_id}: {error}");
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !target_in_r2 {
|
||||
|
||||
+30
@@ -131,6 +131,16 @@ pub struct Translations {
|
||||
pub settings_save: &'static str,
|
||||
pub settings_saved: &'static str,
|
||||
pub settings_empty: &'static str,
|
||||
pub settings_intro: &'static str,
|
||||
pub settings_section_general_help: &'static str,
|
||||
pub settings_section_notifications_help: &'static str,
|
||||
pub settings_section_captcha_help: &'static str,
|
||||
pub settings_section_oidc_help: &'static str,
|
||||
pub settings_secret_saved: &'static str,
|
||||
pub settings_secret_not_set: &'static str,
|
||||
pub settings_secret_clear: &'static str,
|
||||
pub settings_status_enabled: &'static str,
|
||||
pub settings_status_disabled: &'static str,
|
||||
pub settings_telegram_bot_token: &'static str,
|
||||
pub settings_telegram_chat_id: &'static str,
|
||||
pub settings_contact_info: &'static str,
|
||||
@@ -406,6 +416,16 @@ static RU: Translations = Translations {
|
||||
settings_save: "Сохранить",
|
||||
settings_saved: "Сохранено!",
|
||||
settings_empty: "Настройки не заданы.",
|
||||
settings_intro: "Параметры сайта, интеграций, хранения медиа и способов входа.",
|
||||
settings_section_general_help: "Публичные данные сайта и параметры, используемые при формировании ссылок и дат.",
|
||||
settings_section_notifications_help: "Telegram для администратора и браузерные push-уведомления для клиентов.",
|
||||
settings_section_captcha_help: "Ключи Cloudflare Turnstile для защиты публичных форм от автоматических отправок.",
|
||||
settings_section_oidc_help: "Настройте доступ администраторов по паролю и через внешний OIDC-провайдер.",
|
||||
settings_secret_saved: "Секрет сохранён. Оставьте поле пустым, чтобы не менять его.",
|
||||
settings_secret_not_set: "Секрет пока не задан.",
|
||||
settings_secret_clear: "Удалить сохранённый секрет",
|
||||
settings_status_enabled: "Включено",
|
||||
settings_status_disabled: "Выключено",
|
||||
settings_telegram_bot_token: "Токен Telegram бота",
|
||||
settings_telegram_chat_id: "Chat ID для уведомлений",
|
||||
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
||||
@@ -671,6 +691,16 @@ static EN: Translations = Translations {
|
||||
settings_save: "Save",
|
||||
settings_saved: "Saved!",
|
||||
settings_empty: "No settings configured.",
|
||||
settings_intro: "Site, integration, media storage, and sign-in settings.",
|
||||
settings_section_general_help: "Public site details and values used to build links and display dates.",
|
||||
settings_section_notifications_help: "Telegram alerts for administrators and browser push notifications for clients.",
|
||||
settings_section_captcha_help: "Cloudflare Turnstile keys used to protect public forms from automated submissions.",
|
||||
settings_section_oidc_help: "Configure administrator access with passwords and an external OIDC provider.",
|
||||
settings_secret_saved: "A secret is stored. Leave this field blank to keep it unchanged.",
|
||||
settings_secret_not_set: "No secret is currently stored.",
|
||||
settings_secret_clear: "Remove the stored secret",
|
||||
settings_status_enabled: "Enabled",
|
||||
settings_status_disabled: "Disabled",
|
||||
settings_telegram_bot_token: "Telegram Bot Token",
|
||||
settings_telegram_chat_id: "Notification Chat ID",
|
||||
settings_contact_info: "Contact info (shown on landing page)",
|
||||
|
||||
+49
-11
@@ -175,7 +175,7 @@ impl R2Storage {
|
||||
Ok(bytes.into_bytes().to_vec())
|
||||
}
|
||||
|
||||
async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
|
||||
pub async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
|
||||
let response = self
|
||||
.client
|
||||
.get_object()
|
||||
@@ -347,7 +347,7 @@ impl Storage {
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
|
||||
pub async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
|
||||
match self {
|
||||
Self::Local => {
|
||||
tokio::fs::copy(resolve_db_path(db_path), destination)
|
||||
@@ -574,34 +574,68 @@ async fn run_media_command(command: &mut Command, context: &str) -> StorageResul
|
||||
))
|
||||
}
|
||||
|
||||
async fn transcode_video_for_browser(source: &Path, destination: &Path) -> StorageResult<()> {
|
||||
fn video_transcode_command(source: &Path, destination: &Path, include_audio: bool) -> Command {
|
||||
let mut command = Command::new("ffmpeg");
|
||||
command
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.args([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-ignore_unknown",
|
||||
"-i",
|
||||
])
|
||||
.arg(source)
|
||||
.args(["-map", "0:v:0", "-map", "0:a?"])
|
||||
.args(["-map", "0:v:0"]);
|
||||
if include_audio {
|
||||
command.args(["-map", "0:a:0?"]);
|
||||
} else {
|
||||
command.arg("-an");
|
||||
}
|
||||
command
|
||||
.args(["-map_metadata", "-1", "-map_chapters", "-1", "-sn", "-dn"])
|
||||
.args([
|
||||
"-vf",
|
||||
"scale=w='min(1920,iw)':h='min(1920,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
|
||||
"scale=w='min(1280,iw)':h='min(1280,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"26",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-tag:v",
|
||||
"avc1",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"96k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-threads",
|
||||
"2",
|
||||
])
|
||||
.arg(destination);
|
||||
run_media_command(&mut command, "failed to transcode video with ffmpeg").await
|
||||
command
|
||||
}
|
||||
|
||||
async fn transcode_video_for_browser(source: &Path, destination: &Path) -> StorageResult<()> {
|
||||
let mut with_audio = video_transcode_command(source, destination, true);
|
||||
match run_media_command(&mut with_audio, "failed to transcode video with ffmpeg").await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(audio_error) => {
|
||||
// Some iPhone MOV files contain a second, audio-like service stream
|
||||
// with no decoder. Preserve audio when possible, but keep the video
|
||||
// usable by retrying without that stream.
|
||||
let mut video_only = video_transcode_command(source, destination, false);
|
||||
run_media_command(
|
||||
&mut video_only,
|
||||
&format!("{audio_error}; video-only fallback also failed"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn video_duration(source: &Path) -> StorageResult<f64> {
|
||||
@@ -665,6 +699,10 @@ async fn create_video_sprite(source: &Path, destination: &Path) -> StorageResult
|
||||
run_media_command(&mut command, "failed to create video preview with ffmpeg").await
|
||||
}
|
||||
|
||||
pub async fn create_video_thumbnail_file(source: &Path, destination: &Path) -> StorageResult<()> {
|
||||
create_video_sprite(source, destination).await
|
||||
}
|
||||
|
||||
/// Create the compact browser MP4 and its four-frame JPEG sprite on disk.
|
||||
/// The source path is only read and is never changed or removed.
|
||||
pub async fn create_compact_video_files(
|
||||
@@ -723,7 +761,7 @@ pub async fn ensure_video_thumbnail(storage: &Storage, db_path: &str) -> Storage
|
||||
let thumbnail = workspace.join("preview.jpg");
|
||||
let result = async {
|
||||
storage.download_to_path(db_path, &source).await?;
|
||||
create_video_sprite(&source, &thumbnail).await?;
|
||||
create_video_thumbnail_file(&source, &thumbnail).await?;
|
||||
let data = tokio::fs::read(&thumbnail)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to read video preview", error))?;
|
||||
|
||||
+368
-226
@@ -4,267 +4,409 @@
|
||||
{% block title %}{{ t.settings_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ t.settings_title }}</h1>
|
||||
<div class="page-head settings-page-head">
|
||||
<div>
|
||||
<h1>{{ t.settings_title }}</h1>
|
||||
<p>{{ t.settings_intro }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
<div class="notification is-success is-light">{{ t.settings_saved }}</div>
|
||||
<div class="notification is-success is-light settings-message">{{ t.settings_saved }}</div>
|
||||
{% endif %}
|
||||
{% if let Some(message) = error %}
|
||||
<div class="notification is-danger is-light">{{ message }}</div>
|
||||
<div class="notification is-danger is-light settings-message">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-card">
|
||||
<form method="post" action="/admin/settings/save">
|
||||
|
||||
<h2 class="subtitle is-5 mb-3" style="border-bottom:1px solid #eee;padding-bottom:0.5rem;">{{ t.settings_contact_info }}</h2>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_contact_info }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="contact_info" placeholder="+7 999 123-45-67 / info@example.com" value="{% for s in &settings %}{% if s.key == "contact_info" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
<form id="settingsForm" class="settings-form" method="post" action="/admin/settings/save">
|
||||
<section class="settings-section">
|
||||
<header class="settings-section-head">
|
||||
<span class="settings-section-icon" aria-hidden="true">🌐</span>
|
||||
<div>
|
||||
<h2>{{ t.settings_section_general }}</h2>
|
||||
<p>{{ t.settings_section_general_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings-section-body">
|
||||
<div class="settings-grid">
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<label class="label" for="contactInfo">{{ t.settings_contact_info }}</label>
|
||||
<div class="control">
|
||||
<input id="contactInfo" class="input" type="text" name="contact_info" placeholder="+7 999 123-45-67 / info@example.com" value="{% for s in &settings %}{% if s.key == "contact_info" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<label class="label" for="siteDomain">{{ t.settings_site_domain }}</label>
|
||||
<div class="control">
|
||||
<input id="siteDomain" class="input" type="url" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<label class="label" for="timezone">{{ t.settings_timezone }}</label>
|
||||
<div class="control">
|
||||
<input id="timezone" class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<label class="label" for="pricingInfo">{{ t.settings_pricing_info }}</label>
|
||||
<div class="control">
|
||||
<textarea id="pricingInfo" class="textarea" name="pricing_info" rows="3" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<label class="label" for="seoKeywordsInput">{{ t.settings_seo_keywords }}</label>
|
||||
<div class="control">
|
||||
<textarea id="seoKeywordsInput" class="textarea" name="seo_keywords" rows="3" placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</div>
|
||||
<div id="seoPreview" class="seo-preview" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_pricing_info }}</label>
|
||||
<div class="control">
|
||||
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header class="settings-section-head">
|
||||
<span class="settings-section-icon" aria-hidden="true">☁️</span>
|
||||
<div>
|
||||
<div class="settings-title-row">
|
||||
<h2>{{ t.settings_section_storage }}</h2>
|
||||
<span class="settings-status{% if r2_enabled_checked %} is-enabled{% endif %}">
|
||||
{% if r2_enabled_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ t.settings_r2_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings-section-body">
|
||||
<label class="settings-toggle" for="r2Enabled">
|
||||
<input id="r2Enabled" type="checkbox" name="r2_enabled" value="true"{% if r2_enabled_checked %} checked{% endif %}>
|
||||
<span class="toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span>
|
||||
<strong>{{ t.settings_r2_enabled }}</strong>
|
||||
<small>{{ t.settings_r2_help }}</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="settings-note settings-note-info">{{ t.settings_r2_migration_help }}</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="field settings-field">
|
||||
<label class="label" for="r2AccountId">{{ t.settings_r2_account_id }}</label>
|
||||
<div class="control">
|
||||
<input id="r2AccountId" class="input is-family-monospace" type="text" name="r2_account_id" maxlength="32" autocomplete="off" spellcheck="false" placeholder="0123456789abcdef0123456789abcdef" value="{% for s in &settings %}{% if s.key == "r2_account_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<label class="label" for="r2Bucket">{{ t.settings_r2_bucket }}</label>
|
||||
<div class="control">
|
||||
<input id="r2Bucket" class="input is-family-monospace" type="text" name="r2_bucket" autocomplete="off" spellcheck="false" placeholder="pet-media" value="{% for s in &settings %}{% if s.key == "r2_bucket" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<label class="label" for="r2AccessKey">{{ t.settings_r2_access_key_id }}</label>
|
||||
<div class="control">
|
||||
<input id="r2AccessKey" class="input is-family-monospace" type="text" name="r2_access_key_id" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "r2_access_key_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<label class="label" for="r2Secret">{{ t.settings_r2_secret_access_key }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="r2Secret" class="input is-family-monospace" type="password" name="r2_secret_access_key" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="settings-help">{% if r2_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if r2_secret_configured %}
|
||||
<label class="secret-clear"><input type="checkbox" name="clear_r2_secret_access_key" value="true"> {{ t.settings_secret_clear }}</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_seo_keywords }}</label>
|
||||
<div class="control">
|
||||
<textarea id="seoKeywordsInput" class="textarea" name="seo_keywords" rows="3"
|
||||
style="resize:vertical;"
|
||||
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header class="settings-section-head">
|
||||
<span class="settings-section-icon" aria-hidden="true">🔔</span>
|
||||
<div>
|
||||
<div class="settings-title-row">
|
||||
<h2>{{ t.settings_section_notifications }}</h2>
|
||||
<span class="settings-status{% if client_notifications_checked %} is-enabled{% endif %}">
|
||||
{% if client_notifications_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ t.settings_section_notifications_help }}</p>
|
||||
</div>
|
||||
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings-section-body">
|
||||
<label class="settings-toggle" for="clientNotifications">
|
||||
<input id="clientNotifications" type="checkbox" name="client_notifications_enabled" value="true"{% if client_notifications_checked %} checked{% endif %}>
|
||||
<span class="toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span>
|
||||
<strong>{{ t.settings_client_notifications_enabled }}</strong>
|
||||
<small>{{ t.settings_client_notifications_help }}</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<details style="margin-top:1.5rem;">
|
||||
<summary class="subtitle is-5 mb-3" style="cursor:pointer;border-bottom:1px solid #eee;padding-bottom:0.5rem;">
|
||||
{{ t.settings_section_advanced }}
|
||||
</summary>
|
||||
|
||||
<div style="margin-top:1rem;">
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey">{{ t.settings_section_general }}</h3>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_site_domain }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_timezone }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_storage }}</h3>
|
||||
<div class="notification is-info is-light" style="padding:0.8rem 1rem;font-size:0.85rem;margin-bottom:0.85rem;">
|
||||
<p>{{ t.settings_r2_help }}</p>
|
||||
<p style="margin-top:0.45rem;">{{ t.settings_r2_migration_help }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="r2_enabled" value="true"{% if r2_enabled_checked %} checked{% endif %}>
|
||||
{{ t.settings_r2_enabled }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_r2_account_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="r2_account_id" maxlength="32" autocomplete="off" placeholder="0123456789abcdef0123456789abcdef" value="{% for s in &settings %}{% if s.key == "r2_account_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_r2_bucket }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="r2_bucket" autocomplete="off" placeholder="pet-media" value="{% for s in &settings %}{% if s.key == "r2_bucket" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_r2_access_key_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="r2_access_key_id" autocomplete="off" value="{% for s in &settings %}{% if s.key == "r2_access_key_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_r2_secret_access_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="r2_secret_access_key" autocomplete="new-password"{% if r2_secret_configured %} placeholder="{{ t.settings_r2_secret_unchanged }}"{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="client_notifications_enabled" value="true"{% if client_notifications_checked %} checked{% endif %}>
|
||||
{{ t.settings_client_notifications_enabled }}
|
||||
</label>
|
||||
<p class="help">{{ t.settings_client_notifications_help }}</p>
|
||||
</div>
|
||||
<blockquote class="notification is-warning is-light" style="padding:0.8rem 1rem;font-size:0.85rem;margin:0.75rem 0;">
|
||||
<div class="settings-subsection">
|
||||
<h3>Web Push / VAPID</h3>
|
||||
<div class="settings-note settings-note-warning">
|
||||
<p>{{ t.settings_vapid_warning }}</p>
|
||||
<p style="margin-top:0.45rem;">{{ t.settings_vapid_generate }}</p>
|
||||
<code style="display:inline-block;margin-top:0.2rem;user-select:all;">cargo run --bin generate_vapid</code>
|
||||
</blockquote>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_vapid_public_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="vapid_public_key" autocomplete="off" value="{% for s in &settings %}{% if s.key == "vapid_public_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
<p>{{ t.settings_vapid_generate }} <code>cargo run --bin generate_vapid</code></p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<label class="label" for="vapidPublic">{{ t.settings_vapid_public_key }}</label>
|
||||
<div class="control">
|
||||
<input id="vapidPublic" class="input is-family-monospace" type="text" name="vapid_public_key" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "vapid_public_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<label class="label" for="vapidPrivate">{{ t.settings_vapid_private_key }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="vapidPrivate" class="input is-family-monospace" type="password" name="vapid_private_key" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="settings-help">{% if vapid_private_key_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if vapid_private_key_configured %}
|
||||
<label class="secret-clear"><input type="checkbox" name="clear_vapid_private_key" value="true"> {{ t.settings_secret_clear }}</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<label class="label" for="vapidSubject">{{ t.settings_vapid_subject }}</label>
|
||||
<div class="control">
|
||||
<input id="vapidSubject" class="input" type="text" name="vapid_subject" placeholder="mailto:admin@example.com" value="{% for s in &settings %}{% if s.key == "vapid_subject" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_vapid_private_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="vapid_private_key" autocomplete="new-password" value="{% for s in &settings %}{% if s.key == "vapid_private_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_vapid_subject }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="vapid_subject" placeholder="mailto:admin@example.com" value="{% for s in &settings %}{% if s.key == "vapid_subject" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<details style="margin:0.9rem 0 1.25rem;border:1px solid #e8e5f5;border-radius:8px;background:#faf9ff;">
|
||||
<summary style="cursor:pointer;padding:0.7rem 0.85rem;font-weight:600;font-size:0.9rem;">
|
||||
{{ t.settings_push_subscribers }} ({{ push_subscribers.len() }})
|
||||
</summary>
|
||||
<div style="padding:0 0.85rem 0.85rem;overflow-x:auto;">
|
||||
|
||||
<details class="settings-details">
|
||||
<summary>{{ t.settings_push_subscribers }} <span>{{ push_subscribers.len() }}</span></summary>
|
||||
<div class="settings-details-body">
|
||||
{% if push_subscribers.is_empty() %}
|
||||
<p class="help">{{ t.settings_push_no_subscribers }}</p>
|
||||
<p class="settings-help">{{ t.settings_push_no_subscribers }}</p>
|
||||
{% else %}
|
||||
<table class="table is-fullwidth is-striped is-narrow" style="font-size:0.8rem;background:transparent;">
|
||||
<thead><tr>
|
||||
<th>{{ t.settings_push_client }}</th>
|
||||
<th>{{ t.settings_push_devices }}</th>
|
||||
<th>{{ t.settings_push_language }}</th>
|
||||
<th>{{ t.settings_push_updated }}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for subscriber in &push_subscribers %}
|
||||
<tr>
|
||||
<td>{{ subscriber.client_name }}</td>
|
||||
<td>{{ subscriber.device_count }}</td>
|
||||
<td>{{ subscriber.languages }}</td>
|
||||
<td style="white-space:nowrap;">{{ subscriber.last_updated }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="settings-table-wrap">
|
||||
<table class="table is-fullwidth is-striped is-narrow">
|
||||
<thead><tr>
|
||||
<th>{{ t.settings_push_client }}</th>
|
||||
<th>{{ t.settings_push_devices }}</th>
|
||||
<th>{{ t.settings_push_language }}</th>
|
||||
<th>{{ t.settings_push_updated }}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for subscriber in &push_subscribers %}
|
||||
<tr>
|
||||
<td>{{ subscriber.client_name }}</td>
|
||||
<td>{{ subscriber.device_count }}</td>
|
||||
<td>{{ subscriber.languages }}</td>
|
||||
<td class="is-nowrap">{{ subscriber.last_updated }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_telegram_bot_token }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_captcha }}</h3>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_turnstile_site_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
<div class="settings-subsection">
|
||||
<h3>Telegram</h3>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<label class="label" for="telegramToken">{{ t.settings_telegram_bot_token }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="telegramToken" class="input is-family-monospace" type="password" name="telegram_bot_token" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_oidc }}</h3>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_issuer_url }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_client_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_client_secret }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_allowed_groups }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
|
||||
{{ t.settings_auth_password_enabled }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
|
||||
{{ t.settings_auth_sso_enabled }}
|
||||
</label>
|
||||
<p class="settings-help">{% if telegram_bot_token_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if telegram_bot_token_configured %}
|
||||
<label class="secret-clear"><input type="checkbox" name="clear_telegram_bot_token" value="true"> {{ t.settings_secret_clear }}</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" class="button is-primary" style="margin-top:1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<section class="settings-section">
|
||||
<header class="settings-section-head">
|
||||
<span class="settings-section-icon" aria-hidden="true">🛡️</span>
|
||||
<div>
|
||||
<h2>{{ t.settings_section_captcha }}</h2>
|
||||
<p>{{ t.settings_section_captcha_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings-section-body">
|
||||
<div class="settings-grid">
|
||||
<div class="field settings-field">
|
||||
<label class="label" for="turnstileSiteKey">{{ t.settings_turnstile_site_key }}</label>
|
||||
<div class="control">
|
||||
<input id="turnstileSiteKey" class="input is-family-monospace" type="text" name="turnstile_site_key" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<label class="label" for="turnstileSecret">{{ t.settings_turnstile_secret_key }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="turnstileSecret" class="input is-family-monospace" type="password" name="turnstile_secret_key" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="settings-help">{% if turnstile_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if turnstile_secret_configured %}
|
||||
<label class="secret-clear"><input type="checkbox" name="clear_turnstile_secret_key" value="true"> {{ t.settings_secret_clear }}</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header class="settings-section-head">
|
||||
<span class="settings-section-icon" aria-hidden="true">🔐</span>
|
||||
<div>
|
||||
<h2>{{ t.settings_section_oidc }}</h2>
|
||||
<p>{{ t.settings_section_oidc_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings-section-body">
|
||||
<div class="settings-toggle-list">
|
||||
<label class="settings-toggle" for="passwordAuth">
|
||||
<input id="passwordAuth" type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
|
||||
<span class="toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span><strong>{{ t.settings_auth_password_enabled }}</strong></span>
|
||||
</label>
|
||||
<label class="settings-toggle" for="ssoAuth">
|
||||
<input id="ssoAuth" type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
|
||||
<span class="toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span><strong>{{ t.settings_auth_sso_enabled }}</strong></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<label class="label" for="oidcIssuer">{{ t.settings_oidc_issuer_url }}</label>
|
||||
<div class="control">
|
||||
<input id="oidcIssuer" class="input" type="url" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<label class="label" for="oidcClientId">{{ t.settings_oidc_client_id }}</label>
|
||||
<div class="control">
|
||||
<input id="oidcClientId" class="input is-family-monospace" type="text" name="oidc_client_id" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<label class="label" for="oidcClientSecret">{{ t.settings_oidc_client_secret }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="oidcClientSecret" class="input is-family-monospace" type="password" name="oidc_client_secret" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="settings-help">{% if oidc_client_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if oidc_client_secret_configured %}
|
||||
<label class="secret-clear"><input type="checkbox" name="clear_oidc_client_secret" value="true"> {{ t.settings_secret_clear }}</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<label class="label" for="oidcGroups">{{ t.settings_oidc_allowed_groups }}</label>
|
||||
<div class="control">
|
||||
<input id="oidcGroups" class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-save-bar">
|
||||
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.settings-page-head { align-items:flex-end; margin-bottom:1.25rem; }
|
||||
.settings-page-head h1 { font-size:1.55rem; }
|
||||
.settings-page-head p { margin-top:.25rem; color:#777; font-size:.9rem; }
|
||||
.settings-message { border-radius:10px; margin-bottom:1rem; }
|
||||
.settings-form { display:flex; flex-direction:column; gap:1rem; }
|
||||
.settings-section { background:#fff; border:1px solid #e7e5ef; border-radius:14px; overflow:hidden; box-shadow:0 2px 10px rgba(42,34,80,.035); }
|
||||
.settings-section-head { display:grid; grid-template-columns:42px minmax(0,1fr); gap:.8rem; padding:1rem 1.15rem; background:linear-gradient(180deg,#fff,#fbfaff); border-bottom:1px solid #eceaf2; }
|
||||
.settings-section-icon { display:flex; align-items:center; justify-content:center; width:42px; height:42px; border-radius:11px; background:#efedff; font-size:1.2rem; }
|
||||
.settings-section-head h2 { margin:0; font-size:1.05rem; font-weight:750; color:#302c49; }
|
||||
.settings-section-head p { margin:.2rem 0 0; max-width:680px; color:#817c94; font-size:.82rem; line-height:1.45; }
|
||||
.settings-title-row { display:flex; align-items:center; flex-wrap:wrap; gap:.55rem; }
|
||||
.settings-status { display:inline-flex; align-items:center; padding:.12rem .48rem; border-radius:99px; background:#eeedf2; color:#777184; font-size:.67rem; font-weight:700; }
|
||||
.settings-status.is-enabled { background:#dcf7e8; color:#177349; }
|
||||
.settings-section-body { padding:1.15rem; }
|
||||
.settings-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem 1.1rem; }
|
||||
.settings-field { min-width:0; margin:0 !important; }
|
||||
.settings-field-wide { grid-column:1 / -1; }
|
||||
.settings-field .label { margin-bottom:.38rem; font-size:.78rem; font-weight:700; color:#514c66 !important; }
|
||||
.settings-field .input, .settings-field .textarea { border-radius:8px; box-shadow:none; font-size:.88rem; }
|
||||
.settings-field .input:focus, .settings-field .textarea:focus { border-color:#7c6cff !important; box-shadow:0 0 0 2px rgba(124,108,255,.12); }
|
||||
.settings-field .textarea { min-height:76px; resize:vertical; }
|
||||
.settings-help { margin-top:.35rem; color:#8b869b; font-size:.72rem; line-height:1.4; }
|
||||
.secret-control { position:relative; }
|
||||
.secret-control::after { content:"🔒"; position:absolute; right:.75rem; top:50%; transform:translateY(-50%); font-size:.78rem; opacity:.48; pointer-events:none; }
|
||||
.secret-control .input { padding-right:2.25rem; letter-spacing:.05em; }
|
||||
.secret-clear { display:inline-flex; align-items:center; gap:.35rem; margin-top:.38rem; color:#a04e58 !important; font-size:.7rem; cursor:pointer; }
|
||||
.secret-clear input { accent-color:#b85c66; }
|
||||
.settings-toggle-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.7rem; margin-bottom:1rem; }
|
||||
.settings-toggle { display:flex; align-items:flex-start; gap:.7rem; padding:.75rem .85rem; border:1px solid #e8e5f2; border-radius:10px; background:#fbfaff; cursor:pointer; }
|
||||
.settings-toggle > input { position:absolute; opacity:0; pointer-events:none; }
|
||||
.toggle-track { flex:0 0 auto; width:38px; height:22px; padding:2px; border-radius:99px; background:#ccc8d6; transition:.18s; }
|
||||
.toggle-track span { display:block; width:18px; height:18px; border-radius:50%; background:#fff; box-shadow:0 1px 4px rgba(0,0,0,.2); transition:.18s; }
|
||||
.settings-toggle > input:checked + .toggle-track { background:#7567e8; }
|
||||
.settings-toggle > input:checked + .toggle-track span { transform:translateX(16px); }
|
||||
.settings-toggle > input:focus-visible + .toggle-track { outline:2px solid #7c6cff; outline-offset:2px; }
|
||||
.settings-toggle strong { display:block; color:#3e3956; font-size:.82rem; line-height:1.35; }
|
||||
.settings-toggle small { display:block; margin-top:.15rem; color:#8a849c; font-size:.7rem; line-height:1.35; }
|
||||
.settings-note { margin:.9rem 0 1rem; padding:.75rem .9rem; border:1px solid; border-radius:9px; font-size:.76rem; line-height:1.5; }
|
||||
.settings-note p + p { margin-top:.35rem; }
|
||||
.settings-note-info { background:#f1f7ff; border-color:#d7e8fa; color:#42627f; }
|
||||
.settings-note-warning { background:#fff8e8; border-color:#f1dfb4; color:#785f28; }
|
||||
.settings-note code { display:inline-block; margin:.2rem 0 0; padding:.12rem .35rem; border-radius:4px; background:rgba(255,255,255,.7); color:inherit; user-select:all; }
|
||||
.settings-subsection { margin-top:1.1rem; padding-top:1.1rem; border-top:1px solid #eeecf3; }
|
||||
.settings-subsection h3 { margin:0 0 .7rem; color:#514b69; font-size:.82rem; font-weight:800; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.settings-details { margin-top:1rem; border:1px solid #e8e5f2; border-radius:9px; background:#fcfbff; overflow:hidden; }
|
||||
.settings-details summary { display:flex; justify-content:space-between; align-items:center; padding:.7rem .85rem; cursor:pointer; color:#514b69; font-size:.78rem; font-weight:700; }
|
||||
.settings-details summary span { min-width:24px; padding:.05rem .4rem; border-radius:99px; background:#ece9ff; color:#6257bb; text-align:center; }
|
||||
.settings-details-body { padding:0 .85rem .85rem; }
|
||||
.settings-table-wrap { overflow-x:auto; }
|
||||
.settings-table-wrap .table { margin:0; background:transparent; font-size:.75rem; }
|
||||
.is-nowrap { white-space:nowrap; }
|
||||
.seo-preview { margin-top:.5rem; padding:.5rem .65rem; border:1px solid #ebe9ef; border-radius:7px; background:#fafafa; min-height:2rem; line-height:2; font-size:.78rem; }
|
||||
.settings-save-bar { position:sticky; bottom:.75rem; z-index:10; display:flex; justify-content:flex-end; padding:.65rem; border:1px solid rgba(222,218,238,.9); border-radius:12px; background:rgba(255,255,255,.92); box-shadow:0 8px 28px rgba(46,38,86,.12); backdrop-filter:blur(8px); }
|
||||
.settings-save-bar .button { min-width:150px; border-radius:8px; font-weight:700; }
|
||||
@media (max-width:700px) {
|
||||
.settings-grid, .settings-toggle-list { grid-template-columns:1fr; }
|
||||
.settings-field-wide { grid-column:auto; }
|
||||
.settings-section-head { grid-template-columns:36px minmax(0,1fr); padding:.9rem; }
|
||||
.settings-section-icon { width:36px; height:36px; }
|
||||
.settings-section-body { padding:.9rem; }
|
||||
.settings-save-bar { bottom:4.15rem; }
|
||||
.settings-save-bar .button { width:100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var COLORS = [
|
||||
'rgba(124,108,255,0.18)',
|
||||
'rgba(255,82,135,0.15)',
|
||||
'rgba(255,140,38,0.18)',
|
||||
'rgba(0,180,150,0.15)',
|
||||
'rgba(77,166,255,0.18)',
|
||||
'rgba(255,179,64,0.18)',
|
||||
'rgba(176,108,255,0.16)',
|
||||
'rgba(34,180,130,0.16)',
|
||||
var colors = [
|
||||
'rgba(124,108,255,.18)', 'rgba(255,82,135,.15)',
|
||||
'rgba(255,140,38,.18)', 'rgba(0,180,150,.15)',
|
||||
'rgba(77,166,255,.18)', 'rgba(255,179,64,.18)',
|
||||
'rgba(176,108,255,.16)', 'rgba(34,180,130,.16)'
|
||||
];
|
||||
|
||||
var ta = document.getElementById('seoKeywordsInput');
|
||||
var input = document.getElementById('seoKeywordsInput');
|
||||
var preview = document.getElementById('seoPreview');
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function render() {
|
||||
var text = ta.value.trim();
|
||||
if (!text) { preview.style.display = 'none'; return; }
|
||||
|
||||
var parts = text.split(',');
|
||||
var html = '';
|
||||
parts.forEach(function(part, i) {
|
||||
var word = part.trim();
|
||||
if (word) {
|
||||
var color = COLORS[i % COLORS.length];
|
||||
html += '<span style="background:' + color + ';border-radius:4px;padding:2px 6px;margin:2px;">' + esc(word) + '</span>';
|
||||
}
|
||||
if (i < parts.length - 1) {
|
||||
html += '<span style="color:#ccc;font-size:0.8em;margin:0 1px">,</span>';
|
||||
}
|
||||
function renderSeoPreview() {
|
||||
var words = input.value.split(',').map(function(value) { return value.trim(); }).filter(Boolean);
|
||||
preview.replaceChildren();
|
||||
preview.hidden = words.length === 0;
|
||||
words.forEach(function(word, index) {
|
||||
var tag = document.createElement('span');
|
||||
tag.textContent = word;
|
||||
tag.style.background = colors[index % colors.length];
|
||||
tag.style.borderRadius = '4px';
|
||||
tag.style.padding = '2px 6px';
|
||||
tag.style.margin = '2px';
|
||||
preview.appendChild(tag);
|
||||
});
|
||||
|
||||
preview.innerHTML = html;
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
|
||||
ta.addEventListener('input', render);
|
||||
render();
|
||||
input.addEventListener('input', renderSeoPreview);
|
||||
renderSeoPreview();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user