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:
2021-08-12 15:48:02 +02:00
committed by dietrich
parent 1611cfb9a2
commit fc9b18141f
60 changed files with 2238 additions and 396 deletions
+30
View File
@@ -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'
View File
+38
View File
@@ -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
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+46
View File
@@ -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"),
]]
]
]
}
+6
View File
@@ -0,0 +1,6 @@
use seed::{div, prelude::*};
#[must_use]
pub fn view<Ms>() -> Node<Ms> {
div!["List Links"]
}
+247
View File
@@ -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]
]
}
+200
View File
@@ -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],
]
}
+3
View File
@@ -0,0 +1,3 @@
pub mod home;
pub mod list_links;
pub mod list_users;