openvsx-webui-test 0.20.0-dev.4 → 0.20.1-rc.0

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.
Files changed (77) hide show
  1. package/lib/components/scan-admin/scan-card/scan-card-expanded-content.d.ts +2 -1
  2. package/lib/components/scan-admin/scan-card/scan-card-expanded-content.d.ts.map +1 -1
  3. package/lib/components/scan-admin/scan-card/scan-card-expanded-content.js.map +1 -1
  4. package/lib/components/scan-admin/scan-card/scan-card-header.js +1 -1
  5. package/lib/components/scan-admin/scan-card/scan-card-header.js.map +1 -1
  6. package/lib/components/scan-admin/scan-card/utils.js +1 -1
  7. package/lib/components/scan-admin/scan-card/utils.js.map +1 -1
  8. package/lib/default/menu-content.d.ts +1 -1
  9. package/lib/default/menu-content.js +1 -1
  10. package/lib/default/menu-content.js.map +1 -1
  11. package/lib/main.d.ts.map +1 -1
  12. package/lib/main.js +5 -5
  13. package/lib/main.js.map +1 -1
  14. package/lib/other-pages.d.ts.map +1 -1
  15. package/lib/other-pages.js +7 -7
  16. package/lib/other-pages.js.map +1 -1
  17. package/lib/pages/admin-dashboard/{admin-routes.d.ts → admin-dashboard-routes.d.ts} +6 -9
  18. package/lib/pages/admin-dashboard/admin-dashboard-routes.d.ts.map +1 -0
  19. package/lib/pages/admin-dashboard/{admin-routes.js → admin-dashboard-routes.js} +6 -9
  20. package/lib/pages/admin-dashboard/admin-dashboard-routes.js.map +1 -0
  21. package/lib/pages/admin-dashboard/admin-dashboard.d.ts.map +1 -1
  22. package/lib/pages/admin-dashboard/admin-dashboard.js +9 -9
  23. package/lib/pages/admin-dashboard/admin-dashboard.js.map +1 -1
  24. package/lib/pages/admin-dashboard/customers/customer-member-list.js +1 -1
  25. package/lib/pages/admin-dashboard/customers/customer-member-list.js.map +1 -1
  26. package/lib/pages/admin-dashboard/customers/customers.js +1 -1
  27. package/lib/pages/admin-dashboard/customers/customers.js.map +1 -1
  28. package/lib/pages/admin-dashboard/publisher-admin.js +1 -1
  29. package/lib/pages/admin-dashboard/publisher-admin.js.map +1 -1
  30. package/lib/pages/admin-dashboard/usage-stats/usage-stats.js +1 -1
  31. package/lib/pages/admin-dashboard/usage-stats/usage-stats.js.map +1 -1
  32. package/lib/pages/admin-dashboard/welcome.js +1 -1
  33. package/lib/pages/admin-dashboard/welcome.js.map +1 -1
  34. package/lib/pages/extension-detail/extension-detail-changes.d.ts.map +1 -1
  35. package/lib/pages/extension-detail/extension-detail-changes.js +1 -4
  36. package/lib/pages/extension-detail/extension-detail-changes.js.map +1 -1
  37. package/lib/pages/extension-detail/extension-detail-overview.d.ts.map +1 -1
  38. package/lib/pages/extension-detail/extension-detail-overview.js +1 -6
  39. package/lib/pages/extension-detail/extension-detail-overview.js.map +1 -1
  40. package/lib/pages/extension-detail/extension-detail-routes.d.ts +0 -1
  41. package/lib/pages/extension-detail/extension-detail-routes.d.ts.map +1 -1
  42. package/lib/pages/extension-detail/extension-detail-routes.js +2 -3
  43. package/lib/pages/extension-detail/extension-detail-routes.js.map +1 -1
  44. package/lib/pages/extension-detail/extension-detail.d.ts.map +1 -1
  45. package/lib/pages/extension-detail/extension-detail.js +120 -247
  46. package/lib/pages/extension-detail/extension-detail.js.map +1 -1
  47. package/lib/pages/extension-detail/use-extension-details.d.ts +23 -0
  48. package/lib/pages/extension-detail/use-extension-details.d.ts.map +1 -0
  49. package/lib/pages/extension-detail/use-extension-details.js +80 -0
  50. package/lib/pages/extension-detail/use-extension-details.js.map +1 -0
  51. package/lib/pages/user/avatar.js +1 -1
  52. package/lib/pages/user/avatar.js.map +1 -1
  53. package/lib/pages/user/user-settings-namespace-detail.js +1 -1
  54. package/lib/pages/user/user-settings-namespace-detail.js.map +1 -1
  55. package/package.json +3 -1
  56. package/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx +4 -4
  57. package/src/components/scan-admin/scan-card/scan-card-header.tsx +1 -1
  58. package/src/components/scan-admin/scan-card/utils.ts +1 -1
  59. package/src/default/menu-content.tsx +1 -1
  60. package/src/main.tsx +11 -6
  61. package/src/other-pages.tsx +20 -16
  62. package/src/pages/admin-dashboard/{admin-routes.ts → admin-dashboard-routes.ts} +5 -8
  63. package/src/pages/admin-dashboard/admin-dashboard.tsx +27 -23
  64. package/src/pages/admin-dashboard/customers/customer-member-list.tsx +1 -1
  65. package/src/pages/admin-dashboard/customers/customers.tsx +1 -1
  66. package/src/pages/admin-dashboard/publisher-admin.tsx +1 -1
  67. package/src/pages/admin-dashboard/usage-stats/usage-stats.tsx +1 -1
  68. package/src/pages/admin-dashboard/welcome.tsx +1 -1
  69. package/src/pages/extension-detail/extension-detail-changes.tsx +1 -5
  70. package/src/pages/extension-detail/extension-detail-overview.tsx +1 -7
  71. package/src/pages/extension-detail/extension-detail-routes.ts +2 -3
  72. package/src/pages/extension-detail/extension-detail.tsx +290 -407
  73. package/src/pages/extension-detail/use-extension-details.tsx +101 -0
  74. package/src/pages/user/avatar.tsx +1 -1
  75. package/src/pages/user/user-settings-namespace-detail.tsx +1 -1
  76. package/lib/pages/admin-dashboard/admin-routes.d.ts.map +0 -1
  77. package/lib/pages/admin-dashboard/admin-routes.js.map +0 -1
