@kidecms/core 0.1.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 (93) hide show
  1. package/README.md +28 -0
  2. package/admin/components/AdminCard.astro +25 -0
  3. package/admin/components/AiGenerateButton.tsx +102 -0
  4. package/admin/components/AssetsGrid.tsx +711 -0
  5. package/admin/components/BlockEditor.tsx +996 -0
  6. package/admin/components/CheckboxField.tsx +31 -0
  7. package/admin/components/DocumentActions.tsx +317 -0
  8. package/admin/components/DocumentLock.tsx +54 -0
  9. package/admin/components/DocumentsDataTable.tsx +804 -0
  10. package/admin/components/FieldControl.astro +397 -0
  11. package/admin/components/FocalPointSelector.tsx +100 -0
  12. package/admin/components/ImageBrowseDialog.tsx +176 -0
  13. package/admin/components/ImagePicker.tsx +149 -0
  14. package/admin/components/InternalLinkPicker.tsx +80 -0
  15. package/admin/components/LiveHeading.tsx +17 -0
  16. package/admin/components/MobileSidebar.tsx +29 -0
  17. package/admin/components/RelationField.tsx +204 -0
  18. package/admin/components/RichTextEditor.tsx +685 -0
  19. package/admin/components/SelectField.tsx +65 -0
  20. package/admin/components/SidebarUserMenu.tsx +99 -0
  21. package/admin/components/SlugField.tsx +77 -0
  22. package/admin/components/TaxonomySelect.tsx +52 -0
  23. package/admin/components/Toast.astro +40 -0
  24. package/admin/components/TreeItemsEditor.tsx +790 -0
  25. package/admin/components/TreeSelect.tsx +166 -0
  26. package/admin/components/UnsavedGuard.tsx +181 -0
  27. package/admin/components/tree-utils.ts +86 -0
  28. package/admin/components/ui/alert-dialog.tsx +92 -0
  29. package/admin/components/ui/badge.tsx +83 -0
  30. package/admin/components/ui/button.tsx +53 -0
  31. package/admin/components/ui/card.tsx +70 -0
  32. package/admin/components/ui/checkbox.tsx +28 -0
  33. package/admin/components/ui/collapsible.tsx +26 -0
  34. package/admin/components/ui/command.tsx +88 -0
  35. package/admin/components/ui/dialog.tsx +92 -0
  36. package/admin/components/ui/dropdown-menu.tsx +259 -0
  37. package/admin/components/ui/input.tsx +20 -0
  38. package/admin/components/ui/label.tsx +20 -0
  39. package/admin/components/ui/popover.tsx +42 -0
  40. package/admin/components/ui/select.tsx +165 -0
  41. package/admin/components/ui/separator.tsx +21 -0
  42. package/admin/components/ui/sheet.tsx +104 -0
  43. package/admin/components/ui/skeleton.tsx +7 -0
  44. package/admin/components/ui/table.tsx +74 -0
  45. package/admin/components/ui/textarea.tsx +18 -0
  46. package/admin/components/ui/tooltip.tsx +52 -0
  47. package/admin/layouts/AdminLayout.astro +340 -0
  48. package/admin/lib/utils.ts +19 -0
  49. package/dist/admin.js +92 -0
  50. package/dist/ai.js +67 -0
  51. package/dist/api.js +827 -0
  52. package/dist/assets.js +163 -0
  53. package/dist/auth.js +132 -0
  54. package/dist/blocks.js +110 -0
  55. package/dist/content.js +29 -0
  56. package/dist/create-admin.js +23 -0
  57. package/dist/define.js +36 -0
  58. package/dist/generator.js +370 -0
  59. package/dist/image.js +69 -0
  60. package/dist/index.js +16 -0
  61. package/dist/integration.js +256 -0
  62. package/dist/locks.js +37 -0
  63. package/dist/richtext.js +1 -0
  64. package/dist/runtime.js +26 -0
  65. package/dist/schema.js +13 -0
  66. package/dist/seed.js +84 -0
  67. package/dist/values.js +102 -0
  68. package/middleware/auth.ts +100 -0
  69. package/package.json +102 -0
  70. package/routes/api/cms/[collection]/[...path].ts +366 -0
  71. package/routes/api/cms/ai/alt-text.ts +25 -0
  72. package/routes/api/cms/ai/seo.ts +25 -0
  73. package/routes/api/cms/ai/translate.ts +31 -0
  74. package/routes/api/cms/assets/[id].ts +82 -0
  75. package/routes/api/cms/assets/folders.ts +81 -0
  76. package/routes/api/cms/assets/index.ts +23 -0
  77. package/routes/api/cms/assets/upload.ts +112 -0
  78. package/routes/api/cms/auth/invite.ts +166 -0
  79. package/routes/api/cms/auth/login.ts +124 -0
  80. package/routes/api/cms/auth/logout.ts +33 -0
  81. package/routes/api/cms/auth/setup.ts +77 -0
  82. package/routes/api/cms/cron/publish.ts +33 -0
  83. package/routes/api/cms/img/[...path].ts +24 -0
  84. package/routes/api/cms/locks/[...path].ts +37 -0
  85. package/routes/api/cms/preview/render.ts +36 -0
  86. package/routes/api/cms/references/[collection]/[id].ts +60 -0
  87. package/routes/pages/admin/[...path].astro +1104 -0
  88. package/routes/pages/admin/assets/[id].astro +183 -0
  89. package/routes/pages/admin/assets/index.astro +58 -0
  90. package/routes/pages/admin/invite.astro +116 -0
  91. package/routes/pages/admin/login.astro +57 -0
  92. package/routes/pages/admin/setup.astro +91 -0
  93. package/virtual.d.ts +61 -0
