diff --git a/ui/common/src/types/types.ts b/ui/common/src/types/types.ts index e2f63774..391eb9c5 100644 --- a/ui/common/src/types/types.ts +++ b/ui/common/src/types/types.ts @@ -36,6 +36,7 @@ export interface Item extends BaseItem { github_discussions_url?: string; graduated_at?: string; incubating_at?: string; + archived_at?: string; joined_at?: string; mailing_list_url?: string; package_manager_url?: string; diff --git a/ui/webapp/src/App.tsx b/ui/webapp/src/App.tsx index 2f76006e..3f2f8c7c 100644 --- a/ui/webapp/src/App.tsx +++ b/ui/webapp/src/App.tsx @@ -13,6 +13,7 @@ import { GAMES_PATH, GUIDE_PATH, LOGOS_PREVIEW_PATH, + PROJECTS_PATH, SCREENSHOTS_PATH, STATS_PATH, } from './data'; @@ -23,6 +24,7 @@ import Games from './layout/games'; import Guide from './layout/guide'; import Logos from './layout/logos'; import NotFound from './layout/notFound'; +import Projects from './layout/projects'; import Screenshots from './layout/screenshots'; import Stats from './layout/stats'; import { BaseData } from './types'; @@ -176,6 +178,7 @@ const App = () => { + } /> diff --git a/ui/webapp/src/data.ts b/ui/webapp/src/data.ts index 821b0569..7ec4db20 100644 --- a/ui/webapp/src/data.ts +++ b/ui/webapp/src/data.ts @@ -22,6 +22,7 @@ export const EMBED_SETUP_PATH = `${BASE_PATH}/embed-setup`; export const STATS_PATH = `${BASE_PATH}/stats`; export const GUIDE_PATH = `${BASE_PATH}/guide`; export const FINANCES_PATH = `${BASE_PATH}/finances`; +export const PROJECTS_PATH = `${BASE_PATH}/projects`; export const GAMES_PATH = `${BASE_PATH}/games`; export const LOGOS_PREVIEW_PATH = `${BASE_PATH}/logos-preview`; export const SCREENSHOTS_PATH = '/screenshot'; diff --git a/ui/webapp/src/layout/common/ActiveFiltersList.tsx b/ui/webapp/src/layout/common/ActiveFiltersList.tsx index 4176dc4f..d665b776 100644 --- a/ui/webapp/src/layout/common/ActiveFiltersList.tsx +++ b/ui/webapp/src/layout/common/ActiveFiltersList.tsx @@ -61,6 +61,9 @@ const FiltersPerCategoryList = (props: FiltersProps) => { Not {getFoundationNameLabel()} project + + <>{startCase(c)} + diff --git a/ui/webapp/src/layout/common/FiltersInLine.module.css b/ui/webapp/src/layout/common/FiltersInLine.module.css index 9d3f7284..1af27183 100644 --- a/ui/webapp/src/layout/common/FiltersInLine.module.css +++ b/ui/webapp/src/layout/common/FiltersInLine.module.css @@ -17,10 +17,10 @@ .btn { background-color: var(--body-bg); line-height: 1.5 !important; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); background-repeat: no-repeat; - background-position: right 0.3rem center; - background-size: 12px 8px; - background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3e%3cpath fill=%27none%27 stroke=%27%23343a40%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%272%27 d=%27m2 5 6 6 6-6%27/%3e%3c/svg%3e'); + background-position: right 0.75rem center; + background-size: 16px 12px; padding: 0.25rem 0.5rem; padding-right: 2.25rem; color: #212529 !important; @@ -47,20 +47,6 @@ min-width: 15px; } -[data-theme='dark'] .btn { - background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3e%3cpath fill=%27none%27 stroke=%27%23ccc%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%272%27 d=%27M2 5l6 6 6-6%27/%3e%3c/svg%3e'); - border-color: var(--solid-border) !important; - color: var(--color-font) !important; -} - -[data-theme='dark'] .dropdown { - background-color: var(--bg-color); -} - -[data-theme='dark'] .dropdown label { - color: var(--color-light-font) !important; -} - .options { max-height: 300px; } @@ -89,8 +75,4 @@ background-color: var(--body-bg); border-color: var(--clo-secondary) !important; } - - [data-theme='dark'] .btn:hover { - border-color: var(--solid-border) !important; - } } diff --git a/ui/webapp/src/layout/projects/MobileFilters.module.css b/ui/webapp/src/layout/projects/MobileFilters.module.css new file mode 100644 index 00000000..f46ac2e7 --- /dev/null +++ b/ui/webapp/src/layout/projects/MobileFilters.module.css @@ -0,0 +1,20 @@ +.filterBtn { + height: 28px; + font-size: 0.8rem; +} + +.dot { + height: 0.8rem; + width: 0.8rem; + left: 1.8rem; + top: -0.4rem; +} + +.checksList { + max-height: 300px; +} + +.title { + font-size: 0.8rem; + color: var(--color4); +} diff --git a/ui/webapp/src/layout/projects/MobileFilters.tsx b/ui/webapp/src/layout/projects/MobileFilters.tsx new file mode 100644 index 00000000..e4c0c67f --- /dev/null +++ b/ui/webapp/src/layout/projects/MobileFilters.tsx @@ -0,0 +1,95 @@ +import { SVGIcon, SVGIconKind } from 'common'; +import isEmpty from 'lodash/isEmpty'; +import { Accessor, createSignal, For, JSXElement, Show } from 'solid-js'; + +import { ActiveFilters, FilterCategory, FilterSection } from '../../types'; +import Section from '../common/Section'; +import { Sidebar } from '../common/Sidebar'; +import styles from './MobileFilters.module.css'; + +interface Props { + filters: FilterSection[]; + updateActiveFilters: (value: FilterCategory, options: string[]) => void; + resetFilters: () => void; + initialActiveFilters: Accessor; + children: JSXElement; +} + +const MobileFilters = (props: Props) => { + const [openSidebar, setOpenSidebar] = createSignal(false); + + return ( +
+
+ + +
+ +
+ +
Filters
+ + + +
+ } + visibleButton={false} + open={openSidebar()} + onOpenStatusChange={() => setOpenSidebar(false)} + > +
+
+
+ Order +
+ {props.children} +
+ +
+ + {(section: FilterSection) => { + return ( +
+ ); + }} + +
+
+ +
+ ); +}; + +export default MobileFilters; diff --git a/ui/webapp/src/layout/projects/Projects.module.css b/ui/webapp/src/layout/projects/Projects.module.css new file mode 100644 index 00000000..d418f6cd --- /dev/null +++ b/ui/webapp/src/layout/projects/Projects.module.css @@ -0,0 +1,95 @@ +.tableLayout { + table-layout: fixed; +} + +.thead { + font-size: 0.8rem; +} + +.thead th { + color: var(--bs-gray-600); +} + +.tableContent td { + font-size: 0.8rem; + line-height: 2; +} + +.projectNameCell { + width: 18%; +} + +.logoWrapper { + height: 20px; + width: 18px; + min-width: 18px; +} + +.logo { + font-size: 1rem; + max-width: 100%; + max-height: 100%; + height: auto; +} + +.title { + font-size: 0.9rem; +} + +.select { + border-color: #ced4da !important; + font-size: 0.75rem; + min-width: 175px; +} + +@media (max-width: 1199.98px) { + .select { + width: 150px; + } + + .thead { + font-size: 0.6rem; + } + + .tableContent td { + font-size: 0.65rem; + line-height: 2; + } +} + +@media (max-width: 991.98px) { + .projectNameCell { + width: 23%; + } + + .projectBtn { + font-size: 0.8rem; + } + + .maturity { + font-size: 90%; + } +} + +@media only screen and (max-width: 575.98px) { + .thead { + font-size: 0.5rem; + } + + .tableContent td { + font-size: 0.6rem; + line-height: 1.75; + } + + .projectNameCell { + width: 30%; + } + + .projectBtn { + font-size: 0.7rem; + } + + .maturity { + font-size: 80%; + } +} diff --git a/ui/webapp/src/layout/projects/index.tsx b/ui/webapp/src/layout/projects/index.tsx new file mode 100644 index 00000000..55ddc421 --- /dev/null +++ b/ui/webapp/src/layout/projects/index.tsx @@ -0,0 +1,463 @@ +import { useLocation, useNavigate } from '@solidjs/router'; +import { capitalizeFirstLetter, Image, Loading, NoData } from 'common'; +import { isEmpty, isNull, isUndefined, orderBy } from 'lodash'; +import { batch, createEffect, createSignal, For, JSXElement, on, onMount, Show } from 'solid-js'; + +import { SORT_BY_PARAM, SORT_DIRECTION_PARAM } from '../../data'; +import { ActiveFilters, FilterCategory, FilterSection, Item, SecurityAudit, SortDirection } from '../../types'; +import itemsDataGetter from '../../utils/itemsDataGetter'; +import ActiveFiltersList from '../common/ActiveFiltersList'; +import FiltersInLine from '../common/FiltersInLine'; +import Footer from '../navigation/Footer'; +import { useUpdateActiveItemId } from '../stores/activeItem'; +import { useFullDataReady } from '../stores/fullData'; +import MobileFilters from './MobileFilters'; +import styles from './Projects.module.css'; + +interface SortOption { + by: string; + direction: 'asc' | 'desc'; + label?: string; +} +const DEFAULT_SORT_BY = 'name'; +const DEFAULT_SORT_DIRECTION = 'asc'; + +const SORT_OPTIONS: SortOption[] = [ + { label: 'Accepted date (asc)', by: 'accepted_at', direction: 'asc' }, + { label: 'Accepted date (desc)', by: 'accepted_at', direction: 'desc' }, + { label: 'Archived date (asc)', by: 'archived_at', direction: 'asc' }, + { label: 'Archived date (desc)', by: 'archived_at', direction: 'desc' }, + { label: 'Graduated date (asc)', by: 'graduated_at', direction: 'asc' }, + { label: 'Graduated date (desc)', by: 'graduated_at', direction: 'desc' }, + { label: 'Incubating date (asc)', by: 'incubating_at', direction: 'asc' }, + { label: 'Incubating date (desc)', by: 'incubating_at', direction: 'desc' }, + { label: 'Last security audit date (asc)', by: 'last_audit', direction: 'asc' }, + { label: 'Last security audit date (desc)', by: 'last_audit', direction: 'desc' }, + { label: 'Name (asc)', by: 'name', direction: 'asc' }, + { label: 'Name (desc)', by: 'name', direction: 'desc' }, + { label: 'Sandbox date (asc)', by: 'sandbox_at', direction: 'asc' }, + { label: 'Sandbox date (desc)', by: 'sandbox_at', direction: 'desc' }, + { label: 'Security audits number (asc)', by: 'num_audits', direction: 'asc' }, + { label: 'Security audits number (desc)', by: 'num_audits', direction: 'desc' }, +]; + +const Projects = () => { + const navigate = useNavigate(); + const location = useLocation(); + const fullDataReady = useFullDataReady(); + const updateActiveItemId = useUpdateActiveItemId(); + const [filters, setFilters] = createSignal([]); + const [projects, setProjects] = createSignal(); + const [visibleProjects, setVisibleProjects] = createSignal(); + const [activeFilters, setActiveFilters] = createSignal({}); + const [selectedSortOption, setSelectedSortOption] = createSignal({ + by: DEFAULT_SORT_BY, + direction: DEFAULT_SORT_DIRECTION, + }); + + const prepareQuery = () => { + if (location.search !== '') { + const currentFilters: ActiveFilters = {}; + const params = new URLSearchParams(location.search); + for (const [key, value] of params) { + const f = key as FilterCategory; + if (Object.values(FilterCategory).includes(f)) { + if (currentFilters[f]) { + currentFilters[f] = [...currentFilters[f]!, value]; + } else { + currentFilters[f] = [value]; + } + } + } + + batch(() => { + setActiveFilters(currentFilters); + setSelectedSortOption({ + by: params.get(SORT_BY_PARAM) || DEFAULT_SORT_BY, + direction: !isNull(params.get(SORT_DIRECTION_PARAM)) + ? (params.get(SORT_DIRECTION_PARAM) as 'asc') || 'desc' + : DEFAULT_SORT_DIRECTION, + }); + }); + } + }; + + const sortItems = (items: Item[]) => { + const direction = selectedSortOption().direction; + + const getValue = (date: string | undefined): number => { + if (date) { + return new Date(date).valueOf(); + } else { + return direction === SortDirection.Asc ? Number.MAX_SAFE_INTEGER : Number.MIN_SAFE_INTEGER; + } + }; + + switch (selectedSortOption().by) { + case 'name': + return orderBy(items, [(i: Item) => i.name.toLowerCase()], [direction]); + + case 'sandbox_at': + return orderBy( + items, + [ + (i: Item) => + getValue( + i.accepted_at !== i.incubating_at && i.accepted_at !== i.graduated_at ? i.accepted_at : undefined + ), + ], + [] + ); + + case 'num_audits': + return orderBy(items, [(i: Item) => (i.audits ? i.audits.length : 0)], [direction]); + + case 'last_audit': + return orderBy(items, [(i: Item) => getValue(getLastAuditDate(i.audits))], [direction]); + + default: + // eslint-disable-next-line solid/reactivity + return orderBy(items, [(i: Item) => getValue(i[selectedSortOption().by as keyof Item] as string)], [direction]); + } + }; + + const fetchProjects = () => { + const itemsData = itemsDataGetter.getItemsWithMaturity(); + const maturityOptions = itemsDataGetter.getMaturityOptions(); + + if (itemsData) { + const sortedItems = sortItems(itemsData); + batch(() => { + if (maturityOptions.length > 1) { + setFilters([ + { + value: FilterCategory.ProjectMaturity, + title: 'Maturity', + options: maturityOptions.map((opt: string) => ({ + value: opt, + name: capitalizeFirstLetter(opt), + })), + }, + ]); + } + }); + setProjects(sortedItems); + setVisibleProjects(sortedItems); + } else { + batch(() => { + setProjects([]); + setVisibleProjects([]); + }); + } + }; + + const getLastAuditDate = (audits?: SecurityAudit[]): string | undefined => { + if (audits) { + if (audits.length > 0) { + const sortedAudits = audits.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + return sortedAudits[0].date; + } else { + return audits[0].date; + } + } + return undefined; + }; + + const applyFilters = (newFilters: ActiveFilters) => { + setActiveFilters(newFilters); + filterData(); + updateFiltersQueryString(newFilters); + }; + + const filterData = () => { + let filteredProjects = projects(); + Object.keys(activeFilters()).forEach((filterId: string) => { + const filter = activeFilters()[filterId as FilterCategory]; + if (filter) { + filteredProjects = filteredProjects!.filter((project: Item) => { + return filter.includes(project.maturity!); + }); + } + }); + + if (filteredProjects) { + setVisibleProjects(sortItems(filteredProjects!)); + } + }; + + const updateActiveFilters = (value: FilterCategory, options: string[]) => { + const f: ActiveFilters = { ...activeFilters() }; + if (options.length === 0) { + delete f[value]; + } else { + f[value] = options; + } + applyFilters(f); + }; + + const removeFilter = (name: FilterCategory, value: string) => { + const tmpActiveFilters: string[] = ({ ...activeFilters() }[name] || []).filter((f: string) => f !== value); + updateActiveFilters(name, tmpActiveFilters); + }; + + const updateFiltersQueryString = (filters: ActiveFilters) => { + const f = filters || activeFilters(); + const params = new URLSearchParams(); + if (!isUndefined(f) && !isEmpty(f)) { + Object.keys(f).forEach((filterId: string) => { + return f![filterId as FilterCategory]!.forEach((id: string) => { + params.append(filterId as string, id.toString()); + }); + }); + } + + const query = params.toString(); + + navigate( + `${location.pathname}?${SORT_BY_PARAM}=${ + selectedSortOption().by + }&${SORT_DIRECTION_PARAM}=${selectedSortOption().direction}${query !== '' ? `&${query}` : ''}`, + { + replace: true, + scroll: true, // default + } + ); + }; + + const updateSortQueryString = (sort: SortOption) => { + const updatedSearchParams = new URLSearchParams(location.search); + updatedSearchParams.set(SORT_BY_PARAM, sort.by); + updatedSearchParams.set(SORT_DIRECTION_PARAM, sort.direction); + + navigate(`${location.pathname}?${updatedSearchParams.toString()}`, { + replace: true, + scroll: true, // default + }); + }; + + const resetFilters = () => { + batch(() => { + setActiveFilters({}); + setVisibleProjects(sortItems(projects()!)); + }); + }; + + const orderSelect = () => ( + + ); + + createEffect( + on(fullDataReady, () => { + if (fullDataReady()) { + fetchProjects(); + } + }) + ); + + createEffect( + on(selectedSortOption, () => { + if (visibleProjects()) { + setVisibleProjects(sortItems(visibleProjects()!)); + } + }) + ); + + const formatDatesForDevices = (date: string): JSXElement => { + const shortDate = date.split('-'); + shortDate.pop(); + return ( + <> + {date} + {shortDate.join('-')} + + ); + }; + + onMount(() => { + prepareQuery(); + }); + + return ( + }> +
+
+ +
+
+ + {orderSelect()} + +
+ +
+ +
+ +
+
+
Order
+
+ + {orderSelect()} +
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + 0} + fallback={ + + + + } + > + + {(project: Item) => { + return ( + + + + + + + + + + + + ); + }} + + + +
+ Project + + Maturity + + Accepted + + Sandbox + + Incubating + + Graduated + + Archived + + Audits + + Last audit +
+
+ +
No projects found.
+
+
+
+ + + {project.maturity} + + {formatDatesForDevices(project.accepted_at!)} + + + {project.accepted_at === project.incubating_at || + project.accepted_at === project.graduated_at ? ( + '-' + ) : ( + <>{formatDatesForDevices(project.accepted_at!)} + )} + + + + {formatDatesForDevices(project.incubating_at!)} + + + + {formatDatesForDevices(project.graduated_at!)} + + + + {formatDatesForDevices(project.archived_at!)} + + + {project.audits ? project.audits.length : 0} + + + {formatDatesForDevices(getLastAuditDate(project.audits)!)} + +
+
+