diff --git a/jirs-client/js/css/project.css b/jirs-client/js/css/project.css index d7eb20ef..a484aa41 100644 --- a/jirs-client/js/css/project.css +++ b/jirs-client/js/css/project.css @@ -37,6 +37,33 @@ width: 160px; } +#projectPage > #projectBoardFilters > #avatars { + display: flex; + flex-direction: row-reverse; + margin: 0 12px 0 2px; +} + +#projectPage > #projectBoardFilters > #avatars > .avatarIsActiveBorder { + display: inline-flex; + margin-left: -2px; + border-radius: 50%; + transition: transform 0.1s; + cursor: pointer; + user-select: none; +} + +#projectPage > #projectBoardFilters > #avatars > .avatarIsActiveBorder.isActive { + box-shadow: 0 0 0 4px var(--primary); +} + +#projectPage > #projectBoardFilters > #avatars > .avatarIsActiveBorder:hover { + transform: translateY(-5px); + } + +#projectPage > #projectBoardFilters > #avatars > .avatarIsActiveBorder > .styledAvatar { + box-shadow: 0 0 0 2px #fff; +} + @media (max-width: 1100px) { #projectPage { padding: 25px 20px 50px calc(var(--appNavBarLeftWidth) + var(--secondarySideBarWidth) + 20px); diff --git a/jirs-client/js/css/styledAvatar.css b/jirs-client/js/css/styledAvatar.css new file mode 100644 index 00000000..b959ecf6 --- /dev/null +++ b/jirs-client/js/css/styledAvatar.css @@ -0,0 +1,29 @@ +.styledAvatar.image { + display: inline-block; + border-radius: 100%; + /*background-image: url("${imageURL}");*/ + background-position: 50% 50%; + background-repeat: no-repeat; + background-size: cover; + background-color: var(--backgroundLight); +} + +.styledAvatar.letter { + display: inline-block; + /*width: ${props => props.size} px;*/ + /*height: ${props => props.size} px;*/ + border-radius: 100%; + text-transform: uppercase; + color: #fff; + /*background: ${props => props.color};*/ + font-family: var(--font-medium); + font-weight: normal; + /*${props => font.size(Math.round(props.size / 1.7))}*/ +} + +.styledAvatar.letter > span { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} diff --git a/jirs-client/js/css/variables.css b/jirs-client/js/css/variables.css index 9cd1577e..100b4a9c 100644 --- a/jirs-client/js/css/variables.css +++ b/jirs-client/js/css/variables.css @@ -63,3 +63,14 @@ --font-bold: "CircularStdBold"; --font-black: "CircularStdBlack"; } + +:root { + --avatar-color-1: #DA7657; + --avatar-color-2: #6ADA57; + --avatar-color-3: #5784DA; + --avatar-color-4: #AA57DA; + --avatar-color-5: #DA5757; + --avatar-color-6: #DA5792; + --avatar-color-7: #57DACA; + --avatar-color-8: #57A5DA; +} diff --git a/jirs-client/js/styles.css b/jirs-client/js/styles.css index 22363157..995b972d 100644 --- a/jirs-client/js/styles.css +++ b/jirs-client/js/styles.css @@ -7,5 +7,6 @@ @import "css/icon.css"; @import "css/shared.css"; @import "css/styledTooltip.css"; +@import "css/styledAvatar.css"; @import "css/app.css"; @import "css/project.css"; diff --git a/jirs-client/src/lib.rs b/jirs-client/src/lib.rs index 9867aba0..8586f380 100644 --- a/jirs-client/src/lib.rs +++ b/jirs-client/src/lib.rs @@ -11,6 +11,9 @@ mod project_settings; mod register; mod shared; +pub type UserId = i32; +pub type AvatarFilterActive = bool; + #[derive(Clone, Debug)] pub enum Msg { ChangePage(model::Page), @@ -21,6 +24,8 @@ pub enum Msg { // project ProjectTextFilterChanged(String), + ProjectAvatarFilterChanged(UserId, AvatarFilterActive), + ProjectToggleOnlyMy, } fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) { diff --git a/jirs-client/src/model.rs b/jirs-client/src/model.rs index acd8d506..45299040 100644 --- a/jirs-client/src/model.rs +++ b/jirs-client/src/model.rs @@ -51,6 +51,8 @@ pub struct UpdateProjectForm { pub struct ProjectPage { pub about_tooltip_visible: bool, pub text_filter: String, + pub active_avatar_filters: Vec, + pub only_my_filter: bool, } #[derive(Serialize, Deserialize, Debug)] @@ -85,6 +87,8 @@ impl Default for Model { project_page: ProjectPage { about_tooltip_visible: false, text_filter: "".to_string(), + active_avatar_filters: vec![], + only_my_filter: false, }, } } diff --git a/jirs-client/src/project.rs b/jirs-client/src/project.rs index b7f38c74..e12abc00 100644 --- a/jirs-client/src/project.rs +++ b/jirs-client/src/project.rs @@ -1,9 +1,10 @@ use seed::{prelude::*, *}; use crate::model::{Icon, Model, Page}; +use crate::shared::styled_avatar::StyledAvatar; use crate::shared::styled_button::{StyledButton, Variant}; use crate::shared::styled_input::StyledInput; -use crate::shared::{host_client, inner_layout}; +use crate::shared::{host_client, inner_layout, ToNode}; use crate::Msg; pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders) { @@ -22,6 +23,23 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order Msg::ProjectTextFilterChanged(text) => { model.project_page.text_filter = text; } + Msg::ProjectAvatarFilterChanged(user_id, active) => match active { + true => { + model.project_page.active_avatar_filters = model + .project_page + .active_avatar_filters + .iter() + .filter(|id| **id != user_id) + .map(|id| *id) + .collect(); + } + false => { + model.project_page.active_avatar_filters.push(user_id); + } + }, + Msg::ProjectToggleOnlyMy => { + model.project_page.only_my_filter = !model.project_page.only_my_filter; + } _ => (), } } @@ -56,6 +74,7 @@ fn header() -> Node { active: false, text: Some("Github Repo".to_string()), icon: Some(Icon::Github), + on_click: None, } .into_node(); div![ @@ -68,7 +87,7 @@ fn header() -> Node { ] } -fn project_board_filters(_model: &Model) -> Node { +fn project_board_filters(model: &Model) -> Node { let search_input = StyledInput { icon: Some(Icon::Search), id: Some("searchInput".to_string()), @@ -76,5 +95,54 @@ fn project_board_filters(_model: &Model) -> Node { on_change: input_ev(Ev::Change, |value| Msg::ProjectTextFilterChanged(value)), } .into_node(); - div![id!["projectBoardFilters"], search_input] + + let only_my = StyledButton { + variant: Variant::Empty, + icon_only: false, + disabled: false, + active: model.project_page.only_my_filter, + text: Some("Only my".to_string()), + icon: None, + on_click: Some(mouse_ev(Ev::Click, |_| Msg::ProjectToggleOnlyMy)), + } + .into_node(); + + div![ + id!["projectBoardFilters"], + search_input, + avatars_filters(model), + only_my, + ] +} + +fn avatars_filters(model: &Model) -> Node { + let project = match model.project.as_ref() { + Some(p) => p, + _ => return empty![], + }; + let active_avatar_filters = &model.project_page.active_avatar_filters; + let avatars: Vec> = project + .users + .iter() + .map(|user| { + let mut class_list = vec!["avatarIsActiveBorder"]; + let user_id = user.id; + let active = active_avatar_filters.contains(&user_id); + if active { + class_list.push("isActive"); + } + let styled_avatar = StyledAvatar { + avatar_url: user.avatar_url.clone(), + size: 32, + name: user.name.clone(), + on_click: Some(mouse_ev(Ev::Click, move |_| { + Msg::ProjectAvatarFilterChanged(user_id, active) + })), + } + .into_node(); + div![attrs![At::Class => class_list.join(" ")], styled_avatar] + }) + .collect(); + + div![id!["avatars"], avatars] } diff --git a/jirs-client/src/shared/mod.rs b/jirs-client/src/shared/mod.rs index 8027d12c..ed0e63c3 100644 --- a/jirs-client/src/shared/mod.rs +++ b/jirs-client/src/shared/mod.rs @@ -8,10 +8,15 @@ use crate::Msg; pub mod aside; pub mod navbar_left; +pub mod styled_avatar; pub mod styled_button; pub mod styled_input; pub mod styled_tooltip; +pub trait ToNode { + fn into_node(self) -> Node; +} + pub fn styled_icon(icon: Icon) -> Node { i![attrs![At::Class => format!("styledIcon {}", icon)], ""] } diff --git a/jirs-client/src/shared/navbar_left.rs b/jirs-client/src/shared/navbar_left.rs index aeb14419..1b8b90ec 100644 --- a/jirs-client/src/shared/navbar_left.rs +++ b/jirs-client/src/shared/navbar_left.rs @@ -2,7 +2,7 @@ use seed::{prelude::*, *}; use crate::model::{Icon, Model}; use crate::shared::styled_button::{StyledButton, Variant}; -use crate::shared::styled_tooltip; +use crate::shared::{styled_tooltip, ToNode}; use crate::Msg; pub fn render(model: &Model) -> Vec> { @@ -43,6 +43,27 @@ pub fn about_tooltip(_model: &Model, children: Node) -> Node { } fn about_tooltip_popup(model: &Model) -> Node { + let visit_website = StyledButton { + text: Some("Visit Website".to_string()), + variant: Variant::Primary, + disabled: false, + active: false, + icon_only: false, + icon: None, + on_click: None, + } + .into_node(); + let github_repo = StyledButton { + text: Some("Github Repo".to_string()), + variant: Variant::Secondary, + disabled: false, + active: false, + icon_only: false, + icon: Some(Icon::Github), + on_click: None, + } + .into_node(); + styled_tooltip::StyledTooltip { visible: model.project_page.about_tooltip_visible, class_name: "aboutTooltipPopup".to_string(), @@ -71,14 +92,7 @@ fn about_tooltip_popup(model: &Model) -> Node { At::Target => "_blank", At::Rel => "noreferrer noopener", ], - StyledButton { - text: Some("Visit Website".to_string()), - variant: Variant::Primary, - disabled: false, - active: false, - icon_only: false, - icon: None, - }.into_node(), + visit_website, ], a![ id!["about-github-button"], @@ -87,14 +101,7 @@ fn about_tooltip_popup(model: &Model) -> Node { At::Target => "_blank", At::Rel => "noreferrer noopener", ], - StyledButton { - text: Some("Github Repo".to_string()), - variant: Variant::Secondary, - disabled: false, - active: false, - icon_only: false, - icon: Some(Icon::Github), - }.into_node() + github_repo ] ], }.into_node() diff --git a/jirs-client/src/shared/styled_avatar.rs b/jirs-client/src/shared/styled_avatar.rs new file mode 100644 index 00000000..5d004cca --- /dev/null +++ b/jirs-client/src/shared/styled_avatar.rs @@ -0,0 +1,52 @@ +use crate::shared::ToNode; +use crate::Msg; +use seed::{prelude::*, *}; + +pub struct StyledAvatar { + pub avatar_url: Option, + pub size: u32, + pub name: String, + pub on_click: Option>, +} + +impl Default for StyledAvatar { + fn default() -> Self { + Self { + avatar_url: None, + size: 32, + name: "".to_string(), + on_click: None, + } + } +} + +impl ToNode for StyledAvatar { + fn into_node(self) -> Node { + render(self) + } +} + +pub fn render(values: StyledAvatar) -> Node { + let StyledAvatar { + avatar_url, + size, + name, + on_click, + } = values; + let shared_style = format!("width: {size}px; height: {size}px", size = size); + let handler = match on_click { + None => vec![], + Some(h) => vec![h], + }; + match avatar_url { + Some(url) => div![ + attrs![At::Class => "styledAvatar image", At::Style => format!("{shared}; background-image: url({url});", shared = shared_style, url = url)], + handler, + ], + _ => div![ + attrs![At::Class => "styledAvatar letter", At::Style => shared_style], + span![name], + handler + ], + } +} diff --git a/jirs-client/src/shared/styled_button.rs b/jirs-client/src/shared/styled_button.rs index e9c23a85..7a67e4a3 100644 --- a/jirs-client/src/shared/styled_button.rs +++ b/jirs-client/src/shared/styled_button.rs @@ -1,7 +1,7 @@ use seed::{prelude::*, *}; use crate::model::Icon; -use crate::shared::styled_icon; +use crate::shared::{styled_icon, ToNode}; use crate::Msg; pub enum Variant { @@ -32,21 +32,16 @@ pub struct StyledButton { pub active: bool, pub text: Option, pub icon: Option, + pub on_click: Option>, } -impl Into> for StyledButton { - fn into(self) -> Node { - styled_button(self) +impl ToNode for StyledButton { + fn into_node(self) -> Node { + render(self) } } -impl StyledButton { - pub fn into_node(self) -> Node { - self.into() - } -} - -pub fn styled_button(values: StyledButton) -> Node { +pub fn render(values: StyledButton) -> Node { let StyledButton { text, variant, @@ -54,6 +49,7 @@ pub fn styled_button(values: StyledButton) -> Node { disabled, active, icon, + on_click, } = values; let mut class_list = vec!["styledButton".to_string(), variant.to_string()]; if icon_only { @@ -65,6 +61,10 @@ pub fn styled_button(values: StyledButton) -> Node { if icon.is_some() { class_list.push("withIcon".to_string()); } + let handler = match on_click { + Some(h) if !disabled => vec![h], + _ => vec![], + }; let icon_node = match icon { None => empty![], @@ -75,6 +75,7 @@ pub fn styled_button(values: StyledButton) -> Node { attrs![ At::Class => class_list.join(" "), ], + handler, match disabled { true => vec![attrs![At::Disabled => true]], false => vec![], diff --git a/jirs-client/src/shared/styled_input.rs b/jirs-client/src/shared/styled_input.rs index a78577c7..bb89e8bc 100644 --- a/jirs-client/src/shared/styled_input.rs +++ b/jirs-client/src/shared/styled_input.rs @@ -1,7 +1,7 @@ use seed::{prelude::*, *}; use crate::model::Icon; -use crate::shared::styled_icon; +use crate::shared::{styled_icon, ToNode}; use crate::Msg; pub struct StyledInput { @@ -11,18 +11,12 @@ pub struct StyledInput { pub on_change: EventHandler, } -impl Into> for StyledInput { - fn into(self) -> Node { +impl ToNode for StyledInput { + fn into_node(self) -> Node { render(self) } } -impl StyledInput { - pub fn into_node(self) -> Node { - self.into() - } -} - pub fn render(values: StyledInput) -> Node { let StyledInput { id, diff --git a/jirs-client/src/shared/styled_tooltip.rs b/jirs-client/src/shared/styled_tooltip.rs index 1e5b5119..71dbdf49 100644 --- a/jirs-client/src/shared/styled_tooltip.rs +++ b/jirs-client/src/shared/styled_tooltip.rs @@ -1,5 +1,6 @@ use seed::{prelude::*, *}; +use crate::shared::ToNode; use crate::Msg; pub struct StyledTooltip { @@ -8,19 +9,13 @@ pub struct StyledTooltip { pub children: Node, } -impl Into> for StyledTooltip { - fn into(self) -> Node { - styled_tooltip(self) +impl ToNode for StyledTooltip { + fn into_node(self) -> Node { + render(self) } } -impl StyledTooltip { - pub fn into_node(self) -> Node { - self.into() - } -} - -pub fn styled_tooltip(values: StyledTooltip) -> Node { +pub fn render(values: StyledTooltip) -> Node { let StyledTooltip { visible, class_name, diff --git a/migrations/.gitkeep b/migrations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 00000000..a9f52609 --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); +DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 00000000..d68895b1 --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/react-client/src/shared/components/Avatar/index.jsx b/react-client/src/shared/components/Avatar/index.jsx index a17b5bb5..5dc8325f 100644 --- a/react-client/src/shared/components/Avatar/index.jsx +++ b/react-client/src/shared/components/Avatar/index.jsx @@ -1,55 +1,51 @@ -import React from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { Image, Letter } from './Styles'; -const propTypes = { - className: PropTypes.string, - avatarUrl: PropTypes.string, - name: PropTypes.string, - size: PropTypes.number, -}; - -const defaultProps = { - className: undefined, - avatarUrl: null, - name: '', - size: 32, -}; - const Avatar = ({ className, avatarUrl, name, size, ...otherProps }) => { - const sharedProps = { - className, - size, - 'data-testid': name ? `avatar:${name}` : 'avatar', - ...otherProps, - }; + const sharedProps = { + className, + size, + 'data-testid': name ? `avatar:${name}` : 'avatar', + ...otherProps, + }; - if (avatarUrl) { - return ; - } + if (avatarUrl) { + return ; + } - return ( - - {name.charAt(0)} - - ); + return ( + + {name.charAt(0)} + + ); }; const colors = [ - '#DA7657', - '#6ADA57', - '#5784DA', - '#AA57DA', - '#DA5757', - '#DA5792', - '#57DACA', - '#57A5DA', + '#DA7657', + '#6ADA57', + '#5784DA', + '#AA57DA', + '#DA5757', + '#DA5792', + '#57DACA', + '#57A5DA', ]; const getColorFromName = name => colors[name.toLocaleLowerCase().charCodeAt(0) % colors.length]; -Avatar.propTypes = propTypes; -Avatar.defaultProps = defaultProps; +Avatar.propTypes = { + className: PropTypes.string, + avatarUrl: PropTypes.string, + name: PropTypes.string, + size: PropTypes.number, +}; +Avatar.defaultProps = { + className: undefined, + avatarUrl: null, + name: '', + size: 32, +}; export default Avatar;