@@ -8,13 +8,13 @@
8
8
  * SPDX-License-Identifier: EPL-2.0
9
9
  ********************************************************************************/
10
10
 
11
- import { ChangeEvent, FunctionComponent, ReactElement, ReactNode, useContext, useEffect, useState, useRef } from 'react';
11
+ import { FunctionComponent, useCallback, useContext } from 'react';
12
12
  import {
13
- Typography, Box, Theme, Container, Link, Avatar, Paper, Badge, SxProps, Tabs, Tab, Stack, useTheme, PaletteMode,
13
+ Typography, Box, Container, Link, Avatar, Paper, Badge, Tabs, Tab, Stack, useTheme, PaletteMode,
14
14
  decomposeColor
15
15
  } from '@mui/material';
16
16
  import { styled } from '@mui/material/styles';
17
- import { Link as RouteLink, useNavigate, useParams } from 'react-router-dom';
17
+ import { Link as RouteLink, Route, Routes, useNavigate, useParams } from 'react-router-dom';
18
18
  import SaveAltIcon from '@mui/icons-material/SaveAlt';
19
19
  import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
20
20
  import WarningIcon from '@mui/icons-material/Warning';
@@ -22,454 +22,337 @@ import { MainContext } from '../../context';
22
22
  import { createRoute } from '../../utils';
23
23
  import { DelayedLoadIndicator } from '../../components/delayed-load-indicator';
24
24
  import { HoverPopover } from '../../components/hover-popover';
25
- import { Extension, UserData, isError } from '../../extension-registry-types';
25
+ import { Extension, UserData } from '../../extension-registry-types';
26
26
  import { TextDivider } from '../../components/text-divider';
27
27
  import { ExtensionRatingStars } from './extension-rating-stars';
28
28
  import { NamespaceDetailRoutes } from '../namespace-detail/namespace-detail-routes';
29
29
  import { ExtensionDetailOverview } from './extension-detail-overview';
30
30
  import { ExtensionDetailChanges } from './extension-detail-changes';
31
31
  import { ExtensionDetailReviews } from './extension-detail-reviews';
32
- import { ExtensionDetailRoutes } from './extension-detail-routes';
33
32
 
34
- const alignVertically = {
35
- display: 'flex',
36
- alignItems: 'center'
37
- };
33
+ import { ExtensionDetailRoutes } from './extension-detail-routes';
34
+ import { useExtensionDetail } from './use-extension-details';
38
35
 
