Added banner options, logo and image upload with validation to appearance settings. Fixes #15
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,25 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance, UpdateColors};
|
||||
use leptos::ev::MouseEvent;
|
||||
use leptos::wasm_bindgen::JsCast;
|
||||
use web_sys::{Element, File, FormData, HtmlInputElement, Url};
|
||||
use crate::backend::appearance::{DeleteBanner, DeleteLogo, get_appearance, UpdateAppearance, UpdateStyle, upload_image};
|
||||
use crate::backend::banner::{image_extension, BannerOptions, ImageKind, IMAGE_ACCEPT, MAX_DIM, MAX_IMAGE_SIZE};
|
||||
use crate::backend::color::{Colors, PALETTES};
|
||||
use crate::backend::data::{ApiResponse, Appearance};
|
||||
use crate::backend::data::{banner_style, ApiResponse, Appearance};
|
||||
use crate::components::data_form::{DataForm, QuestionDialog};
|
||||
use crate::components::modal_box::DialogOpener;
|
||||
use crate::locales::trl;
|
||||
|
||||
/// Look of the public page as it is being edited. Nothing is stored until it is saved.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Look {
|
||||
colors: RwSignal<Colors>,
|
||||
banner: RwSignal<BannerOptions>,
|
||||
/// Images selected in the file inputs and not uploaded yet, as object URLs for the preview
|
||||
new_banner: RwSignal<Option<String>>,
|
||||
new_logo: RwSignal<Option<String>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn edit_title(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
|
||||
let update = ServerAction::<UpdateAppearance>::new();
|
||||
@@ -62,13 +76,22 @@ fn delete_banner(opener: DialogOpener) -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn delete_logo(opener: DialogOpener) -> impl IntoView {
|
||||
let delete = ServerAction::<DeleteLogo>::new();
|
||||
view! {
|
||||
<QuestionDialog opener=opener action=delete title="Delete logo">
|
||||
<div>{trl("Are you sure you want to delete logo?")}</div>
|
||||
</QuestionDialog>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn color_input(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
colors: RwSignal<Colors>,
|
||||
get: fn(&Colors) -> String,
|
||||
set: fn(&mut Colors, String)) -> impl IntoView {
|
||||
#[prop(into)] value: Signal<String>,
|
||||
#[prop(into)] on_change: Callback<String>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label for=id class="form-label mb-0">{trl(label)}</label>
|
||||
@@ -76,27 +99,198 @@ fn color_input(
|
||||
type="color"
|
||||
id=id
|
||||
class="form-control form-control-color"
|
||||
prop:value=move || get(&colors.get())
|
||||
on:input=move |ev| colors.update(|c| set(c, event_target_value(&ev)))
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_change.run(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample of the public page in the colours being edited. It uses the same classes and the same
|
||||
/// One of a few values, as a row of buttons. Options are pairs of a value and its label.
|
||||
#[component]
|
||||
fn choice(
|
||||
label: &'static str,
|
||||
options: Vec<(&'static str, &'static str)>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
#[prop(into)] on_change: Callback<String>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div class="form-label mb-0">{trl(label)}</div>
|
||||
<div class="btn-group" role="group" aria-label=trl(label)>
|
||||
{options.into_iter().map(|(option, option_label)| view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || if value.get() == option { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline-secondary" }
|
||||
on:click=move |_| on_change.run(option.to_string())>
|
||||
{trl(option_label)}
|
||||
</button>
|
||||
}).collect_view()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Object URLs are made by the browser; still, only a plain one is let into a style
|
||||
fn is_object_url(url: &str) -> bool {
|
||||
url.starts_with("blob:")
|
||||
&& url.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '/' | '.' | '-' | '_' | '[' | ']'))
|
||||
}
|
||||
|
||||
/// Selecting, uploading and deleting of the banner image or the logo. A selected image is shown
|
||||
/// in the preview first and is uploaded on request.
|
||||
#[component]
|
||||
fn image_upload(
|
||||
kind: ImageKind,
|
||||
label: &'static str,
|
||||
/// The stored image exists
|
||||
#[prop(into)] exists: Signal<bool>,
|
||||
/// Object URL of the selected image, for the preview
|
||||
selected: RwSignal<Option<String>>,
|
||||
/// Counter of finished uploads, the page reloads its data when it changes
|
||||
uploads: RwSignal<u32>,
|
||||
delete: DialogOpener) -> impl IntoView {
|
||||
let file = RwSignal::new_local(None::<File>);
|
||||
let error = RwSignal::new(None::<String>);
|
||||
let input_ref = NodeRef::<leptos::html::Input>::new();
|
||||
let upload = Action::new_local(|data: &FormData| upload_image(data.clone().into()));
|
||||
let id = format!("{}_file", kind.name());
|
||||
|
||||
let clear = move || {
|
||||
if let Some(url) = selected.get_untracked() {
|
||||
let _ = Url::revoke_object_url(&url);
|
||||
}
|
||||
selected.set(None);
|
||||
file.set(None);
|
||||
if let Some(input) = input_ref.get_untracked() {
|
||||
input.set_value("");
|
||||
}
|
||||
};
|
||||
|
||||
Effect::new(move |_| {
|
||||
match upload.value().get() {
|
||||
Some(Ok(ApiResponse::Data(_))) => {
|
||||
clear();
|
||||
uploads.update(|count| *count += 1);
|
||||
}
|
||||
Some(Ok(ApiResponse::Error(e))) => error.set(Some(trl(&e)())),
|
||||
Some(Err(e)) => error.set(Some(format!("Server error: {}", e))),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="mb-3">
|
||||
<label for=id.clone() class="form-label">{trl(label)}</label>
|
||||
<input
|
||||
type="file"
|
||||
id=id
|
||||
class="form-control"
|
||||
accept=IMAGE_ACCEPT
|
||||
node_ref=input_ref
|
||||
on:change=move |ev| {
|
||||
let input: HtmlInputElement = event_target(&ev);
|
||||
let chosen = input.files().and_then(|files| files.get(0));
|
||||
// Forget the previous selection, but keep the file in the input
|
||||
if let Some(url) = selected.get_untracked() {
|
||||
let _ = Url::revoke_object_url(&url);
|
||||
}
|
||||
selected.set(None);
|
||||
file.set(None);
|
||||
error.set(None);
|
||||
|
||||
// The server checks the same again
|
||||
if let Some(chosen) = chosen {
|
||||
if image_extension(&chosen.name()).is_none() {
|
||||
error.set(Some(trl("Unsupported image type, use JPG, PNG, GIF or WebP")()));
|
||||
} else if chosen.size() > MAX_IMAGE_SIZE as f64 {
|
||||
error.set(Some(trl("Image is too large, 5 MB at most")()));
|
||||
} else {
|
||||
selected.set(Url::create_object_url_with_blob(&chosen).ok().filter(|url| is_object_url(url)));
|
||||
file.set(Some(chosen));
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
{move || error.get().map(|e| view! {
|
||||
<div class="text-danger mt-1">{e}</div>
|
||||
})}
|
||||
<div class="mt-2">
|
||||
{move || if file.get().is_some() {
|
||||
view! {
|
||||
<button type="button" class="btn btn-sm btn-primary me-2" disabled=move || upload.pending().get()
|
||||
on:click=move |_| {
|
||||
if let (Some(chosen), Ok(data)) = (file.get_untracked(), FormData::new()) {
|
||||
// The kind has to be sent before the file
|
||||
let _ = data.append_with_str("kind", kind.name());
|
||||
let _ = data.append_with_blob_and_filename("file", &chosen, &chosen.name());
|
||||
error.set(None);
|
||||
upload.dispatch_local(data);
|
||||
}
|
||||
}>
|
||||
{trl("Upload")}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" on:click=move |_| clear()>
|
||||
{trl("Cancel")}
|
||||
</button>
|
||||
}.into_any()
|
||||
} else if exists.get() {
|
||||
view! {
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" on:click=move |_| delete.show()>
|
||||
{trl("Delete")}
|
||||
</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {<span></span>}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample of the public page in the look being edited. It uses the same classes and the same
|
||||
/// stylesheet (assets/css/public.css) as the page itself.
|
||||
#[component]
|
||||
fn preview(colors: RwSignal<Colors>, appearance: ReadSignal<Appearance>) -> impl IntoView {
|
||||
fn preview(look: Look, appearance: ReadSignal<Appearance>) -> impl IntoView {
|
||||
let image = move || look.new_banner.get().or_else(|| appearance.get().banner_url());
|
||||
let logo = move || look.new_logo.get().or_else(|| appearance.get().logo_url());
|
||||
let title = move || appearance.get().title.unwrap_or_default();
|
||||
let has_banner = move || image().is_some() || logo().is_some() || !title().trim().is_empty();
|
||||
|
||||
// Clicking into the banner chooses the part of the image that stays visible
|
||||
let set_focus = move |ev: MouseEvent| {
|
||||
let Some(banner) = ev.current_target().and_then(|target| target.dyn_into::<Element>().ok()) else {
|
||||
return
|
||||
};
|
||||
let rect = banner.get_bounding_client_rect();
|
||||
if rect.width() <= 0.0 || rect.height() <= 0.0 {
|
||||
return
|
||||
}
|
||||
|
||||
let percent = |position: f64, size: f64| ((position / size) * 100.0).round().clamp(0.0, 100.0) as i16;
|
||||
let x = percent(ev.client_x() as f64 - rect.left(), rect.width());
|
||||
let y = percent(ev.client_y() as f64 - rect.top(), rect.height());
|
||||
look.banner.update(|banner| {
|
||||
banner.focus_x = x;
|
||||
banner.focus_y = y;
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="public-page public-preview" style=move || colors.get().style()>
|
||||
{move || {
|
||||
let app = appearance.get();
|
||||
app.banner.is_some().then(|| view! {
|
||||
<div class="public-banner" style=app.banner_style()>
|
||||
<h1 class="public-banner-title">{app.title.clone().unwrap_or_default()}</h1>
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
<div class="public-page public-preview" style=move || look.colors.get().style()>
|
||||
<Show when=has_banner>
|
||||
<div class="public-banner" style=move || banner_style(image(), &look.banner.get()) on:click=set_focus>
|
||||
{move || logo().map(|url| view! {
|
||||
<img class="public-banner-logo" src=url alt=""/>
|
||||
})}
|
||||
<h1 class="public-banner-title">{title}</h1>
|
||||
<Show when=move || image().is_some()>
|
||||
<span class="public-banner-focus" style=move || {
|
||||
let banner = look.banner.get().sanitized();
|
||||
format!("left: {}%; top: {}%", banner.focus_x, banner.focus_y)
|
||||
}></span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="public-banner-text" inner_html=move || appearance.get().text.unwrap_or_default()></div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
@@ -150,17 +344,7 @@ fn preview(colors: RwSignal<Colors>, appearance: ReadSignal<Appearance>) -> impl
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl IntoView {
|
||||
let save = ServerAction::<UpdateColors>::new();
|
||||
let sent = StoredValue::new(Colors::default());
|
||||
let dirty = move || colors.get() != saved.get();
|
||||
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(ApiResponse::Data(_))) = save.value().get() {
|
||||
saved.set(sent.get_value());
|
||||
}
|
||||
});
|
||||
|
||||
fn color_settings(colors: RwSignal<Colors>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="form-label">{trl("Palettes")}</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
@@ -182,25 +366,90 @@ fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl Int
|
||||
</button>
|
||||
}).collect_view()}
|
||||
</div>
|
||||
<ColorInput id="color_primary" label="Primary colour" colors=colors
|
||||
get=|c| c.primary.clone() set=|c, v| c.primary = v/>
|
||||
<ColorInput id="color_background" label="Page background" colors=colors
|
||||
get=|c| c.background.clone() set=|c, v| c.background = v/>
|
||||
<ColorInput id="color_card" label="Card background" colors=colors
|
||||
get=|c| c.card.clone() set=|c, v| c.card = v/>
|
||||
<ColorInput id="color_text" label="Text colour" colors=colors
|
||||
get=|c| c.text.clone() set=|c, v| c.text = v/>
|
||||
<ColorInput id="color_primary" label="Primary colour"
|
||||
value=move || colors.get().primary on_change=move |v| colors.update(|c| c.primary = v)/>
|
||||
<ColorInput id="color_background" label="Page background"
|
||||
value=move || colors.get().background on_change=move |v| colors.update(|c| c.background = v)/>
|
||||
<ColorInput id="color_card" label="Card background"
|
||||
value=move || colors.get().card on_change=move |v| colors.update(|c| c.card = v)/>
|
||||
<ColorInput id="color_text" label="Text colour"
|
||||
value=move || colors.get().text on_change=move |v| colors.update(|c| c.text = v)/>
|
||||
{move || colors.get().contrast_issues().into_iter().map(|issue| view! {
|
||||
<div class="text-warning mb-1"><i class="bx bx-error"></i>" "{trl(issue.message())}</div>
|
||||
}).collect_view()}
|
||||
<div class="mt-3">
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn banner_settings(
|
||||
look: Look,
|
||||
appearance: ReadSignal<Appearance>,
|
||||
uploads: RwSignal<u32>,
|
||||
delete_banner: DialogOpener,
|
||||
delete_logo: DialogOpener) -> impl IntoView {
|
||||
let banner = look.banner;
|
||||
|
||||
view! {
|
||||
<ImageUpload kind=ImageKind::Banner label="Banner image" selected=look.new_banner uploads=uploads
|
||||
exists=move || appearance.get().banner_url().is_some() delete=delete_banner/>
|
||||
<ImageUpload kind=ImageKind::Logo label="Logo" selected=look.new_logo uploads=uploads
|
||||
exists=move || appearance.get().logo_url().is_some() delete=delete_logo/>
|
||||
<Choice label="Height" options=vec![("low", "Low"), ("medium", "Medium"), ("high", "High")]
|
||||
value=move || banner.get().height on_change=move |v| banner.update(|b| b.height = v)/>
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label for="banner_dim" class="form-label mb-0">{trl("Darkening")}</label>
|
||||
<input
|
||||
type="range"
|
||||
id="banner_dim"
|
||||
class="form-range w-50"
|
||||
min="0"
|
||||
max=MAX_DIM.to_string()
|
||||
step="5"
|
||||
prop:value=move || banner.get().dim.to_string()
|
||||
on:input=move |ev| {
|
||||
if let Ok(dim) = event_target_value(&ev).parse::<i16>() {
|
||||
banner.update(|b| b.dim = dim);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ColorInput id="title_color" label="Title colour"
|
||||
value=move || banner.get().title_color on_change=move |v| banner.update(|b| b.title_color = v)/>
|
||||
<Choice label="Title alignment" options=vec![("left", "Left"), ("center", "Centre")]
|
||||
value=move || banner.get().title_align on_change=move |v| banner.update(|b| b.title_align = v)/>
|
||||
<div class="text-muted mt-3">
|
||||
{trl("Click into the banner in the preview to choose the part of the image that stays visible.")}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves colours and banner options together, they make one look
|
||||
#[component]
|
||||
fn save_bar(look: Look, saved: RwSignal<(Colors, BannerOptions)>) -> impl IntoView {
|
||||
let save = ServerAction::<UpdateStyle>::new();
|
||||
let sent = StoredValue::new((Colors::default(), BannerOptions::default()));
|
||||
let current = move || (look.colors.get(), look.banner.get());
|
||||
let dirty = move || current() != saved.get();
|
||||
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(ApiResponse::Data(_))) = save.value().get() {
|
||||
saved.set(sent.get_value());
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div>
|
||||
<button type="button" class="btn btn-primary me-2" on:click=move |_| {
|
||||
sent.set_value(colors.get());
|
||||
save.dispatch(UpdateColors { colors: colors.get() });
|
||||
let (colors, banner) = current();
|
||||
sent.set_value((colors.clone(), banner.clone()));
|
||||
save.dispatch(UpdateStyle { colors, banner });
|
||||
}>
|
||||
{trl("Save")}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" on:click=move |_| colors.set(Colors::default())>
|
||||
<button type="button" class="btn btn-outline-secondary" on:click=move |_| {
|
||||
look.colors.set(Colors::default());
|
||||
look.banner.set(BannerOptions::default());
|
||||
}>
|
||||
{trl("Reset to default")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -212,7 +461,7 @@ fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl Int
|
||||
|
||||
match save.value().get() {
|
||||
Some(Ok(ApiResponse::Data(_))) => view! {
|
||||
<span class="text-success">{trl("Colours saved")}</span>
|
||||
<span class="text-success">{trl("Settings saved")}</span>
|
||||
}.into_any(),
|
||||
_ => view! {<span></span>}.into_any()
|
||||
}
|
||||
@@ -234,22 +483,32 @@ fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl Int
|
||||
pub fn appearance() -> impl IntoView {
|
||||
let title_edit = DialogOpener::new();
|
||||
let text_edit = DialogOpener::new();
|
||||
let del_dialog = DialogOpener::new();
|
||||
let appearance = Resource::new(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance());
|
||||
let del_banner = DialogOpener::new();
|
||||
let del_logo = DialogOpener::new();
|
||||
let uploads = RwSignal::new(0u32);
|
||||
let appearance = Resource::new(
|
||||
move || (title_edit.visible() || text_edit.visible() || del_banner.visible() || del_logo.visible(), uploads.get()),
|
||||
|_| get_appearance());
|
||||
let app_edit = RwSignal::new(Appearance::default());
|
||||
let colors = RwSignal::new(Colors::default());
|
||||
let saved_colors = RwSignal::new(Colors::default());
|
||||
let look = Look {
|
||||
colors: RwSignal::new(Colors::default()),
|
||||
banner: RwSignal::new(BannerOptions::default()),
|
||||
new_banner: RwSignal::new(None),
|
||||
new_logo: RwSignal::new(None),
|
||||
};
|
||||
let saved = RwSignal::new((Colors::default(), BannerOptions::default()));
|
||||
let app_preview = RwSignal::new(Appearance::default());
|
||||
let colors_loaded = StoredValue::new(false);
|
||||
let look_loaded = StoredValue::new(false);
|
||||
|
||||
// Runs in the browser only, so the server and the browser render the same markup first.
|
||||
// The data are loaded again after every dialog; that must not throw away colours being edited.
|
||||
// The data are loaded again after every dialog and upload; that must not throw away a look being edited.
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(app)) = appearance.get() {
|
||||
if !colors_loaded.get_value() {
|
||||
colors.set(app.colors.clone());
|
||||
saved_colors.set(app.colors.clone());
|
||||
colors_loaded.set_value(true);
|
||||
if !look_loaded.get_value() {
|
||||
look.colors.set(app.colors.clone());
|
||||
look.banner.set(app.banner_options.clone());
|
||||
saved.set((app.colors.clone(), app.banner_options.clone()));
|
||||
look_loaded.set_value(true);
|
||||
}
|
||||
app_preview.set(app);
|
||||
}
|
||||
@@ -258,47 +517,29 @@ pub fn appearance() -> impl IntoView {
|
||||
view! {
|
||||
<EditTitle opener=title_edit appearance=app_edit.read_only()/>
|
||||
<EditText opener=text_edit appearance=app_edit.read_only()/>
|
||||
<DeleteBanner opener=del_dialog/>
|
||||
<DeleteBanner opener=del_banner/>
|
||||
<DeleteLogo opener=del_logo/>
|
||||
<h1>{trl("Appearance settings")}</h1>
|
||||
<div class="row mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{trl("Top banner")}</h5>
|
||||
<form method="post" action="/upload" enctype="multipart/form-data">
|
||||
<label for="banner_file">{trl("Banner file")}</label>
|
||||
<input id="banner_file" type="file" class="form-control" name="file_to_upload"/>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{trl("Upload")}
|
||||
</button>
|
||||
</form><br/>
|
||||
<h5 class="card-title">{trl("Title and text")}</h5>
|
||||
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
|
||||
{move || {
|
||||
appearance.get().map(|a| match a {
|
||||
Ok(app) => {
|
||||
app_edit.set(app.clone());
|
||||
let has_banner = app.banner.is_some();
|
||||
let title = app.title.clone().unwrap_or_default();
|
||||
let style = app.banner_style();
|
||||
view! {
|
||||
<div>
|
||||
<Show when=move || has_banner>
|
||||
<div class="public-banner" style=style.clone()>
|
||||
<h1 class="public-banner-title">{title.clone()}</h1>
|
||||
</div>
|
||||
</Show>
|
||||
<div>
|
||||
<div class="fs-4 mb-2">{app.title.clone().unwrap_or_default()}</div>
|
||||
<button class="btn btn-info" on:click=move |_| title_edit.show()>
|
||||
<i class="bx bx-edit-alt me-1"></i> {trl("Edit title")}</button>
|
||||
<button class="btn btn-danger" on:click=move |_| del_dialog.show()>
|
||||
{trl("Delete")}
|
||||
</button>
|
||||
<br/><br/>
|
||||
<div inner_html={app.text.unwrap_or("<< TEXT >>".to_string())}>
|
||||
|
||||
</div>
|
||||
<a class="dropdown-item" href="javascript:void(0);" on:click=move |_| text_edit.show()>
|
||||
<i class="bx bx-edit-alt me-1"></i> {trl("Edit text")}</a>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
@@ -314,21 +555,33 @@ pub fn appearance() -> impl IntoView {
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-lg-8 order-lg-2 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{trl("Preview of the public page")}</h5>
|
||||
<Preview colors=colors appearance=app_preview.read_only()/>
|
||||
<Preview look=look appearance=app_preview.read_only()/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 order-lg-1 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="col-lg-4 order-lg-1">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{trl("Colours")}</h5>
|
||||
<ColorSettings colors=colors saved=saved_colors/>
|
||||
<ColorSettings colors=look.colors/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{trl("Banner")}</h5>
|
||||
<BannerSettings look=look appearance=app_preview.read_only() uploads=uploads
|
||||
delete_banner=del_banner delete_logo=del_logo/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<SaveBar look=look saved=saved/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -167,16 +167,17 @@ pub fn Public() -> impl IntoView {
|
||||
{move || {
|
||||
appearance.get().map(|a| match a {
|
||||
Ok(a) => {
|
||||
let has_banner = a.banner.is_some();
|
||||
let title = a.title.clone().unwrap_or_default();
|
||||
let style = a.banner_style();
|
||||
view! {
|
||||
<div>
|
||||
<Show when=move || has_banner>
|
||||
<div class="public-banner" style=style.clone()>
|
||||
<h1 class="public-banner-title">{title.clone()}</h1>
|
||||
{a.has_banner().then(|| view! {
|
||||
<div class="public-banner" style=a.banner_style()>
|
||||
{a.logo_url().map(|url| view! {
|
||||
<img class="public-banner-logo" src=url alt=""/>
|
||||
})}
|
||||
<h1 class="public-banner-title">{title}</h1>
|
||||
</div>
|
||||
</Show>
|
||||
})}
|
||||
<div>
|
||||
<div class="public-banner-text" inner_html={a.text.unwrap_or("".to_string())}>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user