@blocklet/list 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import styled from 'styled-components';
4
+ import Empty from '@arcblock/ux/lib/Empty';
5
+ import Alert from '@arcblock/ux/lib/Alert';
6
+ import Box from '@mui/material/Box';
7
+ import Grid from '@mui/material/Grid';
8
+ import CircularProgress from '@mui/material/CircularProgress';
9
+
10
+ import { NoResults, EmptyTitle, NoResultsTips } from './empty';
11
+ import { useSearchContext } from '../contexts/store';
12
+ import { formatError } from '../tools/utils';
13
+
14
+ export default function BlockletList({ blocklets, ...rest }) {
15
+ const { blockletRender, errors, loadings, selectedCategory, blockletList, getCategoryLocale, queryParams, t } =
16
+ useSearchContext();
17
+
18
+ const showFilterTip = !!selectedCategory || !!queryParams.price;
19
+
20
+ if (errors.fetchBlockletsError) {
21
+ return (
22
+ <Alert type="error" variant="icon">
23
+ <div>{formatError(errors.fetchBlockletsError)}</div>
24
+ </Alert>
25
+ );
26
+ }
27
+ if (loadings.fetchBlockletsLoading) {
28
+ return (
29
+ <Box display="flex" alignItems="center" justifyContent="center">
30
+ <CircularProgress />
31
+ </Box>
32
+ );
33
+ }
34
+ if (queryParams.search && showFilterTip && blockletList.length === 0) {
35
+ return (
36
+ <CustomEmpty>
37
+ <EmptyTitle
38
+ primaryStart={t('blocklet.noBlockletPart1')}
39
+ primaryEnd={t('blocklet.noBlockletPart2')}
40
+ search={queryParams.search}
41
+ />
42
+ <NoResultsTips keywordTip filterTip />
43
+ </CustomEmpty>
44
+ );
45
+ }
46
+ if (queryParams.search && blockletList.length === 0) {
47
+ return (
48
+ <CustomEmpty>
49
+ <EmptyTitle
50
+ primaryStart={t('blocklet.noBlockletPart1')}
51
+ primaryEnd={t('blocklet.noBlockletPart2')}
52
+ search={queryParams.search}
53
+ />
54
+ <NoResultsTips keywordTip />
55
+ </CustomEmpty>
56
+ );
57
+ }
58
+ if (showFilterTip && blockletList.length === 0) {
59
+ const categoryLocale = getCategoryLocale(selectedCategory);
60
+ return (
61
+ <CustomEmpty>
62
+ {categoryLocale ? (
63
+ <EmptyTitle
64
+ primaryStart={t('blocklet.noCategoryResults1')}
65
+ primaryEnd={t('blocklet.noCategoryResults2')}
66
+ search={categoryLocale}
67
+ />
68
+ ) : (
69
+ <NoResults />
70
+ )}
71
+ <NoResultsTips filterTip />
72
+ </CustomEmpty>
73
+ );
74
+ }
75
+ if (blockletList.length === 0) {
76
+ return (
77
+ <CustomEmpty>
78
+ <NoResults />
79
+ </CustomEmpty>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <Grid container spacing={4} {...rest}>
85
+ {blocklets.map((blocklet) => (
86
+ <StyledGrid item lg={4} md={6} sm={6} xs={12} key={blocklet.did} data-blocklet-did={blocklet.did}>
87
+ {blockletRender(blocklet)}
88
+ </StyledGrid>
89
+ ))}
90
+ </Grid>
91
+ );
92
+ }
93
+
94
+ BlockletList.propTypes = {
95
+ blocklets: PropTypes.array.isRequired,
96
+ };
97
+
98
+ BlockletList.defaultProps = {};
99
+
100
+ const StyledGrid = styled(Grid)`
101
+ @media (max-width: ${(props) => props.theme.breakpoints.values.sm}px) {
102
+ &.MuiGrid-item {
103
+ padding-bottom: 0px;
104
+ padding-left: 16px;
105
+ padding-right: 4px;
106
+ }
107
+ }
108
+ `;
109
+ const CustomEmpty = styled(Empty)`
110
+ text-align: center;
111
+ .primary {
112
+ color: ${(props) => props.theme.palette.primary.main};
113
+ }
114
+ .tips {
115
+ margin-top: ${(props) => props.theme.spacing(1)};
116
+ }
117
+ `;
@@ -0,0 +1,93 @@
1
+ import React, { useState } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { Magnify, Close } from 'mdi-material-ui';
4
+ import { OutlinedInput, InputAdornment } from '@mui/material';
5
+ import { useDebounceFn } from 'ahooks';
6
+ import styled from 'styled-components';
7
+
8
+ import { useSearchContext } from '../contexts/store';
9
+
10
+ const Search = ({ placeholder, ...rest }) => {
11
+ const searchStore = useSearchContext();
12
+ const { queryParams, handleSearchKeyword } = searchStore;
13
+ const [searchStr, setSearchStr] = useState(queryParams.search || '');
14
+
15
+ const { run: handleSearch } = useDebounceFn(handleSearchKeyword, { wait: 300 });
16
+ const handleChange = (event) => {
17
+ const { value } = event.target;
18
+ setSearchStr(value);
19
+ handleSearch(value);
20
+ };
21
+ const handleClose = () => {
22
+ setSearchStr('');
23
+ handleSearchKeyword();
24
+ };
25
+ return (
26
+ <StyledSearch
27
+ startAdornment={
28
+ <InputAdornment position="start">
29
+ <StyledMagnify />
30
+ </InputAdornment>
31
+ }
32
+ onChange={handleChange}
33
+ placeholder={placeholder}
34
+ value={searchStr}
35
+ title={placeholder}
36
+ endAdornment={
37
+ searchStr && (
38
+ <InputAdornment onClick={handleClose} position="end">
39
+ <StyledClose />
40
+ </InputAdornment>
41
+ )
42
+ }
43
+ {...rest}
44
+ />
45
+ );
46
+ };
47
+ Search.propTypes = {
48
+ placeholder: PropTypes.string,
49
+ };
50
+ Search.defaultProps = {
51
+ placeholder: 'Type to search...',
52
+ };
53
+ const StyledSearch = styled(OutlinedInput)`
54
+ background-color: #fff;
55
+ border-radius: 12px;
56
+ font-size: 16px;
57
+ margin: 0 10px;
58
+ .MuiInputBase-input {
59
+ padding: 12px 0px 12px 0px;
60
+ }
61
+ .MuiOutlinedInput-notchedOutline {
62
+ border: none;
63
+ }
64
+ .Mui-focused {
65
+ background-color: #f6f6f6;
66
+ .MuiInputBase-input::placeholder {
67
+ color: transparent;
68
+ }
69
+ }
70
+ @media (max-width: ${(props) => props.theme.breakpoints.values.md}px) {
71
+ font-size: 14px;
72
+ border-radius: 6px;
73
+ .MuiInputBase-input {
74
+ padding: 8px 0 8px 10px;
75
+ }
76
+ }
77
+ `;
78
+
79
+ const StyledMagnify = styled(Magnify)`
80
+ color: ${(props) => props.theme.palette.grey[500]};
81
+ font-size: 28px;
82
+ @media (max-width: ${(props) => props.theme.breakpoints.values.md}px) {
83
+ font-size: 24px;
84
+ }
85
+ `;
86
+
87
+ const StyledClose = styled(Close)`
88
+ color: ${(props) => props.theme.palette.grey[500]};
89
+ font-size: 16px;
90
+ cursor: pointer;
91
+ `;
92
+
93
+ export default Search;
@@ -0,0 +1,250 @@
1
+ import React, { useContext, createContext, useMemo, useEffect, useState } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import qs from 'qs';
4
+ import { useParams, useLocation, useHistory } from 'react-router-dom';
5
+ import { useRequest } from 'ahooks';
6
+ import orderBy from 'lodash-es/orderBy';
7
+ import axios from 'axios';
8
+ import joinUrl from 'url-join';
9
+
10
+ import usePageState from '../hooks/page-state';
11
+ import { getCategories, filterBlockletByPrice, replace } from '../tools/utils';
12
+ import translations from '../assets/locale';
13
+
14
+ const axiosInstance = axios.create();
15
+ const Search = createContext({});
16
+ const { Provider, Consumer } = Search;
17
+
18
+ function SearchProvider({ children, baseUrl, type, endpoint, locale, blockletRender }) {
19
+ const location = useLocation();
20
+ const history = useHistory();
21
+ const pathParams = useParams();
22
+ const isPageMode = type === 'page';
23
+ if (isPageMode && !baseUrl) {
24
+ throw new Error('baseUrl is required when type is page');
25
+ }
26
+ const {
27
+ data: allBlocklets,
28
+ error: fetchBlockletsError,
29
+ loading: fetchBlockletsLoading,
30
+ } = useRequest(
31
+ async () => {
32
+ const { data: list } = await axiosInstance.get(joinUrl(endpoint, '/api/blocklets.json'));
33
+ return list;
34
+ },
35
+ { initialData: [] }
36
+ );
37
+
38
+ const {
39
+ data: allCategories,
40
+ error: fetchCategoriesError,
41
+ loading: fetchCategoriesLoading,
42
+ run: fetchCategories,
43
+ } = useRequest(
44
+ async () => {
45
+ const { data: list } = await axiosInstance.get(`${joinUrl(endpoint, '/api/blocklets/categories')}`);
46
+ return list;
47
+ },
48
+ { initialData: [], manual: true }
49
+ );
50
+
51
+ const [memoryParams, setMemoryParams] = useState({
52
+ sortBy: 'popularity',
53
+ sortDirection: 'desc',
54
+ });
55
+
56
+ const queryParams = useMemo(() => {
57
+ return isPageMode ? qs.parse(location.search, { ignoreQueryPrefix: true }) : memoryParams;
58
+ }, [memoryParams, location.search]);
59
+
60
+ let sortParams;
61
+ // 当作页面使用时 sort 数据比较特殊, 默认取 localStorge 中的值,如果 url query 中有sort值则优先使用
62
+ if (isPageMode) {
63
+ const localSortParams = usePageState(
64
+ {
65
+ sort: 'popularity',
66
+ direction: 'desc',
67
+ },
68
+ baseUrl
69
+ );
70
+ const urlSortParams = {
71
+ sort: queryParams.sortBy,
72
+ direction: queryParams.sortDirection,
73
+ };
74
+ sortParams = urlSortParams.sortBy && urlSortParams.sortDirection ? urlSortParams : localSortParams;
75
+ } else {
76
+ sortParams = useMemo(() => {
77
+ return {
78
+ sort: memoryParams.sortBy,
79
+ direction: memoryParams.sortDirection,
80
+ };
81
+ }, [memoryParams]);
82
+ }
83
+
84
+ const isSearchPage = location.pathname === '/search';
85
+ const selectedCategory = useMemo(() => {
86
+ let result = null;
87
+ if (isPageMode) {
88
+ result = !isSearchPage ? pathParams.category : queryParams.category;
89
+ } else {
90
+ result = queryParams.category;
91
+ }
92
+ return result;
93
+ }, [isPageMode, pathParams, queryParams]);
94
+
95
+ const hasDeveloperFilter = !!queryParams.developer;
96
+ const categoryState = !hasDeveloperFilter
97
+ ? { data: allCategories }
98
+ : getCategories(allBlocklets, queryParams.developer);
99
+
100
+ const blockletList = useMemo(() => {
101
+ const sortByName = (x) => x?.title?.toLocaleLowerCase() || x?.name?.toLocaleLowerCase(); // 按名称排序
102
+ const sortByPopularity = (x) => x.stats.downloads; // 按下载量排序
103
+ const sortByPublish = (x) => x.lastPublishedAt; // 按发布时间
104
+ const sortMap = {
105
+ nameAsc: sortByName,
106
+ nameDesc: sortByName,
107
+ popularity: sortByPopularity,
108
+ publishAt: sortByPublish,
109
+ };
110
+
111
+ let result = allBlocklets || [];
112
+ // 按照付费/免费筛选
113
+ result = filterBlockletByPrice(result, queryParams.price);
114
+ // 按照分类筛选
115
+ result = result.filter((item) => (selectedCategory ? item?.category?.name === selectedCategory : true));
116
+ // 按照作者筛选
117
+ result = result.filter((item) => (queryParams?.developer ? item.owner.did === queryParams.developer : true));
118
+ const lowerSearch = queryParams?.search?.toLocaleLowerCase() || '';
119
+ // 按照搜索筛选
120
+ result = result.filter((item) => {
121
+ return (
122
+ (item?.title || item?.name)?.toLocaleLowerCase().includes(lowerSearch) ||
123
+ item.description?.toLocaleLowerCase().includes(lowerSearch) ||
124
+ item?.version?.toLocaleLowerCase().includes(lowerSearch)
125
+ );
126
+ });
127
+ // 排序
128
+ return orderBy(result, [sortMap[sortParams.sort]], [sortParams.direction]);
129
+ }, [allBlocklets, queryParams, sortParams]);
130
+
131
+ const categoryList = useMemo(() => {
132
+ const list = categoryState.data || [];
133
+ // 分类按照名称排序
134
+ return orderBy(list, [(i) => i.name], ['asc']);
135
+ }, [categoryState.data]);
136
+
137
+ const translate = (key, data) => {
138
+ if (!translations[locale] || !translations[locale][key]) {
139
+ console.warn(`Warning: no ${key} translation of ${locale}`);
140
+ return key;
141
+ }
142
+
143
+ return replace(translations[locale][key], data);
144
+ };
145
+
146
+ const searchStore = {
147
+ errors: { fetchBlockletsError, fetchCategoriesError },
148
+ loadings: { fetchBlockletsLoading, fetchCategoriesLoading },
149
+ endpoint,
150
+ sortParams,
151
+ history,
152
+ blockletList,
153
+ t: translate,
154
+ queryParams,
155
+ selectedCategory,
156
+ isSearchPage,
157
+ categoryList,
158
+ isPageMode,
159
+ baseUrl,
160
+ blockletRender,
161
+ locale,
162
+ handleSort: (value) => {
163
+ const changData = { ...queryParams, sortBy: value, sortDirection: value === 'nameAsc' ? 'asc' : 'desc' };
164
+ if (isPageMode) {
165
+ sortParams.sort = changData.sortBy;
166
+ sortParams.direction = changData.sortDirection;
167
+ history.push(`${baseUrl}search?${qs.stringify(changData)}`);
168
+ } else {
169
+ setMemoryParams(changData);
170
+ }
171
+ },
172
+ handleSearchKeyword: (value) => {
173
+ const changData = { ...queryParams, search: value || undefined };
174
+ if (isPageMode) {
175
+ history.push(`${baseUrl}search?${qs.stringify(changData)}`);
176
+ } else {
177
+ setMemoryParams(changData);
178
+ }
179
+ },
180
+ handlePriceFilter: (value) => {
181
+ const changData = { ...queryParams, price: value === queryParams.price ? undefined : value };
182
+ if (isPageMode) {
183
+ history.push(`${baseUrl}search?${qs.stringify(changData)}`);
184
+ } else {
185
+ setMemoryParams(changData);
186
+ }
187
+ },
188
+ getCategoryLocale: (name) => {
189
+ if (!name) return null;
190
+ let result = null;
191
+ const find = categoryState.data.find((item) => item.name === name);
192
+ if (find) {
193
+ result = find.locales[locale];
194
+ }
195
+ return result;
196
+ },
197
+ handleCategorySelect: (value) => {
198
+ if (value === 'all') {
199
+ const changData = { ...queryParams, category: undefined };
200
+ if (isPageMode) {
201
+ history.push(!isSearchPage ? baseUrl : `${baseUrl}search?${qs.stringify(changData)}`);
202
+ } else {
203
+ setMemoryParams(changData);
204
+ }
205
+ } else {
206
+ const changData = { ...queryParams, category: value };
207
+ if (isPageMode) {
208
+ history.push(!isSearchPage ? `${baseUrl}category/${value}` : `${baseUrl}search?${qs.stringify(changData)}`);
209
+ } else {
210
+ setMemoryParams(changData);
211
+ }
212
+ }
213
+ },
214
+ get developerName() {
215
+ return allBlocklets.find((i) => i.owner.did === queryParams.developer)?.owner?.name || '';
216
+ },
217
+ };
218
+
219
+ useEffect(() => {
220
+ if (!hasDeveloperFilter) {
221
+ fetchCategories();
222
+ }
223
+ }, [!hasDeveloperFilter]);
224
+ return <Provider value={searchStore}>{children}</Provider>;
225
+ }
226
+
227
+ SearchProvider.propTypes = {
228
+ children: PropTypes.any.isRequired,
229
+ baseUrl: PropTypes.string,
230
+ endpoint: PropTypes.string.isRequired,
231
+ // 组件的类型: page 单独作为页面使用 持久化数据将存储在 url 和 localstorage,select 作为选择器组件使用 数据在存储在内存中
232
+ type: PropTypes.oneOf(['select', 'page']).isRequired,
233
+ locale: PropTypes.oneOf(['zh', 'en']),
234
+ blockletRender: PropTypes.func.isRequired,
235
+ };
236
+
237
+ SearchProvider.defaultProps = {
238
+ baseUrl: null,
239
+ locale: 'zh',
240
+ };
241
+
242
+ function useSearchContext() {
243
+ const searchStore = useContext(Search);
244
+ if (!searchStore) {
245
+ return {};
246
+ }
247
+ return searchStore;
248
+ }
249
+
250
+ export { SearchProvider, Consumer as SearchConsumer, useSearchContext };
@@ -0,0 +1,53 @@
1
+ import { useLocalStorageState, useReactive } from 'ahooks';
2
+
3
+ const PAGE_STATE_KEY = 'page-state';
4
+
5
+ export default function usePageState(defaultValue = {}, persistence = true, path = window.location.pathname) {
6
+ const [pageState, setPageState] = useLocalStorageState(PAGE_STATE_KEY, {});
7
+ const state = useReactive(pageState);
8
+ if (!(path in pageState) && persistence) {
9
+ state[path] = defaultValue;
10
+ syncState();
11
+ }
12
+ function syncState() {
13
+ setPageState(state);
14
+ }
15
+
16
+ return new Proxy(
17
+ {},
18
+ {
19
+ get: (target, prop) => {
20
+ try {
21
+ return state[path][prop];
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ },
26
+ set: (target, prop, value) => {
27
+ try {
28
+ const data = {
29
+ ...(state[path] || {}),
30
+ [prop]: value,
31
+ };
32
+ state[path] = data;
33
+ syncState();
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ },
39
+ deleteProperty: (target, prop) => {
40
+ try {
41
+ const data = { ...(state[path] || {}) };
42
+ delete data[prop];
43
+ delete state[path][prop];
44
+ syncState();
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ },
50
+ ownKeys: () => Object.keys(state[path] || {}),
51
+ }
52
+ );
53
+ }
package/src/index.js ADDED
@@ -0,0 +1,27 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+
4
+ import SelectBase from './base';
5
+ import { SearchProvider } from './contexts/store';
6
+
7
+ export default function BlockletList(props) {
8
+ return (
9
+ <SearchProvider {...props}>
10
+ <SelectBase />
11
+ </SearchProvider>
12
+ );
13
+ }
14
+
15
+ BlockletList.propTypes = {
16
+ baseUrl: PropTypes.string,
17
+ endpoint: PropTypes.string.isRequired,
18
+ // 组件的类型: page 单独作为页面使用 持久化数据将存储在 url 和 localstorage,select 作为选择器组件使用 数据在存储在内存中
19
+ type: PropTypes.oneOf(['select', 'page']).isRequired,
20
+ locale: PropTypes.oneOf(['zh', 'en']),
21
+ blockletRender: PropTypes.func.isRequired,
22
+ };
23
+
24
+ BlockletList.defaultProps = {
25
+ baseUrl: null,
26
+ locale: 'zh',
27
+ };
@@ -0,0 +1,97 @@
1
+ import joinURL from 'url-join';
2
+
3
+ const isFreeBlocklet = (meta) => {
4
+ if (!meta.payment) {
5
+ return true;
6
+ }
7
+
8
+ const priceList = (meta.payment.price || []).map((x) => x.value || 0);
9
+ return priceList.every((x) => x === 0);
10
+ };
11
+ const getSortOptions = (t) => {
12
+ return [
13
+ {
14
+ name: t('sort.popularity'),
15
+ value: 'popularity',
16
+ },
17
+ {
18
+ name: t('sort.lastPublished'),
19
+ value: 'publishAt',
20
+ },
21
+ {
22
+ name: t('sort.nameAscend'),
23
+ value: 'nameAsc',
24
+ },
25
+ {
26
+ name: t('sort.nameDescend'),
27
+ value: 'nameDesc',
28
+ },
29
+ ];
30
+ };
31
+ const getPrices = (t) => {
32
+ return [
33
+ { name: t('blocklet.free'), value: 'free' },
34
+ { name: t('blocklet.payment'), value: 'payment' },
35
+ ];
36
+ };
37
+ /**
38
+ * 从开发者所属 blocklets 中的得到 Categories
39
+ * @param {*} list
40
+ * @param {*} developerDid
41
+ * @returns
42
+ */
43
+ const getCategories = (list, developerDid) => {
44
+ const filterList = list.filter((item) => (developerDid ? item.owner.did === developerDid : true));
45
+ const Categories = filterList.map((item) => item.category);
46
+ const res = new Map();
47
+ const result = Categories.filter((i) => !!i).filter((a) => !res.has(a._id) && res.set(a._id, 1));
48
+ return { data: result };
49
+ };
50
+ /**
51
+ * 根据 是否付费 过滤 blocklet list
52
+ * @param {*} list
53
+ * @param {*} price
54
+ * @returns
55
+ */
56
+ const filterBlockletByPrice = (list = [], price = '') => {
57
+ let result = list;
58
+ if (!price) return result;
59
+ if (price === 'free') {
60
+ result = list.filter((blocklet) => isFreeBlocklet(blocklet));
61
+ } else {
62
+ result = list.filter((blocklet) => !isFreeBlocklet(blocklet));
63
+ }
64
+ return result;
65
+ };
66
+
67
+ const formatError = (error) => {
68
+ if (Array.isArray(error.errors)) {
69
+ return error.errors.map((x) => x.message).join('\n');
70
+ }
71
+
72
+ return error.message;
73
+ };
74
+
75
+ const getStoreDetail = (storeUrl, blocklet) => {
76
+ return joinURL(storeUrl, `/blocklets/${blocklet.did}`);
77
+ };
78
+ const formatLogoPath = (did, asset, target = 'assets') => {
79
+ if (asset.startsWith(target)) {
80
+ return asset;
81
+ }
82
+ return `${target}/${did}/${asset}`;
83
+ };
84
+ const replace = (template, data) =>
85
+ // eslint-disable-next-line no-prototype-builtins
86
+ template.replace(/{(\w*)}/g, (m, key) => (data.hasOwnProperty(key) ? data[key] : ''));
87
+
88
+ export {
89
+ getSortOptions,
90
+ getPrices,
91
+ getCategories,
92
+ filterBlockletByPrice,
93
+ getStoreDetail,
94
+ formatLogoPath,
95
+ replace,
96
+ formatError,
97
+ };