@firecms/media_manager 3.1.0-canary.02232f4

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 (48) hide show
  1. package/LICENSE +6 -0
  2. package/dist/MediaManagerProvider.d.ts +14 -0
  3. package/dist/components/MediaAssetCard.d.ts +13 -0
  4. package/dist/components/MediaAssetDetails.d.ts +11 -0
  5. package/dist/components/MediaLibraryCard.d.ts +8 -0
  6. package/dist/components/MediaLibraryView.d.ts +9 -0
  7. package/dist/components/MediaUploadDialog.d.ts +13 -0
  8. package/dist/components/index.d.ts +5 -0
  9. package/dist/index.d.ts +5 -0
  10. package/dist/index.es.js +1741 -0
  11. package/dist/index.es.js.map +1 -0
  12. package/dist/index.umd.js +1737 -0
  13. package/dist/index.umd.js.map +1 -0
  14. package/dist/locales/de.d.ts +45 -0
  15. package/dist/locales/en.d.ts +45 -0
  16. package/dist/locales/es.d.ts +45 -0
  17. package/dist/locales/fr.d.ts +45 -0
  18. package/dist/locales/hi.d.ts +45 -0
  19. package/dist/locales/it.d.ts +45 -0
  20. package/dist/locales/pt.d.ts +45 -0
  21. package/dist/types/config.d.ts +87 -0
  22. package/dist/types/controller.d.ts +66 -0
  23. package/dist/types/index.d.ts +3 -0
  24. package/dist/types/media_asset.d.ts +70 -0
  25. package/dist/useMediaManagerController.d.ts +18 -0
  26. package/dist/useMediaManagerPlugin.d.ts +23 -0
  27. package/package.json +58 -0
  28. package/src/MediaManagerProvider.tsx +34 -0
  29. package/src/components/MediaAssetCard.tsx +134 -0
  30. package/src/components/MediaAssetDetails.tsx +353 -0
  31. package/src/components/MediaLibraryCard.tsx +58 -0
  32. package/src/components/MediaLibraryView.tsx +196 -0
  33. package/src/components/MediaUploadDialog.tsx +266 -0
  34. package/src/components/index.ts +5 -0
  35. package/src/index.ts +10 -0
  36. package/src/locales/de.ts +45 -0
  37. package/src/locales/en.ts +45 -0
  38. package/src/locales/es.ts +45 -0
  39. package/src/locales/fr.ts +45 -0
  40. package/src/locales/hi.ts +45 -0
  41. package/src/locales/it.ts +45 -0
  42. package/src/locales/pt.ts +45 -0
  43. package/src/types/config.ts +100 -0
  44. package/src/types/controller.ts +79 -0
  45. package/src/types/index.ts +3 -0
  46. package/src/types/media_asset.ts +84 -0
  47. package/src/useMediaManagerController.tsx +387 -0
  48. package/src/useMediaManagerPlugin.tsx +114 -0
