Working user and link list, (big)restructure
This is now a workspace consisting of: * the pslink app (a wasm frontend for the admin interface) * the pslink binary * shared - modules for communication between the two above.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "app"
|
||||
authors = ["Dietrich <dietrich@teilgedanken.de>"]
|
||||
categories = ["web-programming", "network-programming", "web-programming::http-server", "command-line-utilities"]
|
||||
description = "A wasm frontend for pslink. The frontend communicates via json API."
|
||||
edition = "2018"
|
||||
keywords = ["url", "link", "webpage", "actix", "web"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/enaut/pslink/"
|
||||
version = "0.3.1"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
fluent = "0.15"
|
||||
seed = "0.8"
|
||||
serde = "1.0"
|
||||
strum_macros = "0.20"
|
||||
unic-langid = "0.9"
|
||||
strum = "0.20"
|
||||
enum-map = "1"
|
||||
|
||||
shared = { path = "../shared" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 'z'
|
||||
@@ -0,0 +1,38 @@
|
||||
list-links = Link Liste
|
||||
add-link = Link hinzufügen
|
||||
invite-user = Benutzer einladen
|
||||
list-users = Liste der Benutzer
|
||||
welcome-user = Herzlich willkommen {$username}
|
||||
logout = Abmelden
|
||||
login = Login
|
||||
|
||||
not-found = Dieser Link existiert nicht, oder wurde gelöscht.
|
||||
|
||||
edit-link-headline = Zu editierender Link: {$linktitle}
|
||||
edit-link = Link Editieren
|
||||
create-link = Link Erstellen
|
||||
link-description = Beschreibung
|
||||
link-target = Link Ziel
|
||||
link-code = Link Code
|
||||
shortlink = Shortlink
|
||||
qr-code = QR-code
|
||||
search-placeholder = Filtern nach...
|
||||
|
||||
danger-zone = Achtung!
|
||||
danger-zone-text = Verändern Sie den Code von bereits veröffentlichten Links nicht. Sollte es dennoch geschehen werden veröffentlichte links unbenutzbar. Wird das Linkziel verändert, so zeigen auch die bereits veröffentlichten Links auf das neue Ziel.
|
||||
|
||||
save-edits = Speichere die Veränderungen
|
||||
delete-link = Diesen Link löschen
|
||||
|
||||
edit-user-headline = Benutzereinstellungen von: {$username}
|
||||
username = Benutzername
|
||||
email = Email
|
||||
password = Passwort
|
||||
password-placeholder = Leer lassen um das Passwort nicht zu ändern
|
||||
save-user = Benutzer speichern
|
||||
edit-user = Benutzer editieren
|
||||
make-user-admin = Zum Administrator befördern
|
||||
make-user-regular = Zurückstufen zum normalen Nutzer
|
||||
|
||||
userid = Benutzernummer
|
||||
statistics = Statistik
|
||||
@@ -0,0 +1,38 @@
|
||||
list-links = List of existing links
|
||||
add-link = Add a new link
|
||||
invite-user = Invite a new user
|
||||
list-users = List of existing users
|
||||
welcome-user = Welcome {$username}
|
||||
logout = Logout
|
||||
login = Login
|
||||
|
||||
not-found = This Link has not been found or has been deleted
|
||||
|
||||
edit-link-headline = Edit link: {$linktitle}
|
||||
edit-link = Edit link
|
||||
create-link = Create link
|
||||
link-description = Description
|
||||
link-target = Link target
|
||||
link-code = Link code
|
||||
shortlink = Shortlink
|
||||
search-placeholder = Filter according to...
|
||||
|
||||
danger-zone = Danger Zone!
|
||||
danger-zone-text = Do not change the code of links that are published. If you do so the published links will become invalid! If you change the target the published links will point to the new target.
|
||||
|
||||
save-edits = Save edits
|
||||
delete-link = Delete this link
|
||||
|
||||
user-headline = User Settings of: {$username}
|
||||
edit-user-headline = Change Settings of: {$username}
|
||||
username = Username
|
||||
email = Email
|
||||
password = Password
|
||||
password-placeholder = Leave this empty to keep the current password
|
||||
save-user = Save this user
|
||||
edit-user = Edit this user
|
||||
make-user-admin = Promote to admin
|
||||
make-user-regular = Demote to regular
|
||||
|
||||
userid = User ID
|
||||
statistics = Statistics
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use fluent::{FluentArgs, FluentBundle, FluentResource};
|
||||
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
// ------ I18n ------
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct I18n {
|
||||
lang: Lang,
|
||||
ftl_bundle: Arc<FluentBundle<FluentResource>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for I18n {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self.lang)
|
||||
}
|
||||
}
|
||||
|
||||
impl I18n {
|
||||
#[must_use]
|
||||
pub fn new(lang: Lang) -> Self {
|
||||
Self {
|
||||
lang,
|
||||
ftl_bundle: Arc::new(lang.create_ftl_bundle()),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn lang(&self) -> &Lang {
|
||||
&self.lang
|
||||
}
|
||||
|
||||
pub fn set_lang(&mut self, lang: Lang) -> &Self {
|
||||
self.lang = lang;
|
||||
self.ftl_bundle = Arc::new(lang.create_ftl_bundle());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn translate(&self, key: impl AsRef<str>, args: Option<&FluentArgs>) -> String {
|
||||
let msg = self
|
||||
.ftl_bundle
|
||||
.get_message(key.as_ref())
|
||||
.expect("get fluent message");
|
||||
|
||||
let pattern = msg.value().expect("get value for fluent message");
|
||||
|
||||
self.ftl_bundle
|
||||
.format_pattern(pattern, args, &mut vec![])
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ------ Lang ------
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Debug, Copy, Clone, Display, EnumIter, EnumString, AsRefStr, Eq, PartialEq)]
|
||||
pub enum Lang {
|
||||
#[strum(serialize = "en-US")]
|
||||
EnUS,
|
||||
#[strum(serialize = "de-DE")]
|
||||
DeDE,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
#[must_use]
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::EnUS => "English (US)",
|
||||
Self::DeDE => "Deutsch (Deutschland)",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn ftl_messages(self) -> &'static str {
|
||||
macro_rules! include_ftl_messages {
|
||||
( $lang_id:literal ) => {
|
||||
include_str!(concat!("../locales/", $lang_id, "/main.ftl"))
|
||||
};
|
||||
}
|
||||
match self {
|
||||
Self::EnUS => include_ftl_messages!("en"),
|
||||
Self::DeDE => include_ftl_messages!("de"),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn to_language_identifier(self) -> LanguageIdentifier {
|
||||
self.as_ref()
|
||||
.parse()
|
||||
.expect("parse Lang to LanguageIdentifier")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn create_ftl_bundle(self) -> FluentBundle<FluentResource> {
|
||||
let ftl_resource =
|
||||
FluentResource::try_new(self.ftl_messages().to_owned()).expect("parse FTL messages");
|
||||
|
||||
let mut bundle = FluentBundle::new(vec![self.to_language_identifier()]);
|
||||
bundle.add_resource(ftl_resource).expect("add FTL resource");
|
||||
bundle
|
||||
}
|
||||
}
|
||||
|
||||
// ------ create_t ------
|
||||
|
||||
/// Convenience macro to improve readability of `view`s with many translations.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
///```rust,no_run
|
||||
/// fn view(model: &Model) -> Node<Msg> {
|
||||
/// let args_male_sg = fluent_args![
|
||||
/// "userName" => "Stephan",
|
||||
/// "userGender" => "male",
|
||||
/// ];
|
||||
///
|
||||
/// create_t!(model.i18n);
|
||||
/// div![
|
||||
/// p![t!("hello-world")],
|
||||
/// p![t!("hello-user", args_male_sg)],
|
||||
/// ]
|
||||
/// }
|
||||
///```
|
||||
#[macro_export]
|
||||
macro_rules! create_t {
|
||||
( $i18n:expr ) => {
|
||||
// This replaces $d with $ in the inner macro.
|
||||
seed::with_dollar_sign! {
|
||||
($d:tt) => {
|
||||
macro_rules! t {
|
||||
{ $d key:expr } => {
|
||||
{
|
||||
$i18n.translate($d key, None)
|
||||
}
|
||||
};
|
||||
{ $d key:expr, $d args:expr } => {
|
||||
{
|
||||
$i18n.translate($d key, Some(&$d args))
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
pub mod i18n;
|
||||
pub mod navigation;
|
||||
pub mod pages;
|
||||
|
||||
use pages::list_links;
|
||||
use pages::list_users;
|
||||
use seed::{div, log, prelude::*, App, Url, C};
|
||||
|
||||
use crate::i18n::{I18n, Lang};
|
||||
|
||||
// ------ ------
|
||||
// Init
|
||||
// ------ ------
|
||||
|
||||
fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
||||
orders.subscribe(Msg::UrlChanged);
|
||||
|
||||
log!(url);
|
||||
|
||||
let lang = I18n::new(Lang::DeDE);
|
||||
|
||||
Model {
|
||||
index: 0,
|
||||
base_url: Url::new().add_path_part("app"),
|
||||
page: Page::init(url, orders, lang.clone()),
|
||||
i18n: lang,
|
||||
}
|
||||
}
|
||||
|
||||
// ------ ------
|
||||
// Model
|
||||
// ------ ------
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Model {
|
||||
index: usize,
|
||||
base_url: Url,
|
||||
page: Page,
|
||||
i18n: i18n::I18n,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Page {
|
||||
Home(pages::list_links::Model),
|
||||
ListUsers(pages::list_users::Model),
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
fn init(mut url: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Self {
|
||||
url.next_path_part();
|
||||
let result = match url.remaining_path_parts().as_slice() {
|
||||
[] | ["list_links"] => Self::Home(pages::list_links::init(
|
||||
url,
|
||||
&mut orders.proxy(Msg::ListLinksMsg),
|
||||
i18n,
|
||||
)),
|
||||
["list_users"] => Self::ListUsers(pages::list_users::init(
|
||||
url,
|
||||
&mut orders.proxy(Msg::ListUsersMsg),
|
||||
i18n,
|
||||
)),
|
||||
_other => Self::NotFound,
|
||||
};
|
||||
|
||||
log!("Page initialized");
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// ------ ------
|
||||
// Update
|
||||
// ------ ------
|
||||
#[allow(renamed_and_removed_lints, pub_enum_variant_names)]
|
||||
#[derive(Clone)]
|
||||
pub enum Msg {
|
||||
UrlChanged(subs::UrlChanged),
|
||||
ListLinksMsg(list_links::Msg),
|
||||
ListUsersMsg(list_users::Msg),
|
||||
NoMessage,
|
||||
}
|
||||
|
||||
fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
match msg {
|
||||
Msg::UrlChanged(url) => {
|
||||
log!("Url changed!");
|
||||
|
||||
model.page = Page::init(url.0, orders, model.i18n.clone());
|
||||
}
|
||||
Msg::ListLinksMsg(msg) => {
|
||||
if let Page::Home(model) = &mut model.page {
|
||||
list_links::update(msg, model, &mut orders.proxy(Msg::ListLinksMsg))
|
||||
}
|
||||
}
|
||||
Msg::ListUsersMsg(msg) => {
|
||||
if let Page::ListUsers(model) = &mut model.page {
|
||||
list_users::update(msg, model, &mut orders.proxy(Msg::ListUsersMsg))
|
||||
}
|
||||
}
|
||||
Msg::NoMessage => (),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Urls<'a> {
|
||||
base_url: std::borrow::Cow<'a, Url>,
|
||||
}
|
||||
|
||||
impl<'a> Urls<'a> {
|
||||
/// Create a new `Urls` instance.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// Urls::new(base_url).home()
|
||||
/// ```
|
||||
pub fn new(base_url: impl Into<std::borrow::Cow<'a, Url>>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return base `Url`. If `base_url` isn't owned, it will be cloned.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// pub fn admin_urls(self) -> page::admin::Urls<'a> {
|
||||
/// page::admin::Urls::new(self.base_url().add_path_part(ADMIN))
|
||||
/// }
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn base_url(self) -> Url {
|
||||
self.base_url.into_owned()
|
||||
}
|
||||
#[must_use]
|
||||
pub fn home(self) -> Url {
|
||||
self.base_url()
|
||||
}
|
||||
#[must_use]
|
||||
pub fn list_links(self) -> Url {
|
||||
self.base_url().add_path_part("list_links")
|
||||
}
|
||||
#[must_use]
|
||||
pub fn list_users(self) -> Url {
|
||||
self.base_url().add_path_part("list_users")
|
||||
}
|
||||
}
|
||||
|
||||
// ------ ------
|
||||
// View
|
||||
// ------ ------
|
||||
|
||||
fn view(model: &Model) -> Node<Msg> {
|
||||
div![
|
||||
C!["page"],
|
||||
navigation::navigation(&model.i18n, &model.base_url,),
|
||||
view_content(&model.page, &model.base_url),
|
||||
]
|
||||
}
|
||||
fn view_content(page: &Page, url: &Url) -> Node<Msg> {
|
||||
div![
|
||||
C!["container"],
|
||||
match page {
|
||||
Page::Home(model) => pages::list_links::view(model).map_msg(Msg::ListLinksMsg),
|
||||
Page::ListUsers(model) => pages::list_users::view(model).map_msg(Msg::ListUsersMsg),
|
||||
Page::NotFound => div![div![url.to_string()], "Page not found!"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ------ ------
|
||||
// Start
|
||||
// ------ ------
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn main() {
|
||||
App::start("app", init, update, view);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use fluent::fluent_args;
|
||||
use seed::{a, attrs, div, li, nav, ol, prelude::*, Url};
|
||||
|
||||
use crate::{i18n::I18n, Msg};
|
||||
#[must_use]
|
||||
pub fn navigation(i18n: &I18n, base_url: &Url) -> Node<Msg> {
|
||||
let username = fluent_args![ "username" => "enaut"];
|
||||
macro_rules! t {
|
||||
{ $key:expr } => {
|
||||
{
|
||||
i18n.translate($key, None)
|
||||
}
|
||||
};
|
||||
{ $key:expr, $args:expr } => {
|
||||
{
|
||||
i18n.translate($key, Some(&$args))
|
||||
}
|
||||
};
|
||||
}
|
||||
nav![
|
||||
ol![
|
||||
li![a![
|
||||
attrs! {At::Href => crate::Urls::new(base_url).list_links()},
|
||||
t!("list-links"),
|
||||
],],
|
||||
li![a![ev(Ev::Click, |_| Msg::NoMessage), t!("add-link"),],],
|
||||
li![a![
|
||||
attrs! {At::Href => "#"},
|
||||
ev(Ev::Click, |_| Msg::NoMessage),
|
||||
t!("invite-user"),
|
||||
],],
|
||||
li![a![
|
||||
attrs! {At::Href => crate::Urls::new(base_url).list_users()},
|
||||
t!("list-users"),
|
||||
],],
|
||||
],
|
||||
ol![
|
||||
li![div![t!("welcome-user", username)]],
|
||||
li![a![
|
||||
attrs! {At::Href => "#"},
|
||||
ev(Ev::Click, |_| Msg::NoMessage),
|
||||
t!("logout"),
|
||||
]]
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use seed::{div, prelude::*};
|
||||
|
||||
#[must_use]
|
||||
pub fn view<Ms>() -> Node<Ms> {
|
||||
div!["List Links"]
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
use enum_map::EnumMap;
|
||||
use seed::{a, attrs, button, h1, input, log, prelude::*, section, table, td, th, tr, Url, C};
|
||||
|
||||
use shared::{
|
||||
apirequests::general::Ordering,
|
||||
apirequests::{
|
||||
general::Operation,
|
||||
links::{LinkOverviewColumns, LinkRequestForm},
|
||||
},
|
||||
datatypes::FullLink,
|
||||
};
|
||||
|
||||
use crate::i18n::I18n;
|
||||
|
||||
pub fn init(_: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Model {
|
||||
orders.send_msg(Msg::Fetch);
|
||||
|
||||
Model {
|
||||
links: Vec::new(),
|
||||
i18n,
|
||||
formconfig: LinkRequestForm::default(),
|
||||
inputs: EnumMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Model {
|
||||
links: Vec<FullLink>,
|
||||
i18n: I18n,
|
||||
formconfig: LinkRequestForm,
|
||||
inputs: EnumMap<LinkOverviewColumns, FilterInput>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct FilterInput {
|
||||
filter_input: ElRef<web_sys::HtmlInputElement>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Msg {
|
||||
Fetch,
|
||||
OrderBy(LinkOverviewColumns),
|
||||
Received(Vec<FullLink>),
|
||||
CodeFilterChanged(String),
|
||||
DescriptionFilterChanged(String),
|
||||
TargetFilterChanged(String),
|
||||
AuthorFilterChanged(String),
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
/// Sould only panic on bugs.
|
||||
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
match msg {
|
||||
Msg::Fetch => {
|
||||
orders.skip(); // No need to rerender
|
||||
let data = model.formconfig.clone(); // complicated way to move into the closure
|
||||
orders.perform_cmd(async {
|
||||
let data = data;
|
||||
let response = fetch(
|
||||
Request::new("/admin/json/list_links/")
|
||||
.method(Method::Post)
|
||||
.json(&data)
|
||||
.expect("serialization failed"),
|
||||
)
|
||||
.await
|
||||
.expect("HTTP request failed");
|
||||
|
||||
let user: Vec<FullLink> = response
|
||||
.check_status() // ensure we've got 2xx status
|
||||
.expect("status check failed")
|
||||
.json()
|
||||
.await
|
||||
.expect("deserialization failed");
|
||||
|
||||
Msg::Received(user)
|
||||
});
|
||||
}
|
||||
Msg::OrderBy(column) => {
|
||||
model.formconfig.order = model.formconfig.order.as_ref().map_or_else(
|
||||
|| {
|
||||
Some(Operation {
|
||||
column: column.clone(),
|
||||
value: Ordering::Ascending,
|
||||
})
|
||||
},
|
||||
|order| {
|
||||
Some(Operation {
|
||||
column: column.clone(),
|
||||
value: if order.column == column && order.value == Ordering::Ascending {
|
||||
Ordering::Descending
|
||||
} else {
|
||||
Ordering::Ascending
|
||||
},
|
||||
})
|
||||
},
|
||||
);
|
||||
orders.send_msg(Msg::Fetch);
|
||||
|
||||
model.links.sort_by(match column {
|
||||
LinkOverviewColumns::Code => {
|
||||
|o: &FullLink, t: &FullLink| o.link.code.cmp(&t.link.code)
|
||||
}
|
||||
LinkOverviewColumns::Description => {
|
||||
|o: &FullLink, t: &FullLink| o.link.title.cmp(&t.link.title)
|
||||
}
|
||||
LinkOverviewColumns::Target => {
|
||||
|o: &FullLink, t: &FullLink| o.link.target.cmp(&t.link.target)
|
||||
}
|
||||
LinkOverviewColumns::Author => {
|
||||
|o: &FullLink, t: &FullLink| o.user.username.cmp(&t.user.username)
|
||||
}
|
||||
LinkOverviewColumns::Statistics => {
|
||||
|o: &FullLink, t: &FullLink| o.clicks.number.cmp(&t.clicks.number)
|
||||
}
|
||||
})
|
||||
}
|
||||
Msg::Received(response) => {
|
||||
model.links = response;
|
||||
}
|
||||
Msg::CodeFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[LinkOverviewColumns::Code].sieve = sanit;
|
||||
orders.send_msg(Msg::Fetch);
|
||||
}
|
||||
Msg::DescriptionFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[LinkOverviewColumns::Description].sieve = sanit;
|
||||
orders.send_msg(Msg::Fetch);
|
||||
}
|
||||
Msg::TargetFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[LinkOverviewColumns::Target].sieve = sanit;
|
||||
orders.send_msg(Msg::Fetch);
|
||||
}
|
||||
Msg::AuthorFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[LinkOverviewColumns::Author].sieve = sanit;
|
||||
orders.send_msg(Msg::Fetch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// # Panics
|
||||
/// Sould only panic on bugs.
|
||||
pub fn view(model: &Model) -> Node<Msg> {
|
||||
macro_rules! t {
|
||||
{ $key:expr } => {
|
||||
{
|
||||
model.i18n.translate($key, None)
|
||||
}
|
||||
};
|
||||
{ $key:expr, $args:expr } => {
|
||||
{
|
||||
model.i18n.translate($key, Some(&$args))
|
||||
}
|
||||
};
|
||||
}
|
||||
section![
|
||||
h1!("List Links Page from list_links"),
|
||||
table![
|
||||
tr![
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Code)),
|
||||
t!("link-code")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::OrderBy(
|
||||
LinkOverviewColumns::Description
|
||||
)),
|
||||
t!("link-description")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Target)),
|
||||
t!("link-target")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Author)),
|
||||
t!("username")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Statistics)),
|
||||
t!("statistics")
|
||||
]
|
||||
],
|
||||
tr![
|
||||
C!["filters"],
|
||||
td![input![
|
||||
attrs! {
|
||||
At::Value => &model.formconfig.filter[LinkOverviewColumns::Code].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t!("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, Msg::CodeFilterChanged),
|
||||
el_ref(&model.inputs[LinkOverviewColumns::Code].filter_input),
|
||||
]],
|
||||
td![input![
|
||||
attrs! {At::Value =>
|
||||
&model
|
||||
.formconfig.filter[LinkOverviewColumns::Description].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t!("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, Msg::DescriptionFilterChanged),
|
||||
el_ref(&model.inputs[LinkOverviewColumns::Description].filter_input),
|
||||
]],
|
||||
td![input![
|
||||
attrs! {At::Value =>
|
||||
&model
|
||||
.formconfig.filter[LinkOverviewColumns::Target].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t!("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, Msg::TargetFilterChanged),
|
||||
el_ref(&model.inputs[LinkOverviewColumns::Target].filter_input),
|
||||
]],
|
||||
td![input![
|
||||
attrs! {At::Value =>
|
||||
&model
|
||||
.formconfig.filter[LinkOverviewColumns::Author].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t!("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, Msg::AuthorFilterChanged),
|
||||
el_ref(&model.inputs[LinkOverviewColumns::Author].filter_input),
|
||||
]],
|
||||
td![]
|
||||
],
|
||||
model.links.iter().map(view_link)
|
||||
],
|
||||
button![ev(Ev::Click, |_| Msg::Fetch), "Fetch links"]
|
||||
]
|
||||
}
|
||||
|
||||
fn view_link(l: &FullLink) -> Node<Msg> {
|
||||
tr![
|
||||
td![&l.link.code],
|
||||
td![&l.link.title],
|
||||
td![a![attrs![At::Href => &l.link.target], &l.link.target]],
|
||||
td![&l.user.username],
|
||||
td![&l.clicks.number]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use enum_map::EnumMap;
|
||||
use seed::{attrs, button, h1, input, log, prelude::*, section, table, td, th, tr, Url, C};
|
||||
use shared::{
|
||||
apirequests::general::{Operation, Ordering},
|
||||
apirequests::users::{UserOverviewColumns, UserRequestForm},
|
||||
datatypes::User,
|
||||
};
|
||||
|
||||
use crate::i18n::I18n;
|
||||
#[must_use]
|
||||
pub fn init(_: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Model {
|
||||
orders.send_msg(Msg::Fetch);
|
||||
Model {
|
||||
users: Vec::new(),
|
||||
i18n,
|
||||
formconfig: UserRequestForm::default(),
|
||||
inputs: EnumMap::default(),
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Model {
|
||||
users: Vec<User>,
|
||||
i18n: I18n,
|
||||
formconfig: UserRequestForm,
|
||||
inputs: EnumMap<UserOverviewColumns, FilterInput>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct FilterInput {
|
||||
filter_input: ElRef<web_sys::HtmlInputElement>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Msg {
|
||||
Fetch,
|
||||
OrderBy(UserOverviewColumns),
|
||||
Received(Vec<User>),
|
||||
IdFilterChanged(String),
|
||||
EmailFilterChanged(String),
|
||||
UsernameFilterChanged(String),
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
/// Sould only panic on bugs.
|
||||
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
match msg {
|
||||
Msg::Fetch => {
|
||||
orders.skip(); // No need to rerender
|
||||
let data = model.formconfig.clone(); // complicated way to move into the closure
|
||||
orders.perform_cmd(async {
|
||||
let data = data;
|
||||
let response = fetch(
|
||||
Request::new("/admin/json/list_users/")
|
||||
.method(Method::Post)
|
||||
.json(&data)
|
||||
.expect("serialization failed"),
|
||||
)
|
||||
.await
|
||||
.expect("HTTP request failed");
|
||||
|
||||
let users: Vec<User> = response
|
||||
.check_status() // ensure we've got 2xx status
|
||||
.expect("status check failed")
|
||||
.json()
|
||||
.await
|
||||
.expect("deserialization failed");
|
||||
|
||||
Msg::Received(users)
|
||||
});
|
||||
}
|
||||
Msg::OrderBy(column) => {
|
||||
model.formconfig.order = model.formconfig.order.as_ref().map_or_else(
|
||||
|| {
|
||||
Some(Operation {
|
||||
column: column.clone(),
|
||||
value: Ordering::Ascending,
|
||||
})
|
||||
},
|
||||
|order| {
|
||||
Some(Operation {
|
||||
column: column.clone(),
|
||||
value: if order.column == column && order.value == Ordering::Ascending {
|
||||
Ordering::Descending
|
||||
} else {
|
||||
Ordering::Ascending
|
||||
},
|
||||
})
|
||||
},
|
||||
);
|
||||
orders.send_msg(Msg::Fetch);
|
||||
|
||||
model.users.sort_by(match column {
|
||||
UserOverviewColumns::Id => |o: &User, t: &User| o.id.cmp(&t.id),
|
||||
UserOverviewColumns::Username => |o: &User, t: &User| o.username.cmp(&t.username),
|
||||
UserOverviewColumns::Email => |o: &User, t: &User| o.email.cmp(&t.email),
|
||||
})
|
||||
}
|
||||
Msg::Received(response) => {
|
||||
model.users = response;
|
||||
}
|
||||
Msg::IdFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_numeric()).collect();
|
||||
model.formconfig.filter[UserOverviewColumns::Id].sieve = sanit;
|
||||
orders.send_msg(Msg::Fetch);
|
||||
}
|
||||
Msg::UsernameFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[UserOverviewColumns::Username].sieve = sanit;
|
||||
orders.send_msg(Msg::Fetch);
|
||||
}
|
||||
Msg::EmailFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
// FIXME: Sanitazion does not work for @
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[UserOverviewColumns::Email].sieve = sanit;
|
||||
orders.send_msg(Msg::Fetch);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
/// # Panics
|
||||
/// Sould only panic on bugs.
|
||||
pub fn view(model: &Model) -> Node<Msg> {
|
||||
macro_rules! t {
|
||||
{ $key:expr } => {
|
||||
{
|
||||
model.i18n.translate($key, None)
|
||||
}
|
||||
};
|
||||
{ $key:expr, $args:expr } => {
|
||||
{
|
||||
model.i18n.translate($key, Some(&$args))
|
||||
}
|
||||
};
|
||||
}
|
||||
section![
|
||||
h1!("List Users Page from list_users"),
|
||||
table![
|
||||
tr![
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::OrderBy(UserOverviewColumns::Id)),
|
||||
t!("userid")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::OrderBy(UserOverviewColumns::Email)),
|
||||
t!("email")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::OrderBy(UserOverviewColumns::Username)),
|
||||
t!("username")
|
||||
],
|
||||
],
|
||||
tr![
|
||||
C!["filters"],
|
||||
td![input![
|
||||
attrs! {
|
||||
At::Value => &model.formconfig.filter[UserOverviewColumns::Id].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t!("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, Msg::IdFilterChanged),
|
||||
el_ref(&model.inputs[UserOverviewColumns::Id].filter_input),
|
||||
]],
|
||||
td![input![
|
||||
attrs! {At::Value =>
|
||||
&model
|
||||
.formconfig.filter[UserOverviewColumns::Email].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t!("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, Msg::EmailFilterChanged),
|
||||
el_ref(&model.inputs[UserOverviewColumns::Email].filter_input),
|
||||
]],
|
||||
td![input![
|
||||
attrs! {At::Value =>
|
||||
&model
|
||||
.formconfig.filter[UserOverviewColumns::Username].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t!("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, Msg::UsernameFilterChanged),
|
||||
el_ref(&model.inputs[UserOverviewColumns::Username].filter_input),
|
||||
]],
|
||||
],
|
||||
model.users.iter().map(view_user)
|
||||
],
|
||||
button![ev(Ev::Click, |_| Msg::Fetch), "Refresh"]
|
||||
]
|
||||
}
|
||||
|
||||
fn view_user(l: &User) -> Node<Msg> {
|
||||
tr![
|
||||
td![&l.id],
|
||||
td![&l.email],
|
||||
//td![a![attrs![At::Href => &l.link.target], &l.link.target]],
|
||||
td![&l.username],
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod home;
|
||||
pub mod list_links;
|
||||
pub mod list_users;
|
||||
Reference in New Issue
Block a user