39
- const link = {
36
+ const inlineLinkStyle = {
40
37
  display: 'contents',
41
38
  cursor: 'pointer',
42
39
  textDecoration: 'none',
43
- '&:hover': {
44
- textDecoration: 'underline'
45
- }
40
+ '&:hover': { textDecoration: 'underline' }
41
+ } as const;
42
+
43
+ const StyledRouteLink = styled(RouteLink)(inlineLinkStyle);
44
+ const StyledLink = styled(Link)(inlineLinkStyle);
45
+ const StyledHoverPopover = styled(HoverPopover)({ display: 'flex', alignItems: 'center' });
46
+ const PreviewBadge = styled(Badge)(({ theme }) => ({
47
+ '& .MuiBadge-badge': { top: theme.spacing(1), right: theme.spacing(-5) }
48
+ }));
49
+
50
+ enum ExtensionTab {
51
+ OVERVIEW = 'overview',
52
+ CHANGES = 'changes',
53
+ REVIEWS = 'reviews',
54
+ }
55
+
56
+ const TAB_VALUES = new Set<string>(Object.values(ExtensionTab));
57
+
58
+ const isTabSegment = (segment?: string): segment is ExtensionTab =>
59
+ TAB_VALUES.has(segment ?? '');
60
+
61
+ const parseTab = (segment?: string): ExtensionTab =>
62
+ isTabSegment(segment) ? segment : ExtensionTab.OVERVIEW;
63
+
64
+ const buildExtensionPath = (namespace: string, name: string, target?: string, ...extra: string[]) => {
65
+ const arr = [ExtensionDetailRoutes.ROOT, namespace, name];
66
+ if (target) arr.push(target);
67
+ arr.push(...extra);
68
+ return createRoute(arr);
46
69
  };
47
70
 
48
- const StyledRouteLink = styled(RouteLink)(link);
49
- const StyledLink = styled(Link)(link);
50
- const StyledHoverPopover = styled(HoverPopover)(alignVertically);
71
+ const UnverifiedBanner: FunctionComponent<{
72
+ extension: Extension;
73
+ headerTextColor: string;
74
+ themeType: PaletteMode;
75
+ }> = ({ extension, headerTextColor, themeType }) => {
76
+ const { pageSettings } = useContext(MainContext);
77
+
78
+ if (extension.verified) return null;
79
+
80
+ return (
81
+ <Paper
82
+ sx={{
83
+ display: 'flex',
84
+ maxWidth: '800px',
85
+ p: 2,
86
+ mt: 0,
87
+ mx: { xs: 0, md: 6 },
88
+ mb: { xs: 2, md: 4 },
89
+ bgcolor: `warning.${themeType}`,
90
+ color: headerTextColor,
91
+ '& a': { color: headerTextColor, textDecoration: 'underline' }
92
+ }}
93
+ >
94
+ <WarningIcon fontSize='large' />
95
+ <Box ml={1}>
96
+ This version of the &ldquo;{extension.displayName ?? extension.name}&rdquo; extension was published
97
+ by <Link href={extension.publishedBy.homepage}>
98
+ {extension.publishedBy.loginName}
99
+ </Link>. That user account is not a verified publisher of
100
+ the namespace &ldquo;{extension.namespace}&rdquo; of
101
+ this extension. <Link href={pageSettings.urls.namespaceAccessInfo} target='_blank'>
102
+ See the documentation
103
+ </Link> to learn how we handle namespaces and what you can do to eliminate this warning.
104
+ </Box>
105
+ </Paper>
106
+ );
107
+ };
51
108
 
52
- export const ExtensionDetail: FunctionComponent = () => {
53
- const theme = useTheme();
54
- const [loading, setLoading] = useState<boolean>(true);
55
- const [notFoundError, setNotFoundError] = useState<string>();
56
- const [extension, setExtension] = useState<Extension>();
57
- const [icon, setIcon] = useState<string>();
109
+ const VerificationIcon: FunctionComponent<{
110
+ verified: boolean;
111
+ color: string;
112
+ }> = ({ verified, color }) => {
113
+ const { pageSettings } = useContext(MainContext);
114
+ const icon = verified ? <VerifiedUserIcon fontSize='small' /> : <WarningIcon fontSize='small' />;
115
+ const title = verified ? 'Verified publisher' : 'Unverified publisher';
58
116
 
59
- const navigate = useNavigate();
60
- const { namespace, name, target, version } = useParams();
61
- const { handleError, pageSettings, service } = useContext(MainContext);
62
-
63
- const abortController = useRef<AbortController>(new AbortController());
64
- useEffect(() => {
65
- updateExtension();
66
- return () => {
67
- abortController.current.abort();
68
- if (icon) {
69
- URL.revokeObjectURL(icon);
70
- }
71
- };
72
- }, []);
73
-
74
- useEffect(() => {
75
- if (versionPointsToTab(version)) {
76
- return;
77
- }
78
-
79
- setLoading(true);
80
- updateExtension();
81
- }, [namespace, name, target, version]);
82
-
83
- const updateExtension = async (): Promise<void> => {
84
- const extensionUrl = getExtensionApiUrl();
85
- try {
86
- const response = await service.getExtensionDetail(abortController.current, extensionUrl);
87
- if (isError(response)) {
88
- throw response;
89
- }
90
- const extension = response as Extension;
91
- const icon = await updateIcon(extension);
92
- setExtension(extension);
93
- setIcon(icon);
94
- setLoading(false);
95
- } catch (err) {
96
- if (err && err.status === 404) {
97
- setNotFoundError(`Extension Not Found: ${namespace}.${name}`);
98
- setLoading(false);
99
- } else {
100
- handleError(err);
101
- }
102
- setLoading(false);
103
- }
104
- };
105
-
106
- const getExtensionApiUrl = (): string => {
107
- return versionPointsToTab(version)
108
- ? service.getExtensionApiUrl({ namespace: namespace as string, name: name as string })
109
- : service.getExtensionApiUrl({ namespace: namespace as string, name: name as string, target: target, version: version });
110
- };
111
-
112
- const updateIcon = async (extension: Extension): Promise<string | undefined> => {
113
- if (icon) {
114
- URL.revokeObjectURL(icon);
115
- }
116
-
117
- return await service.getExtensionIcon(abortController.current, extension);
118
- };
119
-
120
- const onVersionSelect = (version: string): void => {
121
- const arr = [ExtensionDetailRoutes.ROOT, namespace as string, name as string];
122
- if (target) {
123
- arr.push(target);
124
- }
125
- if (version !== 'latest') {
126
- arr.push(version);
127
- }
128
-
129
- navigate(createRoute(arr));
130
- };
131
-
132
- const onReviewUpdate = (): void => {
133
- updateExtension();
134
- };
135
-
136
- const handleTabChange = (event: ChangeEvent, newTab: string): void => {
137
- const previousTab = versionPointsToTab(version) ? version : 'overview';
138
- if (newTab !== previousTab) {
139
- const arr = [ExtensionDetailRoutes.ROOT, namespace as string, name as string];
140
- if (target) {
141
- arr.push(target);
142
- }
143
-
144
- if (newTab === 'reviews' || newTab === 'changes') {
145
- arr.push(newTab);
146
- } else if (version && !versionPointsToTab(version)) {
147
- arr.push(version);
148
- } else if (extension && !isLatestVersion(extension)) {
149
- arr.push(extension.version);
150
- }
151
-
152
- navigate(createRoute(arr));
153
- }
154
- };
155
-
156
- const isLatestVersion = (extension: Extension): boolean => {
157
- return extension.versionAlias.indexOf('latest') >= 0;
158
- };
159
-
160
- const versionPointsToTab = (version?: string): boolean => {
161
- return version === 'reviews' || version === 'changes';
162
- };
163
-
164
- const renderHeaderTags = (extension?: Extension): ReactNode => {
165
- const { extensionHeadTags: ExtensionHeadTagsComponent } = pageSettings.elements;
166
- return <>
167
- { ExtensionHeadTagsComponent
168
- ? <ExtensionHeadTagsComponent extension={extension} pageSettings={pageSettings}/>
169
- : null
170
- }
171
- </>;
172
- };
173
-
174
- const renderNotFound = (): ReactNode => {
175
- return <>
176
- {
177
- notFoundError ?
178
- <Box p={4}>
179
- <Typography variant='h5'>
180
- {notFoundError}
181
- </Typography>
182
- </Box>
183
- : null
184
- }
185
- </>;
186
- };
187
-
188
- const renderTab = (tab: string, extension: Extension): ReactNode => {
189
- switch (tab) {
190
- case 'changes':
191
- return <ExtensionDetailChanges extension={extension} />;
192
- case 'reviews':
193
- return <ExtensionDetailReviews extension={extension} reviewsDidUpdate={onReviewUpdate} />;
194
- default:
195
- return <ExtensionDetailOverview extension={extension} selectVersion={onVersionSelect} />;
196
- }
197
- };
198
-
199
- const renderExtension = (extension: Extension): ReactNode => {
200
- const tab = versionPointsToTab(version) ? version as string : 'overview';
201
- const themeType = (extension.galleryTheme || pageSettings.themeType) ?? 'light';
202
- const fallbackColor = theme.palette.neutral[themeType] as string;
203
- let headerColor = extension.galleryColor || fallbackColor;
204
-
205
- try {
206
- // check if the color string can be decomposed, i.e. if mui understands it, otherwise
207
- // fall back to the neutral color of the used palette.
208
- decomposeColor(headerColor);
209
- } catch (error) {
210
- headerColor = fallbackColor;
211
- }
212
-
213
- const headerTextColor = theme.palette.getContrastText(headerColor);
214
-
215
- return <>
216
- <Box
217
- sx={{
218
- bgcolor: headerColor,
219
- color: headerTextColor,
220
- filter: extension.deprecated ? 'grayscale(100%)' : undefined
221
- }}
222
- >
223
- <Container maxWidth='xl'>
224
- <Box sx={{ display: 'flex', alignItems: 'center', flexDirection: 'column', py: 4, px: 0 }}>
225
- {renderBanner(extension, headerTextColor, themeType)}
226
- <Box
227
- sx={{
228
- display: 'flex',
229
- width: '100%',
230
- flexDirection: { xs: 'column', sm: 'column', md: 'row', lg: 'row', xl: 'row' },
231
- textAlign: { xs: 'center', sm: 'center', md: 'start', lg: 'start', xl: 'start' },
232
- alignItems: { xs: 'center', sm: 'center', md: 'normal', lg: 'normal', xl: 'normal' }
233
- }}
234
- >
235
- <Box
236
- component='img'
237
- src={icon ?? pageSettings.urls.extensionDefaultIcon }
238
- alt={extension.displayName ?? extension.name}
239
- sx={{
240
- height: '7.5rem',
241
- maxWidth: '9rem',
242
- mr: { xs: 0, sm: 0, md: '2rem', lg: '2rem', xl: '2rem' },
243
- pt: 1
244
- }}
245
- />
246
- {renderHeaderInfo(extension, headerTextColor)}
247
- </Box>
248
- </Box>
249
- </Container>
117
+ return (
118
+ <StyledLink href={pageSettings.urls.namespaceAccessInfo} target='_blank' title={title} sx={{ color }}>
119
+ {icon}
120
+ </StyledLink>
121
+ );
122
+ };
123
+
124
+ const UserPopover: FunctionComponent<{
125
+ user: UserData;
126
+ color: string;
127
+ }> = ({ user, color }) => {
128
+ const popupContent = (
129
+ <Box display='flex' flexDirection='row'>
130
+ {user.avatarUrl && (
131
+ <Avatar
132
+ src={user.avatarUrl}
133
+ alt={user.fullName ?? user.loginName}
134
+ variant='rounded'
135
+ sx={{ width: '60px', height: '60px' }}
136
+ />
137
+ )}
138
+ <Box ml={2}>
139
+ {user.fullName && <Typography variant='h6'>{user.fullName}</Typography>}
140
+ <Typography variant='body1'>{user.loginName}</Typography>
250
141
  </Box>
251
- <Container maxWidth='xl'>
252
- <Box>
253
- <Box>
254
- <Tabs value={tab} onChange={handleTabChange} indicatorColor='secondary'>
255
- <Tab value='overview' label='Overview' />
256
- <Tab value='changes' label='Changes' />
257
- <Tab value='reviews' label='Ratings &amp; Reviews' />
258
- </Tabs>
259
- {renderTab(tab, extension)}
260
- </Box>
261
- </Box>
262
- </Container>
263
- </>;
264
- };
265
-
266
- const renderBanner = (extension: Extension, headerTextColor: string, themeType: PaletteMode): ReactNode => {
267
- if (!extension.verified) {
268
- return <Paper
269
- sx={{
270
- display: 'flex',
271
- maxWidth: '800px',
272
- p: 2,
273
- mt: 0,
274
- mr: { xs: 0, sm: 0, md: 6, lg: 6, xl: 6 },
275
- mb: { xs: 2, sm: 2, md: 4, lg: 4, xl: 4 },
276
- ml: { xs: 0, sm: 0, md: 6, lg: 6, xl: 6 },
277
- bgcolor: `warning.${themeType}`,
278
- color: headerTextColor,
279
- '& a': {
280
- color: headerTextColor,
281
- textDecoration: 'underline'
282
- }
283
- }}
284
- >
285
- <WarningIcon fontSize='large' />
286
- <Box ml={1}>
287
- This version of the &ldquo;{extension.displayName ?? extension.name}&rdquo; extension was published
288
- by <Link href={extension.publishedBy.homepage}>
289
- {extension.publishedBy.loginName}
290
- </Link>. That user account is not a verified publisher of
291
- the namespace &ldquo;{extension.namespace}&rdquo; of
292
- this extension. <Link
293
- href={pageSettings.urls.namespaceAccessInfo}
294
- target='_blank' >
295
- See the documentation
296
- </Link> to learn how we handle namespaces and what you can do to eliminate this warning.
297
- </Box>
298
- </Paper>;
299
- }
300
- return null;
301
- };
302
-
303
- const renderHeaderInfo = (extension: Extension, headerTextColor: string): ReactNode => {
304
- const numberFormat = new Intl.NumberFormat(undefined, { notation: 'compact', compactDisplay: 'short' } as any);
305
- const downloadCountFormatted = numberFormat.format(extension.downloadCount || 0);
306
- const reviewCountFormatted = numberFormat.format(extension.reviewCount || 0);
307
- const previewBadgeStyle = (theme: Theme) => ({
308
- "& .MuiBadge-badge": {
309
- top: theme.spacing(1),
310
- right: theme.spacing(-5)
311
- }
312
- });
142
+ </Box>
143
+ );
144
+
145
+ return (
146
+ <StyledHoverPopover id={`user_${user.loginName}_popover`} popupContent={popupContent}>
147
+ <StyledLink href={user.homepage} sx={{ color }}>
148
+ {user.avatarUrl
149
+ ? <>{user.loginName}&nbsp;<Avatar src={user.avatarUrl} alt={user.loginName} sx={{ width: '20px', height: '20px' }} /></>
150
+ : user.loginName}
151
+ </StyledLink>
152
+ </StyledHoverPopover>
153
+ );
154
+ };
313
155
 
156
+ const LicenseLink: FunctionComponent<{
157
+ extension: Extension;
158
+ color: string;
159
+ }> = ({ extension, color }) => {
160
+ if (extension.files.license) {
314
161
  return (
162
+ <StyledLink href={extension.files.license} sx={{ color }} title={extension.license ? 'License type' : undefined}>
163
+ {extension.license || 'Provided license'}
164
+ </StyledLink>
165
+ );
166
+ }
167
+ return <>{extension.license || 'Unlicensed'}</>;
168
+ };
169
+
170
+ const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', compactDisplay: 'short' } as Intl.NumberFormatOptions);
171
+
172
+ const ExtensionHeaderInfo: FunctionComponent<{
173
+ extension: Extension;
174
+ headerTextColor: string;
175
+ }> = ({ extension, headerTextColor }) => {
176
+ const downloadCountFormatted = compactNumber.format(extension.downloadCount || 0);
177
+ const reviewCountFormatted = compactNumber.format(extension.reviewCount || 0);
178
+
179
+ return (
315
180
  <Box overflow='auto' sx={{ pt: 1, overflow: 'visible' }}>
316
- <Badge color='secondary' badgeContent='Preview' invisible={!extension.preview} sx={previewBadgeStyle}>
181
+ <PreviewBadge color='secondary' badgeContent='Preview' invisible={!extension.preview}>
317
182
  <Typography variant='h5' sx={{ fontWeight: 'bold', mb: 1 }}>
318
- { extension.displayName ?? extension.name}
183
+ {extension.displayName ?? extension.name}
319
184
  </Typography>
320
- </Badge>
321
- { extension.deprecated &&
185
+ </PreviewBadge>
186
+
187
+ {extension.deprecated && (
322
188
  <Stack direction='row' alignItems='center'>
323
189
  <WarningIcon fontSize='small' />
324
190
  <Typography>
325
- This extension has been deprecated.{extension.replacement && <>&nbsp;Use <StyledLink sx={{ color: headerTextColor }} href={extension.replacement.url}>
326
- {extension.replacement.displayName}
327
- </StyledLink> instead.</>}
191
+ This extension has been deprecated.
192
+ {extension.replacement && (
193
+ <>&nbsp;Use <StyledLink sx={{ color: headerTextColor }} href={extension.replacement.url}>
194
+ {extension.replacement.displayName}
195
+ </StyledLink> instead.</>
196
+ )}
328
197
  </Typography>
329
198
  </Stack>
330
- }
331
- <Box
332
- sx={{
333
- ...alignVertically,
334
- color: headerTextColor,
335
- flexDirection: { xs: 'column', sm: 'column', md: 'row', lg: 'row', xl: 'row' }
336
- }}
337
- >
338
- <Box sx={alignVertically}>
339
- {renderAccessInfo(extension, headerTextColor)}&nbsp;
340
- <StyledRouteLink
341
- to={createRoute([NamespaceDetailRoutes.ROOT, extension.namespace])}
342
- style={{ color: headerTextColor }}>
199
+ )}
200
+
201
+ <Box sx={{ display: 'flex', alignItems: 'center', color: headerTextColor, flexDirection: { xs: 'column', md: 'row' } }}>
202
+ <Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
203
+ <VerificationIcon verified={extension.verified} color={headerTextColor} />&nbsp;
204
+ <StyledRouteLink to={createRoute([NamespaceDetailRoutes.ROOT, extension.namespace])} style={{ color: headerTextColor }}>
343
205
  {extension.namespaceDisplayName}
344
206
  </StyledRouteLink>
345
207
  </Box>
346
- <TextDivider backgroundColor={headerTextColor} collapseSmall={true} />
347
- <Box sx={alignVertically}>
348
- Published by&nbsp;{renderUser(extension.publishedBy, headerTextColor, alignVertically)}
208
+ <TextDivider backgroundColor={headerTextColor} collapseSmall />
209
+ <Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
210
+ Published by&nbsp;<UserPopover user={extension.publishedBy} color={headerTextColor} />
349
211
  </Box>
350
- <TextDivider backgroundColor={headerTextColor} collapseSmall={true} />
351
- <Box sx={alignVertically}>
352
- {renderLicense(extension, headerTextColor)}
212
+ <TextDivider backgroundColor={headerTextColor} collapseSmall />
213
+ <Box sx={{ display: 'flex', alignItems: 'center' }}>
214
+ <LicenseLink extension={extension} color={headerTextColor} />
353
215
  </Box>
354
216
  </Box>
217
+
355
218
  <Box mt={2} mb={2} overflow='auto'>
356
219
  <Typography sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{extension.description}</Typography>
357
220
  </Box>
358
- <Box
359
- sx={{
360
- ...alignVertically,
361
- color: headerTextColor,
362
- justifyContent: { xs: 'center', sm: 'center', md: 'flex-start', lg: 'flex-start', xl: 'flex-start' }
363
- }}
364
- >
365
- <Box component='span' sx={alignVertically}
366
- title={extension.downloadCount && extension.downloadCount >= 1000 ? `${extension.downloadCount} downloads` : undefined}>
221
+
222
+ <Box sx={{ display: 'flex', alignItems: 'center', color: headerTextColor, justifyContent: { xs: 'center', md: 'flex-start' } }}>
223
+ <Box component='span' sx={{ display: 'flex', alignItems: 'center' }}
224
+ title={extension.downloadCount && extension.downloadCount >= 1000 ? `${extension.downloadCount} downloads` : undefined}
225
+ >
367
226
  <SaveAltIcon fontSize='small' />&nbsp;{downloadCountFormatted}&nbsp;{extension.downloadCount === 1 ? 'download' : 'downloads'}
368
227
  </Box>
369
228
  <TextDivider backgroundColor={headerTextColor} />
370
229
  <StyledLink
371
230
  href={createRoute([ExtensionDetailRoutes.ROOT, extension.namespace, extension.name, 'reviews'])}
372
- sx={{
373
- ...alignVertically,
374
- color: headerTextColor
375
- }}
231
+ sx={{ display: 'flex', alignItems: 'center', color: headerTextColor }}
376
232
  title={
377
- extension.averageRating !== undefined ?
378
- `Average rating: ${getRoundedRating(extension.averageRating)} out of 5 (${extension.reviewCount} reviews)`
379
- : 'Not rated yet'
380
- }>
233
+ extension.averageRating === undefined
234
+ ? 'Not rated yet'
235
+ : `Average rating: ${Math.round(extension.averageRating * 10) / 10} out of 5 (${extension.reviewCount} reviews)`
236
+ }
237
+ >
381
238
  <ExtensionRatingStars number={extension.averageRating ?? 0} fontSize='small' />
382
239
  ({reviewCountFormatted})
383
240
  </StyledLink>
384
- </Box>
385
241
  </Box>
386
- );
387
- };
388
-
389
- const getRoundedRating = (rating: number): number => {
390
- return Math.round(rating * 10) / 10;
391
- };
392
-
393
- const renderAccessInfo = (extension: Extension, themeColor: string): ReactNode => {
394
- let icon: ReactElement;
395
- let title: string;
396
- if (extension.verified) {
397
- icon = <VerifiedUserIcon fontSize='small' />;
398
- title = 'Verified publisher';
399
- } else {
400
- icon = <WarningIcon fontSize='small' />;
401
- title = 'Unverified publisher';
402
- }
403
- return <StyledLink
404
- href={pageSettings.urls.namespaceAccessInfo}
405
- target='_blank'
406
- title={title}
407
- sx={{ color: themeColor }}>
408
- {icon}
409
- </StyledLink>;
410
- };
242
+ </Box>
243
+ );
244
+ };
411
245
 