@@ -0,0 +1,149 @@
1
+ import { useState, useRef, useCallback, useEffect } from "react";
2
+ import { ImagePlus, Loader2, Upload, X } from "lucide-react";
3
+ import { cn, thumbnail } from "../lib/utils";
4
+ import { Button } from "./ui/button";
5
+ import ImageBrowseDialog from "./ImageBrowseDialog";
6
+
7
+ type Props = {
8
+ name: string;
9
+ value?: string;
10
+ placeholder?: string;
11
+ onChange?: (value: string) => void;
12
+ };
13
+
14
+ export default function ImagePicker({ name, value: initialValue, onChange: onChangeProp }: Props) {
15
+ const [value, setValue] = useState(initialValue ?? "");
16
+ const [assetId, setAssetId] = useState<string | null>(null);
17
+ const [localPreview, setLocalPreview] = useState<string | null>(null);
18
+ const [uploading, setUploading] = useState(false);
19
+ const [open, setOpen] = useState(false);
20
+ const fileInputRef = useRef<HTMLInputElement>(null);
21
+ const hiddenRef = useRef<HTMLInputElement>(null);
22
+
23
+ // Resolve asset ID from URL on mount
24
+ useEffect(() => {
25
+ if (!value) return;
26
+ fetch(`/api/cms/assets?url=${encodeURIComponent(value)}`)
27
+ .then((res) => (res.ok ? res.json() : null))
28
+ .then((asset) => {
29
+ if (asset?._id) setAssetId(asset._id);
30
+ })
31
+ .catch(() => {});
32
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
33
+
34
+ // Notify the form of value changes so UnsavedGuard detects them
35
+ const prevValueRef = useRef(value);
36
+ useEffect(() => {
37
+ if (prevValueRef.current !== value) {
38
+ prevValueRef.current = value;
39
+ hiddenRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
40
+ }
41
+ }, [value]);
42
+
43
+ const handleUpload = useCallback(
44
+ async (file: File) => {
45
+ setUploading(true);
46
+ try {
47
+ setLocalPreview(URL.createObjectURL(file));
48
+
49
+ const formData = new FormData();
50
+ formData.append("file", file);
51
+ const res = await fetch("/api/cms/assets/upload", { method: "POST", body: formData });
52
+ if (!res.ok) throw new Error("Upload failed");
53
+ const asset = await res.json();
54
+ setValue(asset.url);
55
+ setAssetId(asset._id);
56
+ onChangeProp?.(asset.url);
57
+ } catch (e) {
58
+ console.error("Upload failed:", e);
59
+ setLocalPreview(null);
60
+ } finally {
61
+ setUploading(false);
62
+ }
63
+ },
64
+ [onChangeProp],
65
+ );
66
+
67
+ const handleFileChange = useCallback(
68
+ (e: React.ChangeEvent<HTMLInputElement>) => {
69
+ const file = e.target.files?.[0];
70
+ if (file) handleUpload(file);
71
+ },
72
+ [handleUpload],
73
+ );
74
+
75
+ const imgSrc = localPreview ?? thumbnail(value);
76
+ const isImage =
77
+ value && (value.match(/\.(jpg|jpeg|png|gif|webp|avif|svg)$/i) || value.startsWith("http") || localPreview);
78
+
79
+ return (
80
+ <div className="space-y-3">
81
+ <input ref={hiddenRef} type="hidden" name={name} value={value} />
82
+
83
+ {value && (
84
+ <div className="group relative inline-block">
85
+ {isImage ? (
86
+ <a
87
+ href={assetId ? `/admin/assets/${assetId}` : undefined}
88
+ target="_blank"
89
+ className={cn(
90
+ "block size-40 overflow-hidden rounded-lg border",
91
+ assetId && "hover:border-foreground/50 cursor-pointer",
92
+ )}
93
+ >
94
+ <img src={imgSrc} alt="" className="size-full object-cover" />
95
+ </a>
96
+ ) : (
97
+ <div className="bg-muted/30 flex size-40 items-center justify-center rounded-lg border">
98
+ <span className="text-muted-foreground truncate px-4 text-sm">{value}</span>
99
+ </div>
100
+ )}
101
+ <button
102
+ type="button"
103
+ title="Remove image"
104
+ onClick={() => {
105
+ if (localPreview) URL.revokeObjectURL(localPreview);
106
+ setLocalPreview(null);
107
+ setValue("");
108
+ setAssetId(null);
109
+ onChangeProp?.("");
110
+ }}
111
+ className="border-foreground/25 hover:border-foreground bg-background/80 absolute top-2 right-2 flex size-5 items-center justify-center rounded border opacity-0 backdrop-blur-sm transition-[opacity,border-color] group-hover:opacity-100"
112
+ >
113
+ <X className="size-3" />
114
+ </button>
115
+ </div>
116
+ )}
117
+
118
+ <div className="flex gap-2">
119
+ <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
120
+ <Button
121
+ type="button"
122
+ variant="outline"
123
+ className="text-foreground/70"
124
+ onClick={() => fileInputRef.current?.click()}
125
+ disabled={uploading}
126
+ >
127
+ {uploading ? <Loader2 className="size-3 animate-spin" /> : <Upload className="size-4 stroke-1" />}
128
+ Upload
129
+ </Button>
130
+ <Button type="button" variant="outline" className="text-foreground/70" onClick={() => setOpen(true)}>
131
+ <ImagePlus className="size-4 stroke-1" />
132
+ Browse
133
+ </Button>
134
+ </div>
135
+
136
+ <ImageBrowseDialog
137
+ open={open}
138
+ onOpenChange={setOpen}
139
+ onSelect={(asset) => {
140
+ if (localPreview) URL.revokeObjectURL(localPreview);
141
+ setLocalPreview(null);
142
+ setValue(asset.url);
143
+ setAssetId(asset._id);
144
+ onChangeProp?.(asset.url);
145
+ }}
146
+ />
147
+ </div>
148
+ );
149
+ }
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Check, ChevronsUpDown } from "lucide-react";
5
+ import { Button } from "./ui/button";
6
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./ui/command";
7
+ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
8
+ import { cn } from "../lib/utils";
9
+
10
+ export type LinkOptionGroup = {
11
+ collection: string;
12
+ label: string;
13
+ items: Array<{ id: string; label: string; href: string }>;
14
+ };
15
+
16
+ export default function InternalLinkPicker({
17
+ editHref,
18
+ linkOptions,
19
+ onSelect,
20
+ }: {
21
+ editHref: string;
22
+ linkOptions: LinkOptionGroup[];
23
+ onSelect: (item: { id: string; label: string; href: string }) => void;
24
+ }) {
25
+ const [open, setOpen] = React.useState(false);
26
+
27
+ const selectedLabel = React.useMemo(() => {
28
+ if (!editHref) return "";
29
+ for (const group of linkOptions) {
30
+ const found = group.items.find((item) => item.href === editHref);
31
+ if (found) return found.label;
32
+ }
33
+ return editHref;
34
+ }, [editHref, linkOptions]);
35
+
36
+ return (
37
+ <div className="min-w-0 flex-3">
38
+ <Popover open={open} onOpenChange={setOpen}>
39
+ <PopoverTrigger asChild>
40
+ <Button
41
+ variant="outline"
42
+ role="combobox"
43
+ aria-expanded={open}
44
+ className="h-7 w-full justify-between font-normal"
45
+ >
46
+ <span className={cn("truncate", !selectedLabel && "text-muted-foreground")}>
47
+ {selectedLabel || "Search documents..."}
48
+ </span>
49
+ <ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
50
+ </Button>
51
+ </PopoverTrigger>
52
+ <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
53
+ <Command>
54
+ <CommandInput placeholder="Search documents..." />
55
+ <CommandList>
56
+ <CommandEmpty>No documents found.</CommandEmpty>
57
+ {linkOptions.map((group) => (
58
+ <CommandGroup key={group.collection} heading={group.label}>
59
+ {group.items.map((item) => (
60
+ <CommandItem
61
+ key={item.id}
62
+ value={`${item.label} ${item.href}`}
63
+ onSelect={() => {
64
+ onSelect(item);
65
+ setOpen(false);
66
+ }}
67
+ >
68
+ <Check className={cn("size-4", editHref === item.href ? "opacity-100" : "opacity-0")} />
69
+ {item.label}
70
+ </CommandItem>
71
+ ))}
72
+ </CommandGroup>
73
+ ))}
74
+ </CommandList>
75
+ </Command>
76
+ </PopoverContent>
77
+ </Popover>
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,17 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export default function LiveHeading({ initial, inputName = "title" }: { initial: string; inputName?: string }) {
6
+ const [text, setText] = useState(initial);
7
+
8
+ useEffect(() => {
9
+ const input = document.querySelector<HTMLInputElement>(`[name="${inputName}"]`);
10
+ if (!input) return;
11
+ const handler = () => setText(input.value || initial);
12
+ input.addEventListener("input", handler);
13
+ return () => input.removeEventListener("input", handler);
14
+ }, [initial, inputName]);
15
+
16
+ return <>{text}</>;
17
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { Menu } from "lucide-react";
4
+ import { useState } from "react";
5
+
6
+ import { Button } from "./ui/button";
7
+ import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
8
+
9
+ export default function MobileSidebar({ children }: { children: React.ReactNode }) {
10
+ const [open, setOpen] = useState(false);
11
+
12
+ return (
13
+ <>
14
+ <Button variant="ghost" size="icon-sm" onClick={() => setOpen(true)}>
15
+ <Menu className="size-5" />
16
+ <span className="sr-only">Open menu</span>
17
+ </Button>
18
+
19
+ <Sheet open={open} onOpenChange={setOpen}>
20
+ <SheetContent side="left" className="w-68 p-0" showCloseButton={false}>
21
+ <SheetTitle className="sr-only">Navigation</SheetTitle>
22
+ <div className="flex h-full flex-col" onClick={() => setOpen(false)}>
23
+ {children}
24
+ </div>
25
+ </SheetContent>
26
+ </Sheet>
27
+ </>
28
+ );
29
+ }
@@ -0,0 +1,204 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
5
+
6
+ import { Button } from "./ui/button";
7
+ import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "./ui/command";
8
+ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
9
+ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "./ui/sheet";
10
+ import { cn } from "../lib/utils";
11
+
12
+ type Option = { value: string; label: string };
13
+
14
+ type Props = {
15
+ name: string;
16
+ value?: string;
17
+ hasMany?: boolean;
18
+ options: Option[];
19
+ collectionSlug: string;
20
+ collectionLabel: string;
21
+ labelField?: string;
22
+ };
23
+
24
+ export default function RelationField({
25
+ name,
26
+ value: initialValue,
27
+ hasMany = false,
28
+ options: initialOptions,
29
+ collectionSlug,
30
+ collectionLabel,
31
+ labelField = "title",
32
+ }: Props) {
33
+ const [options, setOptions] = useState(initialOptions);
34
+ const [selected, setSelected] = useState<string[]>(() => {
35
+ if (!initialValue) return [];
36
+ if (hasMany) {
37
+ try {
38
+ const parsed = JSON.parse(initialValue);
39
+ return Array.isArray(parsed) ? parsed.map(String) : [];
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+ return initialValue ? [initialValue] : [];
45
+ });
46
+ const [open, setOpen] = useState(false);
47
+ const [sheetOpen, setSheetOpen] = useState(false);
48
+ const hiddenRef = useRef<HTMLInputElement>(null);
49
+ const iframeRef = useRef<HTMLIFrameElement>(null);
50
+
51
+ const hiddenValue = hasMany ? JSON.stringify(selected) : (selected[0] ?? "");
52
+
53
+ // Notify form of changes so UnsavedGuard can detect them
54
+ useEffect(() => {
55
+ hiddenRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
56
+ }, [hiddenValue]);
57
+
58
+ const getLabel = useCallback((id: string) => options.find((o) => o.value === id)?.label ?? id, [options]);
59
+
60
+ const displayLabel = useMemo(() => {
61
+ if (selected.length === 0) return "";
62
+ if (hasMany) return `${selected.length} selected`;
63
+ return getLabel(selected[0]);
64
+ }, [selected, hasMany, getLabel]);
65
+
66
+ const selectItem = (id: string) => {
67
+ if (hasMany) {
68
+ setSelected((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]));
69
+ } else {
70
+ setSelected((prev) => (prev[0] === id ? [] : [id]));
71
+ setOpen(false);
72
+ }
73
+ };
74
+
75
+ const remove = (id: string) => {
76
+ setSelected((prev) => prev.filter((v) => v !== id));
77
+ };
78
+
79
+ // Listen for postMessage from embedded iframe after successful save
80
+ useEffect(() => {
81
+ if (!sheetOpen) return;
82
+
83
+ const handleMessage = (e: MessageEvent) => {
84
+ if (e.data?.type !== "cms:created") return;
85
+ const docId = String(e.data.id);
86
+ fetch(`/api/cms/${collectionSlug}/${docId}?status=any`)
87
+ .then((res) => (res.ok ? res.json() : null))
88
+ .then((doc) => {
89
+ if (doc) {
90
+ const label = String(doc[labelField] ?? doc.slug ?? docId);
91
+ setOptions((prev) => {
92
+ const exists = prev.some((o) => o.value === docId);
93
+ return exists
94
+ ? prev.map((o) => (o.value === docId ? { ...o, label } : o))
95
+ : [{ value: docId, label }, ...prev];
96
+ });
97
+ if (hasMany) {
98
+ setSelected((prev) => (prev.includes(docId) ? prev : [...prev, docId]));
99
+ } else {
100
+ setSelected([docId]);
101
+ }
102
+ }
103
+ setSheetOpen(false);
104
+ });
105
+ };
106
+
107
+ window.addEventListener("message", handleMessage);
108
+ return () => window.removeEventListener("message", handleMessage);
109
+ }, [sheetOpen, collectionSlug, hasMany, labelField]);
110
+
111
+ return (
112
+ <div className="space-y-2">
113
+ <input ref={hiddenRef} type="hidden" name={name} value={hiddenValue} />
114
+
115
+ {/* Selected items (hasMany chips) */}
116
+ {hasMany && selected.length > 0 && (
117
+ <div className="flex flex-wrap gap-1.5">
118
+ {selected.map((id) => (
119
+ <span
120
+ key={id}
121
+ className="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-sm"
122
+ >
123
+ {getLabel(id)}
124
+ <button
125
+ type="button"
126
+ title="Remove"
127
+ onClick={() => remove(id)}
128
+ className="text-muted-foreground hover:text-foreground -mr-0.5 rounded p-0.5"
129
+ >
130
+ <X className="size-3" />
131
+ </button>
132
+ </span>
133
+ ))}
134
+ </div>
135
+ )}
136
+
137
+ {/* Combobox */}
138
+ <Popover open={open} onOpenChange={setOpen}>
139
+ <PopoverTrigger asChild>
140
+ <Button
141
+ variant="outline"
142
+ role="combobox"
143
+ aria-expanded={open}
144
+ size="lg"
145
+ className="border-input bg-muted/30 hover:bg-muted dark:bg-input/30 dark:hover:bg-input/50 w-full justify-between text-base font-normal"
146
+ >
147
+ <span className={cn("truncate", !displayLabel && "text-muted-foreground")}>
148
+ {displayLabel || `Search ${collectionLabel.toLowerCase()}...`}
149
+ </span>
150
+ <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
151
+ </Button>
152
+ </PopoverTrigger>
153
+ <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
154
+ <Command>
155
+ <CommandInput placeholder={`Search ${collectionLabel.toLowerCase()}...`} />
156
+ <CommandList>
157
+ <CommandEmpty>No results found.</CommandEmpty>
158
+ {options.map((o) => (
159
+ <CommandItem key={o.value} value={o.label} onSelect={() => selectItem(o.value)}>
160
+ <Check className={cn("ml-1 size-4", selected.includes(o.value) ? "opacity-100" : "opacity-0")} />
161
+ {o.label}
162
+ </CommandItem>
163
+ ))}
164
+ </CommandList>
165
+ </Command>
166
+ </PopoverContent>
167
+ </Popover>
168
+
169
+ {/* Create new button */}
170
+ <Button
171
+ type="button"
172
+ variant="outline"
173
+ size="sm"
174
+ className="text-foreground/70"
175
+ onClick={() => setSheetOpen(true)}
176
+ >
177
+ <Plus className="size-3.5" />
178
+ Create {collectionLabel.toLowerCase()}
179
+ </Button>
180
+
181
+ {/* Create sheet — full-width iframe with the actual add-new page */}
182
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
183
+ <SheetContent
184
+ side="right"
185
+ className="data-[side=right]:w-[90vw] data-[side=right]:sm:max-w-[90vw] data-[side=right]:lg:w-[50vw] data-[side=right]:lg:max-w-[50vw]"
186
+ >
187
+ <SheetHeader className="sr-only">
188
+ <SheetTitle>Create {collectionLabel.toLowerCase()}</SheetTitle>
189
+ </SheetHeader>
190
+ <div className="flex-1 overflow-hidden">
191
+ {sheetOpen && (
192
+ <iframe
193
+ ref={iframeRef}
194
+ src={`/admin/${collectionSlug}/new?_embed=1`}
195
+ title={`Create ${collectionLabel}`}
196
+ className="size-full"
197
+ />
198
+ )}
199
+ </div>
200
+ </SheetContent>
201
+ </Sheet>
202
+ </div>
203
+ );
204
+ }