@vonaffenfels/contentful-teasermanager 1.2.17 → 1.2.18

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.
@@ -1,534 +1,534 @@
1
- import {
2
- TextInput,
3
- Pill,
4
- SectionHeading,
5
- Checkbox,
6
- Button,
7
- DragHandle,
8
- } from "@contentful/f36-components";
9
- import {
10
- DndContext, useDraggable, useDroppable,
11
- PointerSensor, useSensor, useSensors,
12
- } from '@dnd-kit/core';
13
- import contentfulResolveResponse from "contentful-resolve-response";
14
- import {
15
- useEffect, useState,
16
- } from "react";
17
- import useDebounce from "../../../hooks/useDebounce";
18
- import {getContentfulClient} from "../../../lib/contentfulClient";
19
- import {PlusIcon} from "@contentful/f36-icons";
20
- import {CSS} from "@dnd-kit/utilities";
21
- import {queryFromLogic} from "../../../queryFromLogic";
22
- import {useQuery} from "@tanstack/react-query";
23
- export const LogicEditor = (props) => {
24
- const [selectedTags, setSelectedTags] = useState([]);
25
-
26
- useEffect(() => {
27
- setSelectedTags(props?.logicData?.tags || []);
28
- }, [props?.logicData?.tags]);
29
-
30
- const handleDragEnd = (event) => {
31
- const {
32
- over,
33
- active,
34
- } = event;
35
-
36
- if (over) {
37
- const draggedTagIndex = selectedTags.findIndex(tag => tag.id === active.id);
38
- if (draggedTagIndex !== -1) {
39
- const updatedTags = [...selectedTags];
40
- updatedTags[draggedTagIndex] = {
41
- ...updatedTags[draggedTagIndex],
42
- type: over.id, // "and" or "or"
43
- };
44
- setSelectedTags(updatedTags);
45
- }
46
- }
47
- };
48
-
49
- return <DndContext onDragEnd={handleDragEnd}>
50
- <LogicEditorInner {...props} selectedTags={selectedTags} setSelectedTags={setSelectedTags} />
51
- </DndContext>;
52
- };
53
-
54
- const LogicEditorInner = ({
55
- onSave = () => alert("onSave missing"),
56
- sdk,
57
- logicData,
58
- selectedTags,
59
- setSelectedTags,
60
- }) => {
61
- const contentfulClient = getContentfulClient();
62
- const [loading, setLoading] = useState(false);
63
- const [tags, setTags] = useState([]);
64
- const [categories, setCategories] = useState([]);
65
- const [searchQuery, setSearchQuery] = useState("");
66
- const [addTimepoint, setAddTimepoint] = useState(7);
67
- const debouncedSearch = useDebounce(searchQuery, 500);
68
- const [selectedTimepoints, setSelectedTimepoints] = useState([]);
69
- const {
70
- portal,
71
- locale = "de",
72
- } = sdk.parameters.invocation;
73
-
74
- const {data: articles} = useQuery({
75
- enabled: !!selectedTags.length && !!portal && !!contentfulClient,
76
- queryKey: ["articles", selectedTags, portal],
77
- queryFn: async () => {
78
- const query = queryFromLogic({
79
- tags: selectedTags,
80
- project: portal,
81
- });
82
-
83
- try {
84
- const result = await contentfulClient.getEntries({
85
- content_type: "article",
86
- locale: "de",
87
- order: "-fields.date",
88
- limit: 10,
89
- ...query,
90
- select: [
91
- "fields.title",
92
- "fields.slug",
93
- "fields.date",
94
- "sys.id",
95
- ].join(","),
96
- });
97
-
98
- return result;
99
- } catch (e) {
100
- console.error(e);
101
- return {items: []};
102
- }
103
- },
104
- });
105
-
106
- useEffect(() => {
107
- if (logicData?.timepoints) {
108
- setSelectedTimepoints(logicData?.timepoints);
109
- } else {
110
- setSelectedTimepoints([7, 12, 18]);
111
- }
112
- }, [logicData]);
113
- const {
114
- setNodeRef: setOrNodeRef,
115
- isOver: isOrOver,
116
- node: orNode,
117
- } = useDroppable({id: 'or'});
118
- const {
119
- setNodeRef: setAndNodeRef,
120
- isOver: isAndOver,
121
- node: andNode,
122
- } = useDroppable({id: "and"});
123
- const {
124
- setNodeRef: setExcludeNodeRef,
125
- isOver: isExcludeOver,
126
- node: excludeNode,
127
- } = useDroppable({id: "exclude"});
128
-
129
- useEffect(() => {
130
- setLoading(true);
131
- const params = {
132
- limit: 100,
133
- skip: 0,
134
- content_type: "tags",
135
- locale,
136
- order: "-sys.updatedAt",
137
- "fields.portal": portal,
138
- select: [
139
- "fields.title",
140
- "fields.isCategory",
141
- "fields.hidden",
142
- "sys.publishedAt",
143
- "sys.id",
144
- ].join(","),
145
- };
146
- const paramsNavigation = {
147
- limit: 1,
148
- content_type: "navigation",
149
- include: 2,
150
- locale,
151
- "fields.portal": portal,
152
- "fields.type": "main",
153
- };
154
-
155
- if (debouncedSearch) {
156
- params["fields.title[match]"] = debouncedSearch;
157
- }
158
-
159
- Promise.all([
160
- contentfulClient.getEntries(params),
161
- contentfulClient.getEntries(paramsNavigation).then(async (response) => {
162
- if (!response?.items?.[0]) {
163
- return [];
164
- }
165
-
166
- const references = await sdk.cma.entry.references({entryId: response.items[0].sys.id}, {include: 10});
167
- const resolved = contentfulResolveResponse(references);
168
- const allCategories = {};
169
-
170
- for (const category of resolved) {
171
- if (!category?.fields?.children?.de) {
172
- continue;
173
- }
174
-
175
- const children = category.fields.children.de || [];
176
-
177
- for (const child of children) {
178
- getAllTagsFromCategory(child, allCategories);
179
- }
180
- }
181
-
182
- return Object.values(allCategories).filter((c) => c.tags.length > 0);
183
- }),
184
- ]).then(([tags, categories]) => {
185
- setLoading(false);
186
- setTags(tags.items);
187
- setCategories(categories);
188
- });
189
- }, [debouncedSearch, portal, locale]);
190
-
191
- const andTags = selectedTags.filter((t) => t.type === "and");
192
- const orTags = selectedTags.filter((t) => t.type === "or");
193
- const excludeTags = selectedTags.filter((t) => t.type === "exclude");
194
-
195
- return <div className="flex flex-col gap-2 p-4">
196
- <div className="z-10 flex w-full flex-col gap-4 border-b border-gray-200 bg-white py-4">
197
- <div className="flex w-full flex-row items-center gap-2" style={{minHeight: 36}}>
198
- <SectionHeading style={{marginBottom: 0}}>Gewählte Zeitpunkte:</SectionHeading>
199
- <div className="flex flex-row items-center gap-2">
200
- {selectedTimepoints.map((timepoint) => {
201
- return <Pill
202
- key={timepoint}
203
- draggable={false}
204
- testId="pill-item"
205
- variant="active"
206
- label={`${timepoint} Uhr`}
207
- onClose={() => {
208
- setSelectedTimepoints(selectedTimepoints.filter((t) => t !== timepoint));
209
- }}
210
- />;
211
- })}
212
- <TextInput
213
- type="number"
214
- min={1}
215
- max={24}
216
- value={addTimepoint}
217
- onChange={(e) => {
218
- setAddTimepoint(e.target.value);
219
- }}
220
- />
221
- <SectionHeading style={{marginBottom: 0}}>Uhr</SectionHeading>
222
- <Button
223
- variant="secondary"
224
- onClick={() => {
225
- if (selectedTimepoints.includes(addTimepoint)) {
226
- return;
227
- }
228
-
229
- setSelectedTimepoints([...selectedTimepoints, addTimepoint]);
230
- }}
231
- >
232
- <PlusIcon />
233
- </Button>
234
- </div>
235
- </div>
236
- <div className="flex w-full flex-row items-center gap-2" style={{minHeight: 36}}>
237
- <SectionHeading style={{marginBottom: 0}}>Gewählte Kategorien & Tags:</SectionHeading>
238
- <div className="grid w-full grid-cols-5 gap-4">
239
- <div className="flex w-full flex-col gap-2">
240
- <div>
241
- <SectionHeading style={{marginBottom: 0}}>Oder</SectionHeading>
242
- </div>
243
- <div
244
- ref={setOrNodeRef}
245
- style={{
246
- minHeight: 36,
247
- backgroundColor: isOrOver ? "rgba(0, 255, 0, 0.3)" : "transparent",
248
- }}
249
- className="flex h-full w-full flex-row flex-wrap gap-2"
250
- >
251
- {orTags.map((tag) => {
252
- return <DraggablePill
253
- key={tag.id}
254
- id={tag.id}
255
- variant="active"
256
- label={(`${tag.label}`)}
257
- onClose={() => {
258
- setSelectedTags(selectedTags.filter((t) => t.id !== tag.id));
259
- }}
260
- />;
261
- })}
262
- </div>
263
- </div>
264
- <div className="flex w-full flex-col gap-2">
265
- <SectionHeading style={{marginBottom: 0}}>Und</SectionHeading>
266
- <div
267
- ref={setAndNodeRef}
268
- style={{
269
- minHeight: 36,
270
- backgroundColor: isAndOver ? "rgba(0, 255, 0, 0.3)" : "transparent",
271
- }}
272
- className="flex h-full w-full flex-row flex-wrap gap-2"
273
- >
274
- {andTags.map((tag) => {
275
- return <DraggablePill
276
- key={tag.id}
277
- id={tag.id}
278
- testId={tag.id}
279
- variant="active"
280
- label={(`${tag.label}`)}
281
- onClose={() => {
282
- setSelectedTags(selectedTags.filter((t) => t.id !== tag.id));
283
- }}
284
- />;
285
- })}
286
- </div>
287
- </div>
288
- <div className="flex w-full flex-col gap-2">
289
- <SectionHeading style={{marginBottom: 0}}>Ausschließen</SectionHeading>
290
- <div
291
- ref={setExcludeNodeRef}
292
- style={{
293
- minHeight: 36,
294
- backgroundColor: isExcludeOver ? "rgba(0, 255, 0, 0.3)" : "transparent",
295
- }}
296
- className="flex h-full w-full flex-row flex-wrap gap-2"
297
- >
298
- {excludeTags.map((tag) => {
299
- return <DraggablePill
300
- key={tag.id}
301
- id={tag.id}
302
- testId={tag.id}
303
- variant="active"
304
- label={(`${tag.label}`)}
305
- onClose={() => {
306
- setSelectedTags(selectedTags.filter((t) => t.id !== tag.id));
307
- }}
308
- />;
309
- })}
310
- </div>
311
- </div>
312
- <div className="col-span-2 flex w-full flex-col gap-4">
313
- <div>
314
- <SectionHeading style={{marginBottom: 0}}>Artikel</SectionHeading>
315
- </div>
316
- <div className="flex flex-col gap-4">
317
- <table className="w-full max-w-full table-auto gap-4 text-left">
318
- <thead>
319
- <tr>
320
- <th>Datum</th>
321
- <th className="pl-4">Titel</th>
322
- </tr>
323
- </thead>
324
- <tbody>
325
- {articles?.items?.map((article) => {
326
- return <tr key={article.sys.id}>
327
- <td className="whitespace-nowrap">
328
- {new Date(article.fields.date.de).toLocaleString("de-DE", {
329
- hour: "2-digit",
330
- minute: "2-digit",
331
- day: "2-digit",
332
- month: "2-digit",
333
- year: "numeric",
334
- })}
335
- </td>
336
- <td className="truncate whitespace-nowrap pl-4">
337
- {article.fields.title.de}
338
- </td>
339
- </tr>;
340
- })}
341
- </tbody>
342
- </table>
343
- </div>
344
- </div>
345
- </div>
346
- </div>
347
- <div className="flex w-full flex-row gap-2">
348
- <TextInput
349
- placeholder="Suche nach Kategorie oder Tag"
350
- value={searchQuery}
351
- onChange={(e) => setSearchQuery(e.target.value)}
352
- />
353
- </div>
354
- </div>
355
- <div className="grid grid-cols-2 gap-4 overflow-y-auto">
356
- <div className="flex flex-col gap-4">
357
- <div>
358
- <SectionHeading style={{marginBottom: 0}}>Kategorien</SectionHeading>
359
- </div>
360
- <div className="grid grid-cols-3 gap-4">
361
- {categories?.map((category) => {
362
- return (
363
- <Checkbox
364
- key={category.id}
365
- isChecked={selectedTags.some((t) => category.tags.some((c) => c.id === t.id))}
366
- onChange={(e) => {
367
- let newTags = [...selectedTags];
368
-
369
- if (e.target.checked) {
370
- for (const tag of category.tags) {
371
- if (!selectedTags.find((t) => t.id === tag.id)) {
372
- newTags.push({
373
- label: tag.label,
374
- id: tag.id,
375
- type: "or",
376
- });
377
- }
378
- }
379
- } else {
380
- for (const tag of category.tags) {
381
- newTags = newTags.filter((t) => t.id !== tag.id);
382
- }
383
- }
384
-
385
- setSelectedTags(newTags);
386
- }}
387
- >
388
- {category.title} ({selectedTags.filter((t) =>
389
- category.tags.some((c) => c.id === t.id)).length}/{category.tags.length})
390
- </Checkbox>
391
- );
392
- })}
393
- </div>
394
- </div>
395
- <div className="flex flex-col gap-4">
396
- <div>
397
- <SectionHeading style={{marginBottom: 0}}>Tags</SectionHeading>
398
- </div>
399
- <div className="grid grid-cols-3 gap-4">
400
- {tags?.map((tag) => {
401
- return (
402
- <Checkbox
403
- key={tag.sys.id}
404
- isChecked={!!selectedTags.find((t) => t.id === tag.sys.id)}
405
- onChange={(e) => {
406
- if (e.target.checked) {
407
- setSelectedTags([...selectedTags, {
408
- label: tag.fields.title[locale],
409
- id: tag.sys.id,
410
- type: "or",
411
- }]);
412
- } else {
413
- setSelectedTags(selectedTags.filter((t) => t.id !== tag.sys.id));
414
- }
415
- }}
416
- >
417
- {tag.fields.title[locale]}
418
- </Checkbox>
419
- );
420
- })}
421
- </div>
422
- </div>
423
- </div>
424
- <div className="sticky bottom-0 flex w-full flex-row justify-between gap-2 border-t border-gray-200 bg-white py-4">
425
- <Button
426
- variant="positive"
427
- type="button"
428
- onClick={() => onSave({
429
- tags: selectedTags,
430
- timepoints: selectedTimepoints,
431
- })}>Speichern</Button>
432
- <Button
433
- variant="negative"
434
- type="button"
435
- onClick={() => onSave({
436
- tags: [],
437
- timepoints: [],
438
- })}>Logik löschen</Button>
439
- </div>
440
- </div>;
441
- };
442
-
443
- function getAllTagsFromCategory(categoryNode, allCategories = {}, parentCategoryTitle = null) {
444
- if (!categoryNode || !categoryNode.fields?.children) {
445
- return allCategories;
446
- }
447
-
448
- const children = categoryNode.fields.children.de || [];
449
- const title = parentCategoryTitle ? `${parentCategoryTitle} > ${categoryNode.fields.title.de}` : categoryNode.fields.title.de;
450
-
451
- allCategories[categoryNode.sys.id] = {
452
- title,
453
- tags: [],
454
- id: categoryNode.sys.id,
455
- };
456
-
457
- if (categoryNode?.fields?.tags?.de) {
458
- for (const tag of categoryNode.fields.tags.de) {
459
- if (!tag?.fields?.title) {
460
- continue;
461
- }
462
-
463
- if (!allCategories[categoryNode.sys.id].tags.find((c) => c.id === tag.sys.id)) {
464
- allCategories[categoryNode.sys.id].tags.push({
465
- label: tag.fields.title.de,
466
- id: tag.sys.id,
467
- });
468
- }
469
- }
470
- }
471
-
472
- for (const child of children) {
473
- if (!child || !child.fields) {
474
- continue;
475
- }
476
-
477
- if (child?.fields?.tags?.de) {
478
- for (const tag of child.fields.tags.de) {
479
- if (!allCategories[categoryNode.sys.id].tags.find((c) => c.id === tag.sys.id)) {
480
- if (!tag?.fields?.title) {
481
- continue;
482
- }
483
-
484
- allCategories[categoryNode.sys.id].tags.push({
485
- label: tag.fields.title.de,
486
- id: tag.sys.id,
487
- });
488
- }
489
- }
490
- }
491
-
492
- getAllTagsFromCategory(child, allCategories, title);
493
- }
494
-
495
- allCategories[categoryNode.sys.id].tags = [...new Set(allCategories[categoryNode.sys.id].tags)];
496
-
497
- return allCategories;
498
- }
499
-
500
-
501
- function DraggablePill({
502
- id, ...props
503
- }) {
504
- const {
505
- attributes,
506
- listeners,
507
- setNodeRef,
508
- transform,
509
- transition,
510
- } = useDraggable({id});
511
- const style = {
512
- transform: CSS.Translate.toString(transform),
513
- transition,
514
- };
515
-
516
- return (
517
- <div>
518
- <Pill
519
- dragHandleComponent={
520
- <DragHandle
521
- label="Reorder item"
522
- variant="transparent"
523
- {...attributes}
524
- {...listeners}
525
- />
526
- }
527
- isDraggable
528
- ref={setNodeRef}
529
- style={style}
530
- {...props}
531
- />
532
- </div>
533
- );
1
+ import {
2
+ TextInput,
3
+ Pill,
4
+ SectionHeading,
5
+ Checkbox,
6
+ Button,
7
+ DragHandle,
8
+ } from "@contentful/f36-components";
9
+ import {
10
+ DndContext, useDraggable, useDroppable,
11
+ PointerSensor, useSensor, useSensors,
12
+ } from '@dnd-kit/core';
13
+ import contentfulResolveResponse from "contentful-resolve-response";
14
+ import {
15
+ useEffect, useState,
16
+ } from "react";
17
+ import useDebounce from "../../../hooks/useDebounce";
18
+ import {getContentfulClient} from "../../../lib/contentfulClient";
19
+ import {PlusIcon} from "@contentful/f36-icons";
20
+ import {CSS} from "@dnd-kit/utilities";
21
+ import {queryFromLogic} from "../../../queryFromLogic";
22
+ import {useQuery} from "@tanstack/react-query";
23
+ export const LogicEditor = (props) => {
24
+ const [selectedTags, setSelectedTags] = useState([]);
25
+
26
+ useEffect(() => {
27
+ setSelectedTags(props?.logicData?.tags || []);
28
+ }, [props?.logicData?.tags]);
29
+
30
+ const handleDragEnd = (event) => {
31
+ const {
32
+ over,
33
+ active,
34
+ } = event;
35
+
36
+ if (over) {
37
+ const draggedTagIndex = selectedTags.findIndex(tag => tag.id === active.id);
38
+ if (draggedTagIndex !== -1) {
39
+ const updatedTags = [...selectedTags];
40
+ updatedTags[draggedTagIndex] = {
41
+ ...updatedTags[draggedTagIndex],
42
+ type: over.id, // "and" or "or"
43
+ };
44
+ setSelectedTags(updatedTags);
45
+ }
46
+ }
47
+ };
48
+
49
+ return <DndContext onDragEnd={handleDragEnd}>
50
+ <LogicEditorInner {...props} selectedTags={selectedTags} setSelectedTags={setSelectedTags} />
51
+ </DndContext>;
52
+ };
53
+
54
+ const LogicEditorInner = ({
55
+ onSave = () => alert("onSave missing"),
56
+ sdk,
57
+ logicData,
58
+ selectedTags,
59
+ setSelectedTags,
60
+ }) => {
61
+ const contentfulClient = getContentfulClient();
62
+ const [loading, setLoading] = useState(false);
63
+ const [tags, setTags] = useState([]);
64
+ const [categories, setCategories] = useState([]);
65
+ const [searchQuery, setSearchQuery] = useState("");
66
+ const [addTimepoint, setAddTimepoint] = useState(7);
67
+ const debouncedSearch = useDebounce(searchQuery, 500);
68
+ const [selectedTimepoints, setSelectedTimepoints] = useState([]);
69
+ const {
70
+ portal,
71
+ locale = "de",
72
+ } = sdk.parameters.invocation;
73
+
74
+ const {data: articles} = useQuery({
75
+ enabled: !!selectedTags.length && !!portal && !!contentfulClient,
76
+ queryKey: ["articles", selectedTags, portal],
77
+ queryFn: async () => {
78
+ const query = queryFromLogic({
79
+ tags: selectedTags,
80
+ project: portal,
81
+ });
82
+
83
+ try {
84
+ const result = await contentfulClient.getEntries({
85
+ content_type: "article",
86
+ locale: "de",
87
+ order: "-fields.date",
88
+ limit: 10,
89
+ ...query,
90
+ select: [
91
+ "fields.title",
92
+ "fields.slug",
93
+ "fields.date",
94
+ "sys.id",
95
+ ].join(","),
96
+ });
97
+
98
+ return result;
99
+ } catch (e) {
100
+ console.error(e);
101
+ return {items: []};
102
+ }
103
+ },
104
+ });
105
+
106
+ useEffect(() => {
107
+ if (logicData?.timepoints) {
108
+ setSelectedTimepoints(logicData?.timepoints);
109
+ } else {
110
+ setSelectedTimepoints([7, 12, 18]);
111
+ }
112
+ }, [logicData]);
113
+ const {
114
+ setNodeRef: setOrNodeRef,
115
+ isOver: isOrOver,
116
+ node: orNode,
117
+ } = useDroppable({id: 'or'});
118
+ const {
119
+ setNodeRef: setAndNodeRef,
120
+ isOver: isAndOver,
121
+ node: andNode,
122
+ } = useDroppable({id: "and"});
123
+ const {
124
+ setNodeRef: setExcludeNodeRef,
125
+ isOver: isExcludeOver,
126
+ node: excludeNode,
127
+ } = useDroppable({id: "exclude"});
128
+
129
+ useEffect(() => {
130
+ setLoading(true);
131
+ const params = {
132
+ limit: 100,
133
+ skip: 0,
134
+ content_type: "tags",
135
+ locale,
136
+ order: "-sys.updatedAt",
137
+ "fields.portal": portal,
138
+ select: [
139
+ "fields.title",
140
+ "fields.isCategory",
141
+ "fields.hidden",
142
+ "sys.publishedAt",
143
+ "sys.id",
144
+ ].join(","),
145
+ };
146
+ const paramsNavigation = {
147
+ limit: 1,
148
+ content_type: "navigation",
149
+ include: 2,
150
+ locale,
151
+ "fields.portal": portal,
152
+ "fields.type": "main",
153
+ };
154
+
155
+ if (debouncedSearch) {
156
+ params["fields.title[match]"] = debouncedSearch;
157
+ }
158
+
159
+ Promise.all([
160
+ contentfulClient.getEntries(params),
161
+ contentfulClient.getEntries(paramsNavigation).then(async (response) => {
162
+ if (!response?.items?.[0]) {
163
+ return [];
164
+ }
165
+
166
+ const references = await sdk.cma.entry.references({entryId: response.items[0].sys.id}, {include: 10});
167
+ const resolved = contentfulResolveResponse(references);
168
+ const allCategories = {};
169
+
170
+ for (const category of resolved) {
171
+ if (!category?.fields?.children?.de) {
172
+ continue;
173
+ }
174
+
175
+ const children = category.fields.children.de || [];
176
+
177
+ for (const child of children) {
178
+ getAllTagsFromCategory(child, allCategories);
179
+ }
180
+ }
181
+
182
+ return Object.values(allCategories).filter((c) => c.tags.length > 0);
183
+ }),
184
+ ]).then(([tags, categories]) => {
185
+ setLoading(false);
186
+ setTags(tags.items);
187
+ setCategories(categories);
188
+ });
189
+ }, [debouncedSearch, portal, locale]);
190
+
191
+ const andTags = selectedTags.filter((t) => t.type === "and");
192
+ const orTags = selectedTags.filter((t) => t.type === "or");
193
+ const excludeTags = selectedTags.filter((t) => t.type === "exclude");
194
+
195
+ return <div className="flex flex-col gap-2 p-4">
196
+ <div className="z-10 flex w-full flex-col gap-4 border-b border-gray-200 bg-white py-4">
197
+ <div className="flex w-full flex-row items-center gap-2" style={{minHeight: 36}}>
198
+ <SectionHeading style={{marginBottom: 0}}>Gewählte Zeitpunkte:</SectionHeading>
199
+ <div className="flex flex-row items-center gap-2">
200
+ {selectedTimepoints.map((timepoint) => {
201
+ return <Pill
202
+ key={timepoint}
203
+ draggable={false}
204
+ testId="pill-item"
205
+ variant="active"
206
+ label={`${timepoint} Uhr`}
207
+ onClose={() => {
208
+ setSelectedTimepoints(selectedTimepoints.filter((t) => t !== timepoint));
209
+ }}
210
+ />;
211
+ })}
212
+ <TextInput
213
+ type="number"
214
+ min={1}
215
+ max={24}
216
+ value={addTimepoint}
217
+ onChange={(e) => {
218
+ setAddTimepoint(e.target.value);
219
+ }}
220
+ />
221
+ <SectionHeading style={{marginBottom: 0}}>Uhr</SectionHeading>
222
+ <Button
223
+ variant="secondary"
224
+ onClick={() => {
225
+ if (selectedTimepoints.includes(addTimepoint)) {
226
+ return;
227
+ }
228
+
229
+ setSelectedTimepoints([...selectedTimepoints, addTimepoint]);
230
+ }}
231
+ >
232
+ <PlusIcon />
233
+ </Button>
234
+ </div>
235
+ </div>
236
+ <div className="flex w-full flex-row items-center gap-2" style={{minHeight: 36}}>
237
+ <SectionHeading style={{marginBottom: 0}}>Gewählte Kategorien & Tags:</SectionHeading>
238
+ <div className="grid w-full grid-cols-5 gap-4">
239
+ <div className="flex w-full flex-col gap-2">
240
+ <div>
241
+ <SectionHeading style={{marginBottom: 0}}>Oder</SectionHeading>
242
+ </div>
243
+ <div
244
+ ref={setOrNodeRef}
245
+ style={{
246
+ minHeight: 36,
247
+ backgroundColor: isOrOver ? "rgba(0, 255, 0, 0.3)" : "transparent",
248
+ }}
249
+ className="flex h-full w-full flex-row flex-wrap gap-2"
250
+ >
251
+ {orTags.map((tag) => {
252
+ return <DraggablePill
253
+ key={tag.id}
254
+ id={tag.id}
255
+ variant="active"
256
+ label={(`${tag.label}`)}
257
+ onClose={() => {
258
+ setSelectedTags(selectedTags.filter((t) => t.id !== tag.id));
259
+ }}
260
+ />;
261
+ })}
262
+ </div>
263
+ </div>
264
+ <div className="flex w-full flex-col gap-2">
265
+ <SectionHeading style={{marginBottom: 0}}>Und</SectionHeading>
266
+ <div
267
+ ref={setAndNodeRef}
268
+ style={{
269
+ minHeight: 36,
270
+ backgroundColor: isAndOver ? "rgba(0, 255, 0, 0.3)" : "transparent",
271
+ }}
272
+ className="flex h-full w-full flex-row flex-wrap gap-2"
273
+ >
274
+ {andTags.map((tag) => {
275
+ return <DraggablePill
276
+ key={tag.id}
277
+ id={tag.id}
278
+ testId={tag.id}
279
+ variant="active"
280
+ label={(`${tag.label}`)}
281
+ onClose={() => {
282
+ setSelectedTags(selectedTags.filter((t) => t.id !== tag.id));
283
+ }}
284
+ />;
285
+ })}
286
+ </div>
287
+ </div>
288
+ <div className="flex w-full flex-col gap-2">
289
+ <SectionHeading style={{marginBottom: 0}}>Ausschließen</SectionHeading>
290
+ <div
291
+ ref={setExcludeNodeRef}
292
+ style={{
293
+ minHeight: 36,
294
+ backgroundColor: isExcludeOver ? "rgba(0, 255, 0, 0.3)" : "transparent",
295
+ }}
296
+ className="flex h-full w-full flex-row flex-wrap gap-2"
297
+ >
298
+ {excludeTags.map((tag) => {
299
+ return <DraggablePill
300
+ key={tag.id}
301
+ id={tag.id}
302
+ testId={tag.id}
303
+ variant="active"
304
+ label={(`${tag.label}`)}
305
+ onClose={() => {
306
+ setSelectedTags(selectedTags.filter((t) => t.id !== tag.id));
307
+ }}
308
+ />;
309
+ })}
310
+ </div>
311
+ </div>
312
+ <div className="col-span-2 flex w-full flex-col gap-4">
313
+ <div>
314
+ <SectionHeading style={{marginBottom: 0}}>Artikel</SectionHeading>
315
+ </div>
316
+ <div className="flex flex-col gap-4">
317
+ <table className="w-full max-w-full table-auto gap-4 text-left">
318
+ <thead>
319
+ <tr>
320
+ <th>Datum</th>
321
+ <th className="pl-4">Titel</th>
322
+ </tr>
323
+ </thead>
324
+ <tbody>
325
+ {articles?.items?.map((article) => {
326
+ return <tr key={article.sys.id}>
327
+ <td className="whitespace-nowrap">
328
+ {new Date(article.fields.date.de).toLocaleString("de-DE", {
329
+ hour: "2-digit",
330
+ minute: "2-digit",
331
+ day: "2-digit",
332
+ month: "2-digit",
333
+ year: "numeric",
334
+ })}
335
+ </td>
336
+ <td className="truncate whitespace-nowrap pl-4">
337
+ {article.fields.title.de}
338
+ </td>
339
+ </tr>;
340
+ })}
341
+ </tbody>
342
+ </table>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ </div>
347
+ <div className="flex w-full flex-row gap-2">
348
+ <TextInput
349
+ placeholder="Suche nach Kategorie oder Tag"
350
+ value={searchQuery}
351
+ onChange={(e) => setSearchQuery(e.target.value)}
352
+ />
353
+ </div>
354
+ </div>
355
+ <div className="grid grid-cols-2 gap-4 overflow-y-auto">
356
+ <div className="flex flex-col gap-4">
357
+ <div>
358
+ <SectionHeading style={{marginBottom: 0}}>Kategorien</SectionHeading>
359
+ </div>
360
+ <div className="grid grid-cols-3 gap-4">
361
+ {categories?.map((category) => {
362
+ return (
363
+ <Checkbox
364
+ key={category.id}
365
+ isChecked={selectedTags.some((t) => category.tags.some((c) => c.id === t.id))}
366
+ onChange={(e) => {
367
+ let newTags = [...selectedTags];
368
+
369
+ if (e.target.checked) {
370
+ for (const tag of category.tags) {
371
+ if (!selectedTags.find((t) => t.id === tag.id)) {
372
+ newTags.push({
373
+ label: tag.label,
374
+ id: tag.id,
375
+ type: "or",
376
+ });
377
+ }
378
+ }
379
+ } else {
380
+ for (const tag of category.tags) {
381
+ newTags = newTags.filter((t) => t.id !== tag.id);
382
+ }
383
+ }
384
+
385
+ setSelectedTags(newTags);
386
+ }}
387
+ >
388
+ {category.title} ({selectedTags.filter((t) =>
389
+ category.tags.some((c) => c.id === t.id)).length}/{category.tags.length})
390
+ </Checkbox>
391
+ );
392
+ })}
393
+ </div>
394
+ </div>
395
+ <div className="flex flex-col gap-4">
396
+ <div>
397
+ <SectionHeading style={{marginBottom: 0}}>Tags</SectionHeading>
398
+ </div>
399
+ <div className="grid grid-cols-3 gap-4">
400
+ {tags?.map((tag) => {
401
+ return (
402
+ <Checkbox
403
+ key={tag.sys.id}
404
+ isChecked={!!selectedTags.find((t) => t.id === tag.sys.id)}
405
+ onChange={(e) => {
406
+ if (e.target.checked) {
407
+ setSelectedTags([...selectedTags, {
408
+ label: tag.fields.title[locale],
409
+ id: tag.sys.id,
410
+ type: "or",
411
+ }]);
412
+ } else {
413
+ setSelectedTags(selectedTags.filter((t) => t.id !== tag.sys.id));
414
+ }
415
+ }}
416
+ >
417
+ {tag.fields.title[locale]}
418
+ </Checkbox>
419
+ );
420
+ })}
421
+ </div>
422
+ </div>
423
+ </div>
424
+ <div className="sticky bottom-0 flex w-full flex-row justify-between gap-2 border-t border-gray-200 bg-white py-4">
425
+ <Button
426
+ variant="positive"
427
+ type="button"
428
+ onClick={() => onSave({
429
+ tags: selectedTags,
430
+ timepoints: selectedTimepoints,
431
+ })}>Speichern</Button>
432
+ <Button
433
+ variant="negative"
434
+ type="button"
435
+ onClick={() => onSave({
436
+ tags: [],
437
+ timepoints: [],
438
+ })}>Logik löschen</Button>
439
+ </div>
440
+ </div>;
441
+ };
442
+
443
+ function getAllTagsFromCategory(categoryNode, allCategories = {}, parentCategoryTitle = null) {
444
+ if (!categoryNode || !categoryNode.fields?.children) {
445
+ return allCategories;
446
+ }
447
+
448
+ const children = categoryNode.fields.children.de || [];
449
+ const title = parentCategoryTitle ? `${parentCategoryTitle} > ${categoryNode.fields.title.de}` : categoryNode.fields.title.de;
450
+
451
+ allCategories[categoryNode.sys.id] = {
452
+ title,
453
+ tags: [],
454
+ id: categoryNode.sys.id,
455
+ };
456
+
457
+ if (categoryNode?.fields?.tags?.de) {
458
+ for (const tag of categoryNode.fields.tags.de) {
459
+ if (!tag?.fields?.title) {
460
+ continue;
461
+ }
462
+
463
+ if (!allCategories[categoryNode.sys.id].tags.find((c) => c.id === tag.sys.id)) {
464
+ allCategories[categoryNode.sys.id].tags.push({
465
+ label: tag.fields.title.de,
466
+ id: tag.sys.id,
467
+ });
468
+ }
469
+ }
470
+ }
471
+
472
+ for (const child of children) {
473
+ if (!child || !child.fields) {
474
+ continue;
475
+ }
476
+
477
+ if (child?.fields?.tags?.de) {
478
+ for (const tag of child.fields.tags.de) {
479
+ if (!allCategories[categoryNode.sys.id].tags.find((c) => c.id === tag.sys.id)) {
480
+ if (!tag?.fields?.title) {
481
+ continue;
482
+ }
483
+
484
+ allCategories[categoryNode.sys.id].tags.push({
485
+ label: tag.fields.title.de,
486
+ id: tag.sys.id,
487
+ });
488
+ }
489
+ }
490
+ }
491
+
492
+ getAllTagsFromCategory(child, allCategories, title);
493
+ }
494
+
495
+ allCategories[categoryNode.sys.id].tags = [...new Set(allCategories[categoryNode.sys.id].tags)];
496
+
497
+ return allCategories;
498
+ }
499
+
500
+
501
+ function DraggablePill({
502
+ id, ...props
503
+ }) {
504
+ const {
505
+ attributes,
506
+ listeners,
507
+ setNodeRef,
508
+ transform,
509
+ transition,
510
+ } = useDraggable({id});
511
+ const style = {
512
+ transform: CSS.Translate.toString(transform),
513
+ transition,
514
+ };
515
+
516
+ return (
517
+ <div>
518
+ <Pill
519
+ dragHandleComponent={
520
+ <DragHandle
521
+ label="Reorder item"
522
+ variant="transparent"
523
+ {...attributes}
524
+ {...listeners}
525
+ />
526
+ }
527
+ isDraggable
528
+ ref={setNodeRef}
529
+ style={style}
530
+ {...props}
531
+ />
532
+ </div>
533
+ );
534
534
  }