412
- const renderUser = (user: UserData, themeColor: string, alignVertically: SxProps<Theme>): ReactNode => {
413
- const popupContent = <Box display='flex' flexDirection='row'>
414
- {
415
- user.avatarUrl ?
416
- <Avatar
417
- src={user.avatarUrl}
418
- alt={user.fullName ?? user.loginName}
419
- variant='rounded'
420
- sx={{ width: '60px', height: '60px' }} />
421
- : null
422
- }
423
- <Box ml={2}>
424
- {
425
- user.fullName ?
426
- <Typography variant='h6'>{user.fullName}</Typography>
427
- : null
428
- }
429
- <Typography variant='body1'>{user.loginName}</Typography>
430
- </Box>
431
- </Box>;
432
- return <StyledHoverPopover
433
- id={`user_${user.loginName}_popover`}
434
- popupContent={popupContent}
246
+ const ExtensionHeader: FunctionComponent<{
247
+ extension: Extension;
248
+ icon: string | undefined;
249
+ }> = ({ extension, icon }) => {
250
+ const theme = useTheme();
251
+ const { pageSettings } = useContext(MainContext);
252
+
253
+ const themeType = (extension.galleryTheme || pageSettings.themeType) ?? 'light';
254
+ const fallbackColor = theme.palette.neutral[themeType] as string;
255
+ let headerColor = extension.galleryColor || fallbackColor;
256
+
257
+ try {
258
+ decomposeColor(headerColor);
259
+ } catch {
260
+ headerColor = fallbackColor;
261
+ }
262
+
263
+ const headerTextColor = theme.palette.getContrastText(headerColor);
264
+
265
+ return (
266
+ <Box
267
+ sx={{
268
+ bgcolor: headerColor,
269
+ color: headerTextColor,
270
+ filter: extension.deprecated ? 'grayscale(100%)' : undefined
271
+ }}
435
272
  >
436
- <StyledLink href={user.homepage} sx={{ color: themeColor }}>
437
- {
438
- user.avatarUrl ?
439
- <>
440
- {user.loginName}&nbsp;<Avatar
441
- src={user.avatarUrl}
442
- alt={user.loginName}
443
- sx={{ width: '20px', height: '20px' }} />
444
- </>
445
- : user.loginName
446
- }
447
- </StyledLink>
448
- </StyledHoverPopover>;
449
- };
450
-
451
- const renderLicense = (extension: Extension, themeColor: string): ReactNode => {
452
- if (extension.files.license) {
453
- return <StyledLink
454
- href={extension.files.license}
455
- sx={{ color: themeColor }}
456
- title={extension.license ? 'License type' : undefined} >
457
- {extension.license || 'Provided license'}
458
- </StyledLink>;
459
- } else if (extension.license) {
460
- return extension.license;
461
- } else {
462
- return 'Unlicensed';
463
- }
464
- };
465
-
466
- return <>
467
- { renderHeaderTags(extension) }
468
- <DelayedLoadIndicator loading={loading} />
469
- {
470
- extension
471
- ? renderExtension(extension)
472
- : renderNotFound()
473
- }
474
- </>;
475
- };
273
+ <Container maxWidth='xl'>
274
+ <Box sx={{ display: 'flex', alignItems: 'center', flexDirection: 'column', py: 4, px: 0 }}>
275
+ <UnverifiedBanner extension={extension} headerTextColor={headerTextColor} themeType={themeType} />
276
+ <Box
277
+ sx={{
278
+ display: 'flex',
279
+ width: '100%',
280
+ flexDirection: { xs: 'column', md: 'row' },
281
+ textAlign: { xs: 'center', md: 'start' },
282
+ alignItems: { xs: 'center', md: 'normal' }
283
+ }}
284
+ >
285
+ <Box
286
+ component='img'
287
+ src={icon ?? pageSettings.urls.extensionDefaultIcon}
288
+ alt={extension.displayName ?? extension.name}
289
+ sx={{ height: '7.5rem', maxWidth: '9rem', mr: { xs: 0, md: '2rem' }, pt: 1 }}
290
+ />
291
+ <ExtensionHeaderInfo extension={extension} headerTextColor={headerTextColor} />
292
+ </Box>
293
+ </Box>
294
+ </Container>
295
+ </Box>
296
+ );
297
+ };
298
+
299
+ export const ExtensionDetail: FunctionComponent = () => {
300
+ const { namespace, name, target, '*': splat } = useParams();
301
+
302
+ const navigate = useNavigate();
303
+ const { pageSettings } = useContext(MainContext);
304
+
305
+ const version = splat || undefined;
306
+ const effectiveVersion = isTabSegment(version) ? undefined : version;
307
+ const activeTab = parseTab(version);
308
+
309
+ // React Router v6 returns a possibly undefined type for params, but our route configuration guarantees these will be defined.
310
+ const { loading, error, extension, icon, reload } = useExtensionDetail(namespace!, name!, target!, effectiveVersion!);
311
+
312
+ const navigateToVersion = useCallback((selectedVersion: string) => {
313
+ if (!namespace || !name) return;
314
+ navigate(selectedVersion === 'latest'
315
+ ? buildExtensionPath(namespace, name, target)
316
+ : buildExtensionPath(namespace, name, target, selectedVersion));
317
+ }, [navigate, namespace, name, target]);
318
+
319
+ if (!namespace || !name) return null;
320
+
321
+ const basePath = buildExtensionPath(namespace, name, target);
322
+ const reviewsPath = buildExtensionPath(namespace, name, target, ExtensionTab.REVIEWS);
323
+ const changesPath = buildExtensionPath(namespace, name, target, ExtensionTab.CHANGES);
324
+
325
+ let overviewPath = basePath;
326
+ if (version && !isTabSegment(version)) {
327
+ overviewPath = buildExtensionPath(namespace, name, target, version);
328
+ } else if (extension && !extension.versionAlias.includes('latest')) {
329
+ overviewPath = buildExtensionPath(namespace, name, target, extension.version);
330
+ }
331
+
332
+ const HeadTags = pageSettings.elements.extensionHeadTags;
333
+
334
+ return (
335
+ <>
336
+ {HeadTags && <HeadTags extension={extension} pageSettings={pageSettings} />}
337
+ <DelayedLoadIndicator loading={loading} />
338
+ {extension && (
339
+ <>
340
+ <ExtensionHeader extension={extension} icon={icon} />
341
+ <Container maxWidth='xl'>
342
+ <Tabs value={activeTab} indicatorColor='secondary'>
343
+ <Tab value={ExtensionTab.OVERVIEW} label='Overview' component={RouteLink} to={overviewPath} />
344
+ <Tab value={ExtensionTab.CHANGES} label='Changes' component={RouteLink} to={changesPath} />
345
+ <Tab value={ExtensionTab.REVIEWS} label='Ratings &amp; Reviews' component={RouteLink} to={reviewsPath} />
346
+ </Tabs>
347
+ <Routes>
348
+ <Route path={ExtensionTab.REVIEWS} element={<ExtensionDetailReviews extension={extension} reviewsDidUpdate={reload} />} />
349
+ <Route path={ExtensionTab.CHANGES} element={<ExtensionDetailChanges extension={extension} />} />
350
+ <Route path='*' element={<ExtensionDetailOverview extension={extension} selectVersion={navigateToVersion} />} />
351
+ </Routes>
352
+ </Container>
353
+ </>
354
+ )}
355
+ {error && <Box p={4}><Typography variant='h5'>{error.message}</Typography></Box>}
356
+ </>
357
+ );
358
+ };