swoop-common 2.2.212 → 2.2.213
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.
- package/dist/rendering/components/MediaPicker.d.ts +10 -0
- package/dist/rendering/components/MediaPicker.js +165 -0
- package/dist/rendering/components/SwoopImage.d.ts +17 -0
- package/dist/rendering/components/SwoopImage.js +43 -0
- package/dist/rendering/contexts/MediaPickerContext.d.ts +15 -0
- package/dist/rendering/contexts/MediaPickerContext.js +49 -0
- package/dist/rendering/index.d.ts +5 -0
- package/dist/rendering/index.js +5 -0
- package/dist/rendering/prebuild/generated/import_generated.d.ts +2 -0
- package/dist/rendering/prebuild/generated/import_generated.js +2 -0
- package/dist/rendering/renderers/MediaGallery/MediaGalleryForm.d.ts +9 -0
- package/dist/rendering/renderers/MediaGallery/MediaGalleryForm.js +69 -0
- package/dist/rendering/renderers/MediaGallery/MediaGalleryPresentation.d.ts +1 -0
- package/dist/rendering/renderers/MediaGallery/MediaGalleryPresentation.js +20 -0
- package/dist/rendering/schema/generate/jsonSchemaGenerate.js +5 -0
- package/dist/rendering/type_registration/register.js +24 -0
- package/dist/rendering/types/mediaAsset.d.ts +47 -0
- package/dist/rendering/types/mediaAsset.js +6 -0
- package/dist/rendering/util/api.d.ts +12 -0
- package/dist/rendering/util/api.js +43 -0
- package/dist/rendering/util/imgix.d.ts +26 -0
- package/dist/rendering/util/imgix.js +48 -0
- package/package.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { MediaAsset } from "../types/mediaAsset";
|
|
3
|
+
export interface MediaPickerModalProps {
|
|
4
|
+
open: boolean;
|
|
5
|
+
onClose: () => void;
|
|
6
|
+
onSelect: (assets: MediaAsset[]) => void;
|
|
7
|
+
maxSelect?: number;
|
|
8
|
+
initialRegion?: string;
|
|
9
|
+
}
|
|
10
|
+
export default function MediaPickerModal({ open, onClose, onSelect, maxSelect, initialRegion, }: MediaPickerModalProps): React.JSX.Element;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, MenuItem, Box, Typography, InputAdornment, IconButton, CircularProgress, ToggleButton, ToggleButtonGroup, ListSubheader, } from "@mui/material";
|
|
3
|
+
import { Search as SearchIcon, Clear as ClearIcon, CheckCircle as CheckCircleIcon, } from "@mui/icons-material";
|
|
4
|
+
import { MEDIA_REGIONS, searchResultToMediaAsset, } from "../types/mediaAsset";
|
|
5
|
+
import { buildImgixUrl } from "../util/imgix";
|
|
6
|
+
import { fetchMediaAssets, fetchMediaTags } from "../util/api";
|
|
7
|
+
const PAGE_SIZE = 30;
|
|
8
|
+
export default function MediaPickerModal({ open, onClose, onSelect, maxSelect, initialRegion, }) {
|
|
9
|
+
const [region, setRegion] = useState(initialRegion !== null && initialRegion !== void 0 ? initialRegion : MEDIA_REGIONS[0].query);
|
|
10
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
11
|
+
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
|
12
|
+
const [tagId, setTagId] = useState("");
|
|
13
|
+
const [tags, setTags] = useState([]);
|
|
14
|
+
const [page, setPage] = useState(1);
|
|
15
|
+
const [items, setItems] = useState([]);
|
|
16
|
+
const [hasMore, setHasMore] = useState(false);
|
|
17
|
+
const [loading, setLoading] = useState(false);
|
|
18
|
+
const [error, setError] = useState(null);
|
|
19
|
+
const [selected, setSelected] = useState(new Map());
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300);
|
|
22
|
+
return () => clearTimeout(timer);
|
|
23
|
+
}, [searchTerm]);
|
|
24
|
+
// Reset transient state each time the picker opens
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (open) {
|
|
27
|
+
setSelected(new Map());
|
|
28
|
+
setPage(1);
|
|
29
|
+
if (initialRegion)
|
|
30
|
+
setRegion(initialRegion);
|
|
31
|
+
}
|
|
32
|
+
}, [open, initialRegion]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open)
|
|
35
|
+
return;
|
|
36
|
+
fetchMediaTags(region)
|
|
37
|
+
.then(setTags)
|
|
38
|
+
.catch(() => setTags([]));
|
|
39
|
+
}, [open, region]);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
setPage(1);
|
|
42
|
+
}, [region, debouncedSearchTerm, tagId]);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open)
|
|
45
|
+
return;
|
|
46
|
+
setLoading(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
fetchMediaAssets({
|
|
49
|
+
region,
|
|
50
|
+
q: debouncedSearchTerm || undefined,
|
|
51
|
+
tagId: tagId || undefined,
|
|
52
|
+
page,
|
|
53
|
+
itemsPerPage: PAGE_SIZE,
|
|
54
|
+
})
|
|
55
|
+
.then(({ items, hasMore }) => {
|
|
56
|
+
setItems(items);
|
|
57
|
+
setHasMore(hasMore);
|
|
58
|
+
})
|
|
59
|
+
.catch(() => setError("Failed to load images"))
|
|
60
|
+
.finally(() => setLoading(false));
|
|
61
|
+
}, [open, region, debouncedSearchTerm, tagId, page]);
|
|
62
|
+
const tagsByType = useMemo(() => {
|
|
63
|
+
const grouped = new Map();
|
|
64
|
+
tags.forEach((tag) => {
|
|
65
|
+
if (!grouped.has(tag.type))
|
|
66
|
+
grouped.set(tag.type, []);
|
|
67
|
+
grouped.get(tag.type).push(tag);
|
|
68
|
+
});
|
|
69
|
+
return grouped;
|
|
70
|
+
}, [tags]);
|
|
71
|
+
const atMaxSelection = maxSelect !== undefined && selected.size >= maxSelect;
|
|
72
|
+
const toggleSelection = (item) => {
|
|
73
|
+
setSelected((prev) => {
|
|
74
|
+
const next = new Map(prev);
|
|
75
|
+
if (next.has(item.id)) {
|
|
76
|
+
next.delete(item.id);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
if (maxSelect === 1)
|
|
80
|
+
next.clear();
|
|
81
|
+
else if (atMaxSelection)
|
|
82
|
+
return prev;
|
|
83
|
+
next.set(item.id, item);
|
|
84
|
+
}
|
|
85
|
+
return next;
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
const handleConfirm = () => {
|
|
89
|
+
onSelect([...selected.values()].map(searchResultToMediaAsset));
|
|
90
|
+
};
|
|
91
|
+
const handleRegionChange = (_, value) => {
|
|
92
|
+
if (!value)
|
|
93
|
+
return;
|
|
94
|
+
setRegion(value);
|
|
95
|
+
setTagId("");
|
|
96
|
+
};
|
|
97
|
+
return (React.createElement(Dialog, { open: open, onClose: onClose, maxWidth: "lg", fullWidth: true },
|
|
98
|
+
React.createElement(DialogTitle, null, "Select Images"),
|
|
99
|
+
React.createElement(DialogContent, { dividers: true, sx: { minHeight: 480 } },
|
|
100
|
+
React.createElement(Box, { sx: { display: "flex", gap: 2, mb: 2, flexWrap: "wrap" } },
|
|
101
|
+
React.createElement(ToggleButtonGroup, { value: region, exclusive: true, onChange: handleRegionChange, size: "small" }, MEDIA_REGIONS.map((r) => (React.createElement(ToggleButton, { key: r.code, value: r.query }, r.label)))),
|
|
102
|
+
React.createElement(TextField, { size: "small", placeholder: "Search by title or filename", value: searchTerm, onChange: (e) => setSearchTerm(e.target.value), sx: { flexGrow: 1, minWidth: 220 }, InputProps: {
|
|
103
|
+
startAdornment: (React.createElement(InputAdornment, { position: "start" },
|
|
104
|
+
React.createElement(SearchIcon, { fontSize: "small" }))),
|
|
105
|
+
endAdornment: searchTerm && (React.createElement(InputAdornment, { position: "end" },
|
|
106
|
+
React.createElement(IconButton, { size: "small", onClick: () => setSearchTerm("") },
|
|
107
|
+
React.createElement(ClearIcon, { fontSize: "small" })))),
|
|
108
|
+
} }),
|
|
109
|
+
React.createElement(TextField, { select: true, size: "small", label: "Tag", value: tagId, onChange: (e) => setTagId(e.target.value === "" ? "" : Number(e.target.value)), sx: { minWidth: 180 } },
|
|
110
|
+
React.createElement(MenuItem, { value: "" }, "All tags"),
|
|
111
|
+
[...tagsByType.entries()].flatMap(([type, typeTags]) => [
|
|
112
|
+
React.createElement(ListSubheader, { key: `header-${type}` }, type),
|
|
113
|
+
...typeTags.map((tag) => (React.createElement(MenuItem, { key: tag.id, value: tag.id }, tag.title))),
|
|
114
|
+
]))),
|
|
115
|
+
loading ? (React.createElement(Box, { sx: { display: "flex", justifyContent: "center", py: 8 } },
|
|
116
|
+
React.createElement(CircularProgress, null))) : error ? (React.createElement(Typography, { color: "error", sx: { py: 4, textAlign: "center" } }, error)) : items.length === 0 ? (React.createElement(Typography, { color: "text.secondary", sx: { py: 4, textAlign: "center" } }, "No images found")) : (React.createElement(Box, { sx: {
|
|
117
|
+
display: "grid",
|
|
118
|
+
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
|
|
119
|
+
gap: 1.5,
|
|
120
|
+
} }, items.map((item) => {
|
|
121
|
+
const isSelected = selected.has(item.id);
|
|
122
|
+
const disabled = !isSelected && atMaxSelection && maxSelect !== 1;
|
|
123
|
+
return (React.createElement(Box, { key: item.id, onClick: () => !disabled && toggleSelection(item), sx: {
|
|
124
|
+
position: "relative",
|
|
125
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
126
|
+
borderRadius: 1,
|
|
127
|
+
overflow: "hidden",
|
|
128
|
+
outline: isSelected ? "3px solid" : "1px solid",
|
|
129
|
+
outlineColor: isSelected ? "primary.main" : "divider",
|
|
130
|
+
opacity: disabled ? 0.4 : 1,
|
|
131
|
+
} },
|
|
132
|
+
React.createElement("img", { src: buildImgixUrl({ assetId: item.id, filename: item.filename }, { w: 240, h: 160, fit: "crop", q: 40 }), alt: item.title, loading: "lazy", style: {
|
|
133
|
+
width: "100%",
|
|
134
|
+
height: 120,
|
|
135
|
+
objectFit: "cover",
|
|
136
|
+
display: "block",
|
|
137
|
+
} }),
|
|
138
|
+
isSelected && (React.createElement(CheckCircleIcon, { color: "primary", sx: {
|
|
139
|
+
position: "absolute",
|
|
140
|
+
top: 4,
|
|
141
|
+
right: 4,
|
|
142
|
+
bgcolor: "white",
|
|
143
|
+
borderRadius: "50%",
|
|
144
|
+
} })),
|
|
145
|
+
React.createElement(Box, { sx: { p: 0.5 } },
|
|
146
|
+
React.createElement(Typography, { variant: "caption", noWrap: true, display: "block" }, item.title),
|
|
147
|
+
React.createElement(Typography, { variant: "caption", color: "text.secondary", noWrap: true, display: "block" }, item.width && item.height
|
|
148
|
+
? `${item.width} × ${item.height}`
|
|
149
|
+
: item.filename))));
|
|
150
|
+
})))),
|
|
151
|
+
React.createElement(DialogActions, { sx: { justifyContent: "space-between", px: 3 } },
|
|
152
|
+
React.createElement(Box, { sx: { display: "flex", alignItems: "center", gap: 1 } },
|
|
153
|
+
React.createElement(Button, { disabled: page <= 1 || loading, onClick: () => setPage(page - 1) }, "Previous"),
|
|
154
|
+
React.createElement(Typography, { variant: "body2" },
|
|
155
|
+
"Page ",
|
|
156
|
+
page),
|
|
157
|
+
React.createElement(Button, { disabled: !hasMore || loading, onClick: () => setPage(page + 1) }, "Next")),
|
|
158
|
+
React.createElement(Box, { sx: { display: "flex", alignItems: "center", gap: 2 } },
|
|
159
|
+
React.createElement(Typography, { variant: "body2", color: "text.secondary" },
|
|
160
|
+
selected.size,
|
|
161
|
+
" selected",
|
|
162
|
+
maxSelect !== undefined && ` (max ${maxSelect})`),
|
|
163
|
+
React.createElement(Button, { onClick: onClose }, "Cancel"),
|
|
164
|
+
React.createElement(Button, { variant: "contained", disabled: selected.size === 0, onClick: handleConfirm }, "Select")))));
|
|
165
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { MediaAsset } from "../types/mediaAsset";
|
|
3
|
+
import { ImgixOptions } from "../util/imgix";
|
|
4
|
+
export interface SwoopImageProps extends ImgixOptions {
|
|
5
|
+
asset: Pick<MediaAsset, "assetId" | "filename" | "title">;
|
|
6
|
+
alt?: string;
|
|
7
|
+
dprs?: number[];
|
|
8
|
+
style?: React.CSSProperties;
|
|
9
|
+
className?: string;
|
|
10
|
+
onClick?: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Unified image component for CMS media assets. Always renders a sized,
|
|
14
|
+
* compressed imgix URL (with a srcSet for high-DPI screens) so full-size
|
|
15
|
+
* originals are never downloaded.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SwoopImage: React.FC<SwoopImageProps>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
2
|
+
var t = {};
|
|
3
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
4
|
+
t[p] = s[p];
|
|
5
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
6
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
7
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
8
|
+
t[p[i]] = s[p[i]];
|
|
9
|
+
}
|
|
10
|
+
return t;
|
|
11
|
+
};
|
|
12
|
+
import React, { useState } from "react";
|
|
13
|
+
import { Box, Typography } from "@mui/material";
|
|
14
|
+
import BrokenImageIcon from "@mui/icons-material/BrokenImage";
|
|
15
|
+
import { buildImgixUrl, buildImgixSrcSet } from "../util/imgix";
|
|
16
|
+
/**
|
|
17
|
+
* Unified image component for CMS media assets. Always renders a sized,
|
|
18
|
+
* compressed imgix URL (with a srcSet for high-DPI screens) so full-size
|
|
19
|
+
* originals are never downloaded.
|
|
20
|
+
*/
|
|
21
|
+
export const SwoopImage = (_a) => {
|
|
22
|
+
var _b;
|
|
23
|
+
var { asset, alt, dprs, style, className, onClick } = _a, imgixOptions = __rest(_a, ["asset", "alt", "dprs", "style", "className", "onClick"]);
|
|
24
|
+
const [errored, setErrored] = useState(false);
|
|
25
|
+
const src = buildImgixUrl(asset, imgixOptions);
|
|
26
|
+
if (!src || errored) {
|
|
27
|
+
return (React.createElement(Box, { className: className, style: style, onClick: onClick, sx: {
|
|
28
|
+
display: "flex",
|
|
29
|
+
flexDirection: "column",
|
|
30
|
+
alignItems: "center",
|
|
31
|
+
justifyContent: "center",
|
|
32
|
+
bgcolor: "grey.100",
|
|
33
|
+
color: "text.secondary",
|
|
34
|
+
width: imgixOptions.w,
|
|
35
|
+
height: imgixOptions.h,
|
|
36
|
+
minWidth: 60,
|
|
37
|
+
minHeight: 60,
|
|
38
|
+
} },
|
|
39
|
+
React.createElement(BrokenImageIcon, { fontSize: "small" }),
|
|
40
|
+
React.createElement(Typography, { variant: "caption" }, "Image unavailable")));
|
|
41
|
+
}
|
|
42
|
+
return (React.createElement("img", { className: className, src: src, srcSet: buildImgixSrcSet(asset, imgixOptions, dprs), alt: (_b = alt !== null && alt !== void 0 ? alt : asset.title) !== null && _b !== void 0 ? _b : "", width: imgixOptions.w, height: imgixOptions.h, loading: "lazy", style: Object.assign({ objectFit: "cover" }, style), onClick: onClick, onError: () => setErrored(true) }));
|
|
43
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { MediaAsset } from "../types/mediaAsset";
|
|
3
|
+
export interface MediaPickerGetOptions {
|
|
4
|
+
maxSelect?: number;
|
|
5
|
+
initialRegion?: string;
|
|
6
|
+
}
|
|
7
|
+
interface MediaPickerExports {
|
|
8
|
+
get: (options?: MediaPickerGetOptions) => Promise<MediaAsset[]>;
|
|
9
|
+
}
|
|
10
|
+
interface Props {
|
|
11
|
+
children?: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
export declare const useMediaPicker: () => MediaPickerExports;
|
|
14
|
+
export declare const MediaPickerProvider: React.FC<Props>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import React, { createContext, useContext, useRef, useState } from "react";
|
|
11
|
+
import MediaPickerModal from "../components/MediaPicker";
|
|
12
|
+
const MediaPickerContext = createContext(undefined);
|
|
13
|
+
export const useMediaPicker = () => {
|
|
14
|
+
const picker = useContext(MediaPickerContext);
|
|
15
|
+
if (!picker)
|
|
16
|
+
throw new Error("useMediaPicker used from outside MediaPickerProvider");
|
|
17
|
+
return picker;
|
|
18
|
+
};
|
|
19
|
+
export const MediaPickerProvider = ({ children }) => {
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const [options, setOptions] = useState({});
|
|
22
|
+
const changeProp = useRef(() => { });
|
|
23
|
+
const closeProp = useRef(() => { });
|
|
24
|
+
const getSelectedAssets = (...args_1) => __awaiter(void 0, [...args_1], void 0, function* (getOptions = {}) {
|
|
25
|
+
setOptions(getOptions);
|
|
26
|
+
setOpen(true);
|
|
27
|
+
return new Promise((res, rej) => {
|
|
28
|
+
// In case of old refs being called (so i dont need to explicitly remove them)
|
|
29
|
+
let done = false;
|
|
30
|
+
changeProp.current = (assets) => {
|
|
31
|
+
if (done)
|
|
32
|
+
return;
|
|
33
|
+
done = true;
|
|
34
|
+
res(assets);
|
|
35
|
+
setOpen(false);
|
|
36
|
+
};
|
|
37
|
+
closeProp.current = () => {
|
|
38
|
+
if (done)
|
|
39
|
+
return;
|
|
40
|
+
done = true;
|
|
41
|
+
rej();
|
|
42
|
+
setOpen(false);
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
return (React.createElement(MediaPickerContext.Provider, { value: { get: getSelectedAssets } },
|
|
47
|
+
React.createElement(MediaPickerModal, { open: open, onClose: () => closeProp.current(), onSelect: (assets) => changeProp.current(assets), maxSelect: options.maxSelect, initialRegion: options.initialRegion }),
|
|
48
|
+
children));
|
|
49
|
+
};
|
|
@@ -10,5 +10,10 @@ export { MasterSchema, Stage } from "./schema/formSchemaTypes";
|
|
|
10
10
|
export { FORM_BUILDER_JSON_SCHEMA } from "./schema/formBuilders/formBuilderJsonSchema";
|
|
11
11
|
export { FORM_BUILDER_UI_SCHEMA } from "./schema/formBuilders/formBuilderUiSchema";
|
|
12
12
|
export * from "./contexts/ComponentPickerContext";
|
|
13
|
+
export * from "./contexts/MediaPickerContext";
|
|
14
|
+
export * from "./components/SwoopImage";
|
|
15
|
+
export * from "./types/mediaAsset";
|
|
16
|
+
export * from "./util/imgix";
|
|
17
|
+
export { fetchMediaAssets, fetchMediaTags } from "./util/api";
|
|
13
18
|
export * from "./consts/system";
|
|
14
19
|
export * from "./consts/templateChipStyles";
|
package/dist/rendering/index.js
CHANGED
|
@@ -10,5 +10,10 @@ export { Stage } from "./schema/formSchemaTypes";
|
|
|
10
10
|
export { FORM_BUILDER_JSON_SCHEMA } from "./schema/formBuilders/formBuilderJsonSchema";
|
|
11
11
|
export { FORM_BUILDER_UI_SCHEMA } from "./schema/formBuilders/formBuilderUiSchema";
|
|
12
12
|
export * from "./contexts/ComponentPickerContext";
|
|
13
|
+
export * from "./contexts/MediaPickerContext";
|
|
14
|
+
export * from "./components/SwoopImage";
|
|
15
|
+
export * from "./types/mediaAsset";
|
|
16
|
+
export * from "./util/imgix";
|
|
17
|
+
export { fetchMediaAssets, fetchMediaTags } from "./util/api";
|
|
13
18
|
export * from "./consts/system";
|
|
14
19
|
export * from "./consts/templateChipStyles";
|
|
@@ -6,6 +6,8 @@ import "../../renderers/Debug";
|
|
|
6
6
|
import "../../renderers/Example";
|
|
7
7
|
import "../../renderers/Image/ImageForm";
|
|
8
8
|
import "../../renderers/Image/ImagePresentation";
|
|
9
|
+
import "../../renderers/MediaGallery/MediaGalleryForm";
|
|
10
|
+
import "../../renderers/MediaGallery/MediaGalleryPresentation";
|
|
9
11
|
import "../../renderers/MultiEnum";
|
|
10
12
|
import "../../renderers/NestedObjectArray";
|
|
11
13
|
import "../../renderers/StagedText";
|
|
@@ -8,6 +8,8 @@ import "../../renderers/Debug";
|
|
|
8
8
|
import "../../renderers/Example";
|
|
9
9
|
import "../../renderers/Image/ImageForm";
|
|
10
10
|
import "../../renderers/Image/ImagePresentation";
|
|
11
|
+
import "../../renderers/MediaGallery/MediaGalleryForm";
|
|
12
|
+
import "../../renderers/MediaGallery/MediaGalleryPresentation";
|
|
11
13
|
import "../../renderers/MultiEnum";
|
|
12
14
|
import "../../renderers/NestedObjectArray";
|
|
13
15
|
import "../../renderers/StagedText";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { MediaAsset } from "../../types/mediaAsset";
|
|
3
|
+
export declare const FormMediaGallery: React.FC<{
|
|
4
|
+
value: MediaAsset[];
|
|
5
|
+
onChange: (assets: MediaAsset[]) => void;
|
|
6
|
+
label: string;
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
maxItems?: number;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Button, IconButton, Paper, Typography } from "@mui/material";
|
|
3
|
+
import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternate";
|
|
4
|
+
import ClearIcon from "@mui/icons-material/Clear";
|
|
5
|
+
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
|
6
|
+
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
|
7
|
+
import { ComponentPool } from "../../registry/types";
|
|
8
|
+
import { registerComponent } from "../../registry/components";
|
|
9
|
+
import { useMediaPicker } from "../../contexts/MediaPickerContext";
|
|
10
|
+
import { SwoopImage } from "../../components/SwoopImage";
|
|
11
|
+
export const FormMediaGallery = ({ value, onChange, label, enabled, maxItems }) => {
|
|
12
|
+
const { get } = useMediaPicker();
|
|
13
|
+
const atMax = maxItems !== undefined && value.length >= maxItems;
|
|
14
|
+
const remaining = maxItems !== undefined ? maxItems - value.length : undefined;
|
|
15
|
+
const addImages = () => {
|
|
16
|
+
get({ maxSelect: remaining })
|
|
17
|
+
.then((assets) => {
|
|
18
|
+
// Skip assets already in the gallery
|
|
19
|
+
const existing = new Set(value.map((a) => a.assetId));
|
|
20
|
+
onChange([...value, ...assets.filter((a) => !existing.has(a.assetId))]);
|
|
21
|
+
})
|
|
22
|
+
.catch(() => { });
|
|
23
|
+
};
|
|
24
|
+
const removeAt = (index) => {
|
|
25
|
+
onChange(value.filter((_, i) => i !== index));
|
|
26
|
+
};
|
|
27
|
+
const moveTo = (index, target) => {
|
|
28
|
+
if (target < 0 || target >= value.length)
|
|
29
|
+
return;
|
|
30
|
+
const next = [...value];
|
|
31
|
+
[next[index], next[target]] = [next[target], next[index]];
|
|
32
|
+
onChange(next);
|
|
33
|
+
};
|
|
34
|
+
return (React.createElement(Paper, { sx: { p: 2, mb: 1 } },
|
|
35
|
+
React.createElement(Typography, { sx: { mb: 1 } }, label),
|
|
36
|
+
React.createElement(Box, { sx: { display: "flex", gap: 1.5, flexWrap: "wrap" } },
|
|
37
|
+
value.map((asset, index) => (React.createElement(Box, { key: asset.assetId, sx: { position: "relative", width: 160 } },
|
|
38
|
+
React.createElement(SwoopImage, { asset: asset, w: 160, h: 110, fit: "crop", q: 40, style: { borderRadius: 4, display: "block" } }),
|
|
39
|
+
React.createElement(Typography, { variant: "caption", noWrap: true, display: "block" }, asset.title),
|
|
40
|
+
enabled && (React.createElement(Box, { sx: {
|
|
41
|
+
position: "absolute",
|
|
42
|
+
top: 2,
|
|
43
|
+
right: 2,
|
|
44
|
+
display: "flex",
|
|
45
|
+
bgcolor: "rgba(255,255,255,0.85)",
|
|
46
|
+
borderRadius: 1,
|
|
47
|
+
} },
|
|
48
|
+
React.createElement(IconButton, { size: "small", disabled: index === 0, onClick: () => moveTo(index, index - 1) },
|
|
49
|
+
React.createElement(ChevronLeftIcon, { fontSize: "small" })),
|
|
50
|
+
React.createElement(IconButton, { size: "small", disabled: index === value.length - 1, onClick: () => moveTo(index, index + 1) },
|
|
51
|
+
React.createElement(ChevronRightIcon, { fontSize: "small" })),
|
|
52
|
+
React.createElement(IconButton, { size: "small", onClick: () => removeAt(index) },
|
|
53
|
+
React.createElement(ClearIcon, { fontSize: "small" }))))))),
|
|
54
|
+
enabled && !atMax && (React.createElement(Button, { onClick: addImages, variant: "outlined", sx: {
|
|
55
|
+
width: 160,
|
|
56
|
+
height: 110,
|
|
57
|
+
display: "flex",
|
|
58
|
+
flexDirection: "column",
|
|
59
|
+
gap: 0.5,
|
|
60
|
+
textTransform: "none",
|
|
61
|
+
} },
|
|
62
|
+
React.createElement(AddPhotoAlternateIcon, null),
|
|
63
|
+
value.length ? "Add more" : "Add images")))));
|
|
64
|
+
};
|
|
65
|
+
const FormRendererMediaGallery = ({ data, handleChange, path, label, enabled, schema, }) => {
|
|
66
|
+
const maxItems = schema.maxItems;
|
|
67
|
+
return (React.createElement(FormMediaGallery, { value: Array.isArray(data) ? data : [], onChange: (assets) => handleChange(path, assets), label: label, enabled: enabled, maxItems: maxItems }));
|
|
68
|
+
};
|
|
69
|
+
registerComponent("mediaGallery", FormRendererMediaGallery, ComponentPool.FORM);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Typography } from "@mui/material";
|
|
3
|
+
import { ComponentPool } from "../../registry/types";
|
|
4
|
+
import { registerComponent } from "../../registry/components";
|
|
5
|
+
import { SwoopImage } from "../../components/SwoopImage";
|
|
6
|
+
const PresentationMediaGallery = ({ data, label }) => {
|
|
7
|
+
const assets = Array.isArray(data) ? data : [];
|
|
8
|
+
if (!assets.length)
|
|
9
|
+
return null;
|
|
10
|
+
return (React.createElement(Box, { sx: { mb: 2 } },
|
|
11
|
+
label && React.createElement(Typography, { sx: { mb: 1 } }, label),
|
|
12
|
+
React.createElement(Box, { sx: {
|
|
13
|
+
display: "grid",
|
|
14
|
+
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
|
|
15
|
+
gap: 1.5,
|
|
16
|
+
} }, assets.map((asset) => (React.createElement(Box, { key: asset.assetId },
|
|
17
|
+
React.createElement(SwoopImage, { asset: asset, w: 480, h: 320, fit: "crop", style: { width: "100%", height: "auto", borderRadius: 4 } }),
|
|
18
|
+
asset.caption && (React.createElement(Typography, { variant: "caption", color: "text.secondary" }, asset.caption))))))));
|
|
19
|
+
};
|
|
20
|
+
registerComponent("mediaGallery", PresentationMediaGallery, ComponentPool.PRESENTATION);
|
|
@@ -94,6 +94,11 @@ const defaultConverter = (field, stage, omitTitle, isChild) => {
|
|
|
94
94
|
: {};
|
|
95
95
|
let title = omitTitle ? undefined : field.name;
|
|
96
96
|
let isReadonly = !isChild && !field.editableIn.map(Number).includes(stage);
|
|
97
|
+
// The generic path below drops the registered schema's `items`, so array-of-object
|
|
98
|
+
// types need their full registered schema spread in
|
|
99
|
+
if (field.fieldType === "mediaGallery") {
|
|
100
|
+
return Object.assign(Object.assign(Object.assign(Object.assign({}, (isReadonly && { readOnly: true })), { title }), schema.schema), directOptions);
|
|
101
|
+
}
|
|
97
102
|
if (field.fieldType === "operator") {
|
|
98
103
|
return Object.assign(Object.assign({}, (isReadonly && { readOnly: true })), { title, type: "array", "x-type": "operator", items: { type: "string" }, uniqueItems: true });
|
|
99
104
|
}
|
|
@@ -144,6 +144,30 @@ registerType("address", "Address", {
|
|
|
144
144
|
required: ["line1", "city", "postcode"],
|
|
145
145
|
}, {}, false);
|
|
146
146
|
registerType("image", "Image", { type: "string" }, {}, false);
|
|
147
|
+
registerType("mediaGallery", "Images (CMS)", {
|
|
148
|
+
type: "array",
|
|
149
|
+
items: {
|
|
150
|
+
type: "object",
|
|
151
|
+
properties: {
|
|
152
|
+
assetId: { type: "string" },
|
|
153
|
+
type: { type: "string", enum: ["image"] },
|
|
154
|
+
filename: { type: "string" },
|
|
155
|
+
title: { type: "string" },
|
|
156
|
+
caption: { type: "string" },
|
|
157
|
+
width: { type: "integer" },
|
|
158
|
+
height: { type: "integer" },
|
|
159
|
+
},
|
|
160
|
+
required: ["assetId", "type", "filename"],
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
},
|
|
163
|
+
}, {
|
|
164
|
+
type: "object",
|
|
165
|
+
title: "Image Options",
|
|
166
|
+
properties: {
|
|
167
|
+
minItems: { type: "integer", minimum: 0 },
|
|
168
|
+
maxItems: { type: "integer", minimum: 1 },
|
|
169
|
+
},
|
|
170
|
+
}, false);
|
|
147
171
|
registerType("person", "Person (Swooper)", { type: "string" }, {
|
|
148
172
|
type: "object",
|
|
149
173
|
title: "Person Options",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CMS media asset snapshot as stored in component field data.
|
|
3
|
+
* The assetId is a region-prefixed legacy CMS image ID (e.g. "ANT-123") kept
|
|
4
|
+
* for future syncing; the remaining fields let consumers render the image
|
|
5
|
+
* without an API lookup (URLs are built from filename via imgix helpers).
|
|
6
|
+
*/
|
|
7
|
+
export interface MediaAsset {
|
|
8
|
+
assetId: string;
|
|
9
|
+
type: "image";
|
|
10
|
+
filename: string;
|
|
11
|
+
title: string;
|
|
12
|
+
caption?: string;
|
|
13
|
+
width?: number;
|
|
14
|
+
height?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface MediaTag {
|
|
17
|
+
id: number;
|
|
18
|
+
title: string;
|
|
19
|
+
alias: string;
|
|
20
|
+
type: string;
|
|
21
|
+
}
|
|
22
|
+
/** Shape returned by swoop-data's GET /api/media-assets */
|
|
23
|
+
export interface MediaAssetSearchResult {
|
|
24
|
+
id: string;
|
|
25
|
+
type: string;
|
|
26
|
+
title: string;
|
|
27
|
+
caption?: string;
|
|
28
|
+
filename: string;
|
|
29
|
+
width?: number;
|
|
30
|
+
height?: number;
|
|
31
|
+
credit?: string;
|
|
32
|
+
copyright?: string;
|
|
33
|
+
qualityRating?: number;
|
|
34
|
+
resolutionRating?: number;
|
|
35
|
+
}
|
|
36
|
+
export declare const searchResultToMediaAsset: (result: MediaAssetSearchResult) => MediaAsset;
|
|
37
|
+
/** Regions with media libraries. Arctic is excluded — no longer managed. */
|
|
38
|
+
export declare const MEDIA_REGIONS: readonly [{
|
|
39
|
+
readonly code: "ANT";
|
|
40
|
+
readonly label: "Antarctica";
|
|
41
|
+
readonly query: "antarctica";
|
|
42
|
+
}, {
|
|
43
|
+
readonly code: "PAT";
|
|
44
|
+
readonly label: "Patagonia";
|
|
45
|
+
readonly query: "patagonia";
|
|
46
|
+
}];
|
|
47
|
+
export type MediaRegionCode = (typeof MEDIA_REGIONS)[number]["code"];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const searchResultToMediaAsset = (result) => (Object.assign(Object.assign(Object.assign({ assetId: result.id, type: "image", filename: result.filename, title: result.title }, (result.caption && { caption: result.caption })), (result.width && { width: result.width })), (result.height && { height: result.height })));
|
|
2
|
+
/** Regions with media libraries. Arctic is excluded — no longer managed. */
|
|
3
|
+
export const MEDIA_REGIONS = [
|
|
4
|
+
{ code: "ANT", label: "Antarctica", query: "antarctica" },
|
|
5
|
+
{ code: "PAT", label: "Patagonia", query: "patagonia" },
|
|
6
|
+
];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DTOComponentRead, Pagination } from "../../api/generated/core/exports";
|
|
2
|
+
import { MediaAssetSearchResult, MediaTag } from "../types/mediaAsset";
|
|
2
3
|
export declare const getAllPartners: () => Promise<any[]>;
|
|
3
4
|
export declare const fetchComponents: (filters?: Record<string, string>) => Promise<{
|
|
4
5
|
data: Array<DTOComponentRead & {
|
|
@@ -6,4 +7,15 @@ export declare const fetchComponents: (filters?: Record<string, string>) => Prom
|
|
|
6
7
|
}>;
|
|
7
8
|
pagination: Pagination;
|
|
8
9
|
}>;
|
|
10
|
+
export declare const fetchMediaAssets: (params: {
|
|
11
|
+
region: string;
|
|
12
|
+
q?: string;
|
|
13
|
+
tagId?: number;
|
|
14
|
+
page?: number;
|
|
15
|
+
itemsPerPage?: number;
|
|
16
|
+
}) => Promise<{
|
|
17
|
+
items: MediaAssetSearchResult[];
|
|
18
|
+
hasMore: boolean;
|
|
19
|
+
}>;
|
|
20
|
+
export declare const fetchMediaTags: (region: string) => Promise<MediaTag[]>;
|
|
9
21
|
export declare const getComponentsByIds: (componentIds: string[]) => Promise<DTOComponentRead[]>;
|
|
@@ -8,6 +8,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
10
|
import { OpenAPI } from "../../api/generated/core";
|
|
11
|
+
import { OpenAPI as SwoopOpenAPI } from "../../api/generated/swoop";
|
|
11
12
|
import { InternalServices } from "../../api/init";
|
|
12
13
|
const Regions = ["patagonia", "arctic", "antarctica"];
|
|
13
14
|
export const getAllPartners = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
@@ -50,6 +51,48 @@ export const fetchComponents = (filters) => __awaiter(void 0, void 0, void 0, fu
|
|
|
50
51
|
});
|
|
51
52
|
return { data: latestComponents, pagination: data.pagination };
|
|
52
53
|
});
|
|
54
|
+
const resolveSwoopToken = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
55
|
+
const token = SwoopOpenAPI.TOKEN;
|
|
56
|
+
return typeof token === "function"
|
|
57
|
+
? yield token({})
|
|
58
|
+
: token;
|
|
59
|
+
});
|
|
60
|
+
export const fetchMediaAssets = (params) => __awaiter(void 0, void 0, void 0, function* () {
|
|
61
|
+
var _a, _b;
|
|
62
|
+
const itemsPerPage = (_a = params.itemsPerPage) !== null && _a !== void 0 ? _a : 30;
|
|
63
|
+
const query = new URLSearchParams({ region: params.region });
|
|
64
|
+
if (params.q)
|
|
65
|
+
query.set("q", params.q);
|
|
66
|
+
if (params.tagId)
|
|
67
|
+
query.set("tagId", String(params.tagId));
|
|
68
|
+
query.set("page", String((_b = params.page) !== null && _b !== void 0 ? _b : 1));
|
|
69
|
+
query.set("itemsPerPage", String(itemsPerPage));
|
|
70
|
+
const token = yield resolveSwoopToken();
|
|
71
|
+
const response = yield fetch(`${SwoopOpenAPI.BASE}/api/media-assets?${query}`, {
|
|
72
|
+
headers: {
|
|
73
|
+
Authorization: `Bearer ${token}`,
|
|
74
|
+
Accept: "application/ld+json",
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok)
|
|
78
|
+
throw new Error(`Failed to fetch media assets: ${response.status}`);
|
|
79
|
+
const data = yield response.json();
|
|
80
|
+
const items = (data.member || []);
|
|
81
|
+
return { items, hasMore: items.length === itemsPerPage };
|
|
82
|
+
});
|
|
83
|
+
export const fetchMediaTags = (region) => __awaiter(void 0, void 0, void 0, function* () {
|
|
84
|
+
const token = yield resolveSwoopToken();
|
|
85
|
+
const response = yield fetch(`${SwoopOpenAPI.BASE}/api/media-tags?region=${encodeURIComponent(region)}`, {
|
|
86
|
+
headers: {
|
|
87
|
+
Authorization: `Bearer ${token}`,
|
|
88
|
+
Accept: "application/json",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok)
|
|
92
|
+
throw new Error(`Failed to fetch media tags: ${response.status}`);
|
|
93
|
+
const data = yield response.json();
|
|
94
|
+
return (data.mediaTags || []);
|
|
95
|
+
});
|
|
53
96
|
export const getComponentsByIds = (componentIds) => __awaiter(void 0, void 0, void 0, function* () {
|
|
54
97
|
const uniqueIds = Array.from(new Set(componentIds.filter(Boolean)));
|
|
55
98
|
if (!uniqueIds.length)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MediaAsset, MediaRegionCode } from "../types/mediaAsset";
|
|
2
|
+
/**
|
|
3
|
+
* Canonical imgix source domains per region. CMS-migrated files sit at the
|
|
4
|
+
* bucket root, so a full URL is https://{domain}/{filename}?{params}.
|
|
5
|
+
*/
|
|
6
|
+
export declare const IMGIX_DOMAINS: Record<MediaRegionCode, string>;
|
|
7
|
+
export interface ImgixOptions {
|
|
8
|
+
w?: number;
|
|
9
|
+
h?: number;
|
|
10
|
+
q?: number;
|
|
11
|
+
dpr?: number;
|
|
12
|
+
fit?: "crop" | "clip" | "max" | "min" | "scale" | "fill" | "facearea";
|
|
13
|
+
crop?: string;
|
|
14
|
+
auto?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const regionFromAssetId: (assetId: string) => MediaRegionCode | undefined;
|
|
17
|
+
/** Build a sized imgix URL for a CMS media asset. */
|
|
18
|
+
export declare const buildImgixUrl: (asset: Pick<MediaAsset, "assetId" | "filename">, opts?: ImgixOptions) => string;
|
|
19
|
+
export declare const buildImgixSrcSet: (asset: Pick<MediaAsset, "assetId" | "filename">, opts?: ImgixOptions, dprs?: number[]) => string;
|
|
20
|
+
/**
|
|
21
|
+
* Apply imgix sizing params to an existing full image URL (replaces any
|
|
22
|
+
* query string already present). For assets that are stored as plain URLs
|
|
23
|
+
* rather than MediaAsset objects.
|
|
24
|
+
*/
|
|
25
|
+
export declare const withImgixParams: (src: string, opts?: ImgixOptions) => string;
|
|
26
|
+
export declare const imgixSrcSetFromUrl: (src: string, opts?: ImgixOptions, dprs?: number[]) => string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical imgix source domains per region. CMS-migrated files sit at the
|
|
3
|
+
* bucket root, so a full URL is https://{domain}/{filename}?{params}.
|
|
4
|
+
*/
|
|
5
|
+
export const IMGIX_DOMAINS = {
|
|
6
|
+
ANT: "imgix.swoop-antarctica.com",
|
|
7
|
+
PAT: "imgix.swoop-patagonia.com",
|
|
8
|
+
};
|
|
9
|
+
const IMGIX_DEFAULTS = { auto: "format,compress", q: 60 };
|
|
10
|
+
export const regionFromAssetId = (assetId) => {
|
|
11
|
+
var _a;
|
|
12
|
+
const prefix = (_a = assetId === null || assetId === void 0 ? void 0 : assetId.split("-")[0]) === null || _a === void 0 ? void 0 : _a.toUpperCase();
|
|
13
|
+
return prefix && prefix in IMGIX_DOMAINS
|
|
14
|
+
? prefix
|
|
15
|
+
: undefined;
|
|
16
|
+
};
|
|
17
|
+
const buildImgixParams = (opts) => {
|
|
18
|
+
const merged = Object.assign(Object.assign({}, IMGIX_DEFAULTS), opts);
|
|
19
|
+
const params = new URLSearchParams();
|
|
20
|
+
Object.entries(merged).forEach(([key, value]) => {
|
|
21
|
+
if (value !== undefined && value !== null && value !== "")
|
|
22
|
+
params.set(key, String(value));
|
|
23
|
+
});
|
|
24
|
+
return params.toString();
|
|
25
|
+
};
|
|
26
|
+
/** Build a sized imgix URL for a CMS media asset. */
|
|
27
|
+
export const buildImgixUrl = (asset, opts) => {
|
|
28
|
+
const region = regionFromAssetId(asset.assetId);
|
|
29
|
+
if (!region || !asset.filename)
|
|
30
|
+
return "";
|
|
31
|
+
return `https://${IMGIX_DOMAINS[region]}/${encodeURIComponent(asset.filename)}?${buildImgixParams(opts)}`;
|
|
32
|
+
};
|
|
33
|
+
export const buildImgixSrcSet = (asset, opts, dprs = [1, 2]) => dprs
|
|
34
|
+
.map((dpr) => `${buildImgixUrl(asset, Object.assign(Object.assign({}, opts), { dpr }))} ${dpr}x`)
|
|
35
|
+
.join(", ");
|
|
36
|
+
/**
|
|
37
|
+
* Apply imgix sizing params to an existing full image URL (replaces any
|
|
38
|
+
* query string already present). For assets that are stored as plain URLs
|
|
39
|
+
* rather than MediaAsset objects.
|
|
40
|
+
*/
|
|
41
|
+
export const withImgixParams = (src, opts) => {
|
|
42
|
+
if (!src)
|
|
43
|
+
return src;
|
|
44
|
+
return `${src.split("?")[0]}?${buildImgixParams(opts)}`;
|
|
45
|
+
};
|
|
46
|
+
export const imgixSrcSetFromUrl = (src, opts, dprs = [1, 2]) => dprs
|
|
47
|
+
.map((dpr) => `${withImgixParams(src, Object.assign(Object.assign({}, opts), { dpr }))} ${dpr}x`)
|
|
48
|
+
.join(", ");
|