@@ -0,0 +1,353 @@
1
+ import React, { useCallback, useState } from "react";
2
+ import { useCreateFormex } from "@firecms/formex";
3
+ import {
4
+ Button,
5
+ Typography,
6
+ cls,
7
+ CloseIcon,
8
+ DeleteIcon,
9
+ DownloadIcon,
10
+ IconButton,
11
+ TextField,
12
+ Chip,
13
+ CircularProgress,
14
+ Dialog,
15
+ DialogActions,
16
+ DialogContent
17
+ } from "@firecms/ui";
18
+ import { useSnackbarController, useStorageSource, useTranslation } from "@firecms/core";
19
+ import { MediaAsset } from "../types";
20
+
21
+ export interface MediaAssetDetailsProps {
22
+ asset: MediaAsset;
23
+ onClose: () => void;
24
+ onUpdate: (assetId: string, data: Partial<MediaAsset>) => Promise<void>;
25
+ onDelete: (assetId: string) => Promise<void>;
26
+ }
27
+
28
+ /**
29
+ * Side panel component for viewing and editing media asset details.
30
+ */
31
+ export function MediaAssetDetails({
32
+ asset,
33
+ onClose,
34
+ onUpdate,
35
+ onDelete
36
+ }: MediaAssetDetailsProps) {
37
+ const snackbarController = useSnackbarController();
38
+ const storageSource = useStorageSource();
39
+ const [saving, setSaving] = useState(false);
40
+ const [deleting, setDeleting] = useState(false);
41
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
42
+ const [tagInput, setTagInput] = useState("");
43
+ const { t } = useTranslation();
44
+
45
+ const { values, setFieldValue, dirty } = useCreateFormex<Partial<MediaAsset>>({
46
+ initialValues: {
47
+ title: asset.title ?? "",
48
+ altText: asset.altText ?? "",
49
+ caption: asset.caption ?? "",
50
+ tags: asset.tags ?? []
51
+ }
52
+ });
53
+
54
+ const handleSave = useCallback(async () => {
55
+ setSaving(true);
56
+ try {
57
+ await onUpdate(asset.id, values);
58
+ snackbarController.open({
59
+ type: "success",
60
+ message: t("media_asset_updated")
61
+ });
62
+ } catch (error) {
63
+ snackbarController.open({
64
+ type: "error",
65
+ message: t("media_error_updating", { message: error instanceof Error ? error.message : String(error) })
66
+ });
67
+ } finally {
68
+ setSaving(false);
69
+ }
70
+ }, [asset.id, values, onUpdate, snackbarController]);
71
+
72
+ const handleDelete = useCallback(async () => {
73
+ setDeleting(true);
74
+ try {
75
+ await onDelete(asset.id);
76
+ snackbarController.open({
77
+ type: "success",
78
+ message: t("media_asset_deleted")
79
+ });
80
+ onClose();
81
+ } catch (error) {
82
+ snackbarController.open({
83
+ type: "error",
84
+ message: t("media_error_deleting", { message: error instanceof Error ? error.message : String(error) })
85
+ });
86
+ } finally {
87
+ setDeleting(false);
88
+ setDeleteDialogOpen(false);
89
+ }
90
+ }, [asset.id, onDelete, snackbarController, onClose]);
91
+
92
+ const handleDownload = useCallback(async () => {
93
+ try {
94
+ const downloadConfig = await storageSource.getDownloadURL(asset.storagePath, asset.bucket);
95
+ if (downloadConfig.url) {
96
+ window.open(downloadConfig.url, "_blank");
97
+ }
98
+ } catch (error) {
99
+ snackbarController.open({
100
+ type: "error",
101
+ message: t("media_error_getting_url")
102
+ });
103
+ }
104
+ }, [asset, storageSource, snackbarController]);
105
+
106
+ const handleAddTag = useCallback(() => {
107
+ const tag = tagInput.trim();
108
+ if (tag && !values.tags?.includes(tag)) {
109
+ setFieldValue("tags", [...(values.tags ?? []), tag]);
110
+ setTagInput("");
111
+ }
112
+ }, [tagInput, values.tags, setFieldValue]);
113
+
114
+ const handleRemoveTag = useCallback((tagToRemove: string) => {
115
+ setFieldValue("tags", values.tags?.filter((t: string) => t !== tagToRemove) ?? []);
116
+ }, [values.tags, setFieldValue]);
117
+
118
+ const formatSize = (bytes: number): string => {
119
+ if (bytes < 1024) return `${bytes} B`;
120
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
121
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
122
+ };
123
+
124
+ const formatDate = (date: Date): string => {
125
+ return new Intl.DateTimeFormat(undefined, {
126
+ year: "numeric",
127
+ month: "short",
128
+ day: "numeric"
129
+ }).format(date);
130
+ };
131
+
132
+ const isImage = asset.mimeType.startsWith("image/");
133
+ const isVideo = asset.mimeType.startsWith("video/");
134
+
135
+ return (
136
+ <>
137
+ <div className={cls(
138
+ "fixed inset-y-0 right-0 w-full sm:w-96 lg:w-[480px]",
139
+ "bg-surface-50 dark:bg-surface-900",
140
+ "border-l border-surface-accent-200 dark:border-surface-accent-700",
141
+ "shadow-xl z-50",
142
+ "flex flex-col",
143
+ "animate-slide-in-right"
144
+ )}>
145
+ {/* Header */}
146
+ <div className="flex items-center justify-between p-4 border-b border-surface-accent-200 dark:border-surface-accent-700">
147
+ <Typography variant="subtitle1" className="font-medium truncate flex-1 mr-2">
148
+ {asset.title || asset.fileName}
149
+ </Typography>
150
+ <div className="flex items-center gap-1">
151
+ <IconButton onClick={handleDownload}>
152
+ <DownloadIcon size="small" />
153
+ </IconButton>
154
+ <IconButton
155
+ onClick={() => setDeleteDialogOpen(true)}
156
+ className="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
157
+ >
158
+ <DeleteIcon size="small" />
159
+ </IconButton>
160
+ <IconButton onClick={onClose}>
161
+ <CloseIcon size="small" />
162
+ </IconButton>
163
+ </div>
164
+ </div>
165
+
166
+ {/* Preview */}
167
+ <div className="p-4 bg-surface-accent-100 dark:bg-surface-accent-800 flex items-center justify-center min-h-48 max-h-64">
168
+ {isImage && asset.downloadURL ? (
169
+ <img
170
+ src={asset.downloadURL}
171
+ alt={asset.altText || asset.fileName}
172
+ className="max-w-full max-h-full object-contain"
173
+ />
174
+ ) : isVideo && asset.downloadURL ? (
175
+ <video
176
+ src={asset.downloadURL}
177
+ className="max-w-full max-h-full"
178
+ controls
179
+ />
180
+ ) : (
181
+ <div className="text-surface-accent-400">
182
+ {t("media_preview_not_available")}
183
+ </div>
184
+ )}
185
+ </div>
186
+
187
+ {/* Content */}
188
+ <div className="flex-1 overflow-auto p-4 space-y-4">
189
+ {/* Metadata */}
190
+ <div className="grid grid-cols-2 gap-3">
191
+ {asset.dimensions && (
192
+ <div>
193
+ <Typography variant="caption" className="text-surface-accent-500">
194
+ {t("media_dimensions")}
195
+ </Typography>
196
+ <Typography variant="body2">
197
+ {asset.dimensions.width} × {asset.dimensions.height} px
198
+ </Typography>
199
+ </div>
200
+ )}
201
+ <div>
202
+ <Typography variant="caption" className="text-surface-accent-500">
203
+ {t("media_size")}
204
+ </Typography>
205
+ <Typography variant="body2">
206
+ {formatSize(asset.size)}
207
+ </Typography>
208
+ </div>
209
+ <div>
210
+ <Typography variant="caption" className="text-surface-accent-500">
211
+ {t("media_type")}
212
+ </Typography>
213
+ <Typography variant="body2">
214
+ {asset.mimeType}
215
+ </Typography>
216
+ </div>
217
+ <div>
218
+ <Typography variant="caption" className="text-surface-accent-500">
219
+ {t("media_created")}
220
+ </Typography>
221
+ <Typography variant="body2">
222
+ {formatDate(asset.createdAt)}
223
+ </Typography>
224
+ </div>
225
+ </div>
226
+
227
+ <hr className="border-surface-accent-200 dark:border-surface-accent-700" />
228
+
229
+ {/* Editable Fields */}
230
+ <TextField
231
+ label={t("media_file_name")}
232
+ value={asset.fileName}
233
+ disabled
234
+ size="small"
235
+ />
236
+
237
+ <TextField
238
+ label={t("media_title")}
239
+ value={values.title ?? ""}
240
+ onChange={(e) => setFieldValue("title", e.target.value)}
241
+ size="small"
242
+ />
243
+
244
+ <div>
245
+ <TextField
246
+ label={t("media_alt_text")}
247
+ value={values.altText ?? ""}
248
+ onChange={(e) => setFieldValue("altText", e.target.value)}
249
+ size="small"
250
+ />
251
+ <Typography variant="caption" className="text-surface-accent-500 mt-1">
252
+ {t("media_recommended_seo")}
253
+ </Typography>
254
+ </div>
255
+
256
+ <TextField
257
+ label={t("media_caption")}
258
+ value={values.caption ?? ""}
259
+ onChange={(e) => setFieldValue("caption", e.target.value)}
260
+ size="small"
261
+ multiline
262
+ />
263
+
264
+ {/* Tags */}
265
+ <div>
266
+ <Typography variant="caption" className="text-surface-accent-500 mb-1 block">
267
+ {t("media_tags")}
268
+ </Typography>
269
+ <div className="flex flex-wrap gap-1 mb-2">
270
+ {values.tags?.map((tag: string) => (
271
+ <Chip
272
+ key={tag}
273
+ size="small"
274
+ colorScheme="blueLighter"
275
+ onClick={() => handleRemoveTag(tag)}
276
+ >
277
+ {tag} ×
278
+ </Chip>
279
+ ))}
280
+ </div>
281
+ <div className="flex gap-2">
282
+ <TextField
283
+ placeholder={t("media_add_tag")}
284
+ value={tagInput}
285
+ onChange={(e) => setTagInput(e.target.value)}
286
+ size="small"
287
+ className="flex-1"
288
+ onKeyDown={(e) => {
289
+ if (e.key === "Enter") {
290
+ e.preventDefault();
291
+ handleAddTag();
292
+ }
293
+ }}
294
+ />
295
+ <Button
296
+ variant="text"
297
+ size="small"
298
+ onClick={handleAddTag}
299
+ disabled={!tagInput.trim()}
300
+ >
301
+ {t("media_add")}
302
+ </Button>
303
+ </div>
304
+ </div>
305
+ </div>
306
+
307
+ {/* Footer */}
308
+ <div className="flex-shrink-0 p-4 border-t border-surface-accent-200 dark:border-surface-accent-700">
309
+ <Button
310
+ variant="filled"
311
+ onClick={handleSave}
312
+ disabled={!dirty || saving}
313
+ className="w-full"
314
+ >
315
+ {saving ? <CircularProgress size="small" /> : t("media_save_changes")}
316
+ </Button>
317
+ </div>
318
+ </div>
319
+
320
+ {/* Delete Confirmation Dialog */}
321
+ <Dialog
322
+ open={deleteDialogOpen}
323
+ onOpenChange={setDeleteDialogOpen}
324
+ >
325
+ <DialogContent>
326
+ <Typography variant="subtitle1" className="font-medium mb-2">
327
+ {t("media_delete_asset")}
328
+ </Typography>
329
+ <Typography className="text-surface-accent-600 dark:text-surface-accent-400">
330
+ {t("media_delete_confirmation", { name: asset.title || asset.fileName })}
331
+ </Typography>
332
+ </DialogContent>
333
+ <DialogActions>
334
+ <Button
335
+ variant="text"
336
+ onClick={() => setDeleteDialogOpen(false)}
337
+ disabled={deleting}
338
+ >
339
+ {t("cancel")}
340
+ </Button>
341
+ <Button
342
+ variant="filled"
343
+ color="error"
344
+ onClick={handleDelete}
345
+ disabled={deleting}
346
+ >
347
+ {deleting ? <CircularProgress size="small" /> : t("delete")}
348
+ </Button>
349
+ </DialogActions>
350
+ </Dialog>
351
+ </>
352
+ );
353
+ }
@@ -0,0 +1,58 @@
1
+ import React from "react";
2
+ import { Link } from "react-router-dom";
3
+ import {
4
+ Card,
5
+ Typography,
6
+ cls,
7
+ ImageIcon
8
+ } from "@firecms/ui";
9
+ import { useTranslation } from "@firecms/core";
10
+
11
+ export interface MediaLibraryCardProps {
12
+ group?: string;
13
+ context?: unknown;
14
+ }
15
+
16
+ /**
17
+ * Card component displayed on the home page that links to the Media Library.
18
+ */
19
+ export function MediaLibraryCard({ group }: MediaLibraryCardProps) {
20
+ const { t } = useTranslation();
21
+
22
+ // Only render in the "Media" group
23
+ if (group !== "Media") return null;
24
+
25
+ return (
26
+ <Link to="/media" className="no-underline">
27
+ <Card
28
+ className={cls(
29
+ "p-4 cursor-pointer",
30
+ "hover:bg-surface-accent-100 dark:hover:bg-surface-accent-800",
31
+ "transition-colors duration-200",
32
+ "flex flex-col gap-2"
33
+ )}
34
+ >
35
+ <div className="flex items-center gap-3">
36
+ <div className={cls(
37
+ "w-10 h-10 rounded-lg",
38
+ "bg-primary/10 dark:bg-primary/20",
39
+ "flex items-center justify-center"
40
+ )}>
41
+ <ImageIcon className="text-primary" size="medium" />
42
+ </div>
43
+ <div className="flex-1">
44
+ <Typography variant="subtitle2" className="font-medium">
45
+ {t("media_library")}
46
+ </Typography>
47
+ <Typography
48
+ variant="caption"
49
+ className="text-surface-accent-600 dark:text-surface-accent-400"
50
+ >
51
+ {t("media_library_description")}
52
+ </Typography>
53
+ </div>
54
+ </div>
55
+ </Card>
56
+ </Link>
57
+ );
58
+ }
@@ -0,0 +1,196 @@
1
+ import React, { useCallback, useRef, useState } from "react";
2
+ import {
3
+ Button,
4
+ Container,
5
+ SearchBar,
6
+ Typography,
7
+ cls,
8
+ AddIcon,
9
+ CircularProgress,
10
+ RefreshIcon,
11
+ IconButton,
12
+ Tooltip,
13
+ AppsIcon,
14
+ Icon
15
+ } from "@firecms/ui";
16
+ import { useTranslation } from "@firecms/core";
17
+ import { useMediaManager } from "../MediaManagerProvider";
18
+ import { MediaAssetCard } from "./MediaAssetCard";
19
+ import { MediaAssetDetails } from "./MediaAssetDetails";
20
+ import { MediaUploadDialog } from "./MediaUploadDialog";
21
+
22
+ export interface MediaLibraryViewProps {
23
+ maxFileSize?: number;
24
+ acceptedMimeTypes?: string[];
25
+ }
26
+
27
+ /**
28
+ * Main view component for the Media Library.
29
+ * Displays a grid of assets with search, upload, and management capabilities.
30
+ */
31
+ export function MediaLibraryView({
32
+ maxFileSize,
33
+ acceptedMimeTypes
34
+ }: MediaLibraryViewProps) {
35
+ const controller = useMediaManager();
36
+ const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
37
+ const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
38
+ const fileInputRef = useRef<HTMLInputElement>(null);
39
+ const { t } = useTranslation();
40
+
41
+ const handleSearch = useCallback((query?: string) => {
42
+ controller.searchAssets(query ?? "");
43
+ }, [controller]);
44
+
45
+ const handleUploadClick = useCallback(() => {
46
+ setUploadDialogOpen(true);
47
+ }, []);
48
+
49
+ const handleFileSelect = useCallback(async (files: File[]) => {
50
+ for (const file of files) {
51
+ await controller.uploadFile(file);
52
+ }
53
+ setUploadDialogOpen(false);
54
+ }, [controller]);
55
+
56
+ const handleRefresh = useCallback(() => {
57
+ controller.refreshAssets();
58
+ }, [controller]);
59
+
60
+ return (
61
+ <div className="h-full flex flex-col overflow-hidden">
62
+ {/* Header */}
63
+ <div
64
+ className="flex-shrink-0 border-b border-surface-accent-200 dark:border-surface-accent-700 bg-surface-50 dark:bg-surface-900">
65
+ <Container maxWidth="6xl" className="py-4">
66
+ <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
67
+ <div className="flex items-center gap-3">
68
+ <Typography variant="h5" className="font-semibold">
69
+ {t("media_library")}
70
+ </Typography>
71
+ {controller.totalCount !== undefined && (
72
+ <Typography
73
+ variant="caption"
74
+ className="bg-surface-accent-100 dark:bg-surface-accent-800 px-2 py-0.5 rounded-full"
75
+ >
76
+ {t("media_assets_count", { count: controller.totalCount.toString() })}
77
+ </Typography>
78
+ )}
79
+ </div>
80
+
81
+ <div className="flex items-center gap-2 w-full sm:w-auto">
82
+ <SearchBar
83
+ onTextSearch={handleSearch}
84
+ placeholder={t("media_search_assets")}
85
+ className="flex-1 sm:w-64"
86
+ />
87
+
88
+ <div
89
+ className="flex items-center gap-1 border-l border-surface-accent-200 dark:border-surface-accent-700 pl-2 ml-2">
90
+ <Tooltip title={t("media_grid_view")}>
91
+ <IconButton
92
+ onClick={() => setViewMode("grid")}
93
+ className={cls(
94
+ viewMode === "grid" && "bg-surface-accent-100 dark:bg-surface-accent-800"
95
+ )}
96
+ >
97
+ <AppsIcon size="small"/>
98
+ </IconButton>
99
+ </Tooltip>
100
+ <Tooltip title={t("media_list_view")}>
101
+ <IconButton
102
+ onClick={() => setViewMode("list")}
103
+ className={cls(
104
+ viewMode === "list" && "bg-surface-accent-100 dark:bg-surface-accent-800"
105
+ )}
106
+ >
107
+ <Icon iconKey="list" size="small"/>
108
+ </IconButton>
109
+ </Tooltip>
110
+ </div>
111
+
112
+ <Tooltip title={t("media_refresh")}>
113
+ <IconButton onClick={handleRefresh} disabled={controller.loading}>
114
+ <RefreshIcon size="small"/>
115
+ </IconButton>
116
+ </Tooltip>
117
+
118
+ <Button
119
+ variant="filled"
120
+ onClick={handleUploadClick}
121
+ >
122
+ <AddIcon size="small"/>
123
+ {t("media_upload")}
124
+ </Button>
125
+ </div>
126
+ </div>
127
+ </Container>
128
+ </div>
129
+
130
+ {/* Content */}
131
+ <div className="flex-1 overflow-auto">
132
+ <Container maxWidth="6xl" className="py-6">
133
+ {controller.loading && controller.assets.length === 0 ? (
134
+ <div className="flex items-center justify-center h-64">
135
+ <CircularProgress/>
136
+ </div>
137
+ ) : controller.error ? (
138
+ <div className="flex flex-col items-center justify-center h-64 gap-4">
139
+ <Typography className="text-red-500">
140
+ {t("media_error_loading", { message: controller.error.message })}
141
+ </Typography>
142
+ <Button onClick={handleRefresh}>
143
+ {t("media_try_again")}
144
+ </Button>
145
+ </div>
146
+ ) : controller.assets.length === 0 ? (
147
+ <div className="flex flex-col items-center justify-center h-64 gap-4">
148
+ <Typography className="text-surface-accent-500">
149
+ {t("media_no_assets")}
150
+ </Typography>
151
+ <Button onClick={handleUploadClick}>
152
+ <AddIcon size="small"/>
153
+ {t("media_upload_first_file")}
154
+ </Button>
155
+ </div>
156
+ ) : (
157
+ <div className={cls(
158
+ viewMode === "grid"
159
+ ? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
160
+ : "flex flex-col gap-2"
161
+ )}>
162
+ {controller.assets.map(asset => (
163
+ <MediaAssetCard
164
+ key={asset.id}
165
+ asset={asset}
166
+ viewMode={viewMode}
167
+ onClick={() => controller.selectAsset(asset)}
168
+ selected={controller.selectedAsset?.id === asset.id}
169
+ />
170
+ ))}
171
+ </div>
172
+ )}
173
+ </Container>
174
+ </div>
175
+
176
+ {/* Details Panel */}
177
+ {controller.selectedAsset && (
178
+ <MediaAssetDetails
179
+ asset={controller.selectedAsset}
180
+ onClose={() => controller.selectAsset(undefined)}
181
+ onUpdate={controller.updateAsset}
182
+ onDelete={controller.deleteAsset}
183
+ />
184
+ )}
185
+
186
+ {/* Upload Dialog */}
187
+ <MediaUploadDialog
188
+ open={uploadDialogOpen}
189
+ onClose={() => setUploadDialogOpen(false)}
190
+ onUpload={handleFileSelect}
191
+ maxFileSize={maxFileSize}
192
+ acceptedMimeTypes={acceptedMimeTypes}
193
+ />
194
+ </div>
195
+ );
196
+ }