@tuturuuu/ui 0.28.0 → 0.29.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.
- package/CHANGELOG.md +23 -0
- package/package.json +3 -3
- package/src/components/ui/calendar.tsx +6 -6
- package/src/components/ui/date-time-picker-layout.ts +6 -0
- package/src/components/ui/date-time-picker.test.tsx +25 -0
- package/src/components/ui/date-time-picker.tsx +5 -5
- package/src/components/ui/text-editor/__tests__/image-extension-clipboard.test.ts +87 -0
- package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +116 -5
- package/src/components/ui/text-editor/__tests__/markdown-paste-extension.test.ts +419 -2
- package/src/components/ui/text-editor/clipboard-image-files.ts +41 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +249 -0
- package/src/components/ui/text-editor/color-controls.tsx +1 -1
- package/src/components/ui/text-editor/copy-menu.tsx +129 -0
- package/src/components/ui/text-editor/editor.tsx +7 -0
- package/src/components/ui/text-editor/image-extension.ts +3 -14
- package/src/components/ui/text-editor/markdown-paste-extension.ts +62 -14
- package/src/components/ui/text-editor/tool-bar.tsx +17 -69
- package/src/components/ui/text-editor/toolbar-controls.tsx +59 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.29.0](https://github.com/tutur3u/platform/compare/ui-v0.28.1...ui-v0.29.0) (2026-08-20)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **editor:** add copy format menu ([389a6f0](https://github.com/tutur3u/platform/commit/389a6f02d822feef271409a9ab31e8fcbb6ef18e))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **editor:** contain toolbar overflow ([6bda614](https://github.com/tutur3u/platform/commit/6bda61416cbd2e4d4d4af7c7337d7b4e905274ef))
|
|
14
|
+
* **editor:** keep copy menu in toolbar ([c9dd241](https://github.com/tutur3u/platform/commit/c9dd2416f7e1b397c3293445d0bd0564a48c6bda))
|
|
15
|
+
* **editor:** preserve semantic rich paste ([bea67ca](https://github.com/tutur3u/platform/commit/bea67cac57b52b82a9ed4d7fd1a8a892cf4ceea4))
|
|
16
|
+
* **editor:** preserve task clipboard structure ([2d05d86](https://github.com/tutur3u/platform/commit/2d05d8602f39975805944edfccb2e10818863413))
|
|
17
|
+
* **tasks:** restore pasted image uploads ([3edef4e](https://github.com/tutur3u/platform/commit/3edef4e04a1fc6963904b7861d422f8db9d5b749))
|
|
18
|
+
|
|
19
|
+
## [0.28.1](https://github.com/tutur3u/platform/compare/ui-v0.28.0...ui-v0.28.1) (2026-08-16)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **tasks:** preserve rich paste and responsive dialogs ([0a6c5a3](https://github.com/tutur3u/platform/commit/0a6c5a362f51b475c2e8fe984eb3569a46a878ee))
|
|
25
|
+
|
|
3
26
|
## [0.28.0](https://github.com/tutur3u/platform/compare/ui-v0.27.0...ui-v0.28.0) (2026-08-15)
|
|
4
27
|
|
|
5
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -91,9 +91,9 @@
|
|
|
91
91
|
"@tuturuuu/apis": "0.11.2",
|
|
92
92
|
"@tuturuuu/hooks": "0.1.1",
|
|
93
93
|
"@tuturuuu/icons": "0.1.0",
|
|
94
|
-
"@tuturuuu/internal-api": "0.31.
|
|
94
|
+
"@tuturuuu/internal-api": "0.31.1",
|
|
95
95
|
"@tuturuuu/supabase": "0.5.0",
|
|
96
|
-
"@tuturuuu/utils": "0.25.
|
|
96
|
+
"@tuturuuu/utils": "0.25.3",
|
|
97
97
|
"@types/debug": "^4.1.13",
|
|
98
98
|
"browser-image-compression": "^2.0.2",
|
|
99
99
|
"class-variance-authority": "^0.7.1",
|
|
@@ -189,7 +189,7 @@ function Calendar({
|
|
|
189
189
|
};
|
|
190
190
|
|
|
191
191
|
return (
|
|
192
|
-
<div className="space-y-4">
|
|
192
|
+
<div className="min-w-0 space-y-4">
|
|
193
193
|
<div>
|
|
194
194
|
<div className="mb-4 flex items-center justify-between gap-2 border-b px-2 pb-4">
|
|
195
195
|
<button
|
|
@@ -318,21 +318,21 @@ function Calendar({
|
|
|
318
318
|
root: 'bg-transparent',
|
|
319
319
|
months: 'flex flex-col',
|
|
320
320
|
month:
|
|
321
|
-
'
|
|
321
|
+
'w-full min-w-0 max-w-[calc(100vw-4rem)] shrink-0 space-y-4 p-2 text-center font-semibold',
|
|
322
322
|
month_caption: 'hidden',
|
|
323
323
|
nav: 'hidden',
|
|
324
324
|
button_next: 'hidden',
|
|
325
325
|
button_previous: 'hidden',
|
|
326
326
|
month_grid: 'w-full border-collapse',
|
|
327
327
|
weeks: 'flex flex-col gap-1',
|
|
328
|
-
week: 'grid grid-cols-7 gap-
|
|
329
|
-
weekdays: 'grid w-full grid-cols-7 gap-
|
|
328
|
+
week: 'mt-2 grid grid-cols-7 gap-0.5 @[20rem]:gap-1',
|
|
329
|
+
weekdays: 'mb-2 grid w-full grid-cols-7 gap-0.5 @[20rem]:gap-1',
|
|
330
330
|
weekday:
|
|
331
331
|
'text-muted-foreground rounded-md font-normal text-[0.8rem] text-center',
|
|
332
|
-
day: '
|
|
332
|
+
day: 'relative min-w-0 p-0 text-center text-sm',
|
|
333
333
|
day_button: cn(
|
|
334
334
|
buttonVariants({ variant: 'ghost' }),
|
|
335
|
-
'h-
|
|
335
|
+
'aspect-square h-auto min-h-8 w-full rounded-md p-0 font-normal transition-colors duration-300',
|
|
336
336
|
'aria-selected:bg-primary aria-selected:text-primary-foreground',
|
|
337
337
|
'hover:bg-accent/50 hover:text-accent-foreground',
|
|
338
338
|
'hover:aria-selected:bg-primary hover:aria-selected:text-primary-foreground'
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const DATE_TIME_PICKER_LAYOUT_CLASS_NAMES = {
|
|
2
|
+
calendar: 'min-w-0 overflow-x-auto p-2',
|
|
3
|
+
container: '@container min-w-0 overflow-hidden',
|
|
4
|
+
layout: 'flex min-w-0 flex-col @[34rem]:flex-row',
|
|
5
|
+
time: 'flex min-w-0 flex-col gap-3 border-t p-3 @[34rem]:w-52 @[34rem]:shrink-0 @[34rem]:border-t-0 @[34rem]:border-l',
|
|
6
|
+
} as const;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DATE_TIME_PICKER_LAYOUT_CLASS_NAMES } from './date-time-picker-layout';
|
|
3
|
+
|
|
4
|
+
describe('DateTimePicker responsive layout', () => {
|
|
5
|
+
it('switches calendar and time controls from the picker container width', () => {
|
|
6
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.container).toContain(
|
|
7
|
+
'@container'
|
|
8
|
+
);
|
|
9
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.layout).toContain(
|
|
10
|
+
'@[34rem]:flex-row'
|
|
11
|
+
);
|
|
12
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.time).toContain(
|
|
13
|
+
'@[34rem]:border-l'
|
|
14
|
+
);
|
|
15
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.layout).not.toContain('sm:');
|
|
16
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.time).not.toContain('sm:');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('contains narrow calendars instead of letting them escape the dialog', () => {
|
|
20
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.calendar).toContain('min-w-0');
|
|
21
|
+
expect(DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.calendar).toContain(
|
|
22
|
+
'overflow-x-auto'
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
useRef,
|
|
32
32
|
useState,
|
|
33
33
|
} from 'react';
|
|
34
|
+
import { DATE_TIME_PICKER_LAYOUT_CLASS_NAMES } from './date-time-picker-layout';
|
|
34
35
|
import { Separator } from './separator';
|
|
35
36
|
|
|
36
37
|
interface DateTimePickerProps {
|
|
@@ -63,7 +64,6 @@ interface DateTimePickerProps {
|
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
// Utility to find the nearest scrollable parent
|
|
67
67
|
function getScrollableParent(node: HTMLElement | null): HTMLElement | null {
|
|
68
68
|
if (!node) return null;
|
|
69
69
|
let parent = node.parentElement;
|
|
@@ -530,7 +530,7 @@ export function DateTimePicker({
|
|
|
530
530
|
: undefined;
|
|
531
531
|
|
|
532
532
|
const timeControl = showTimeSelect ? (
|
|
533
|
-
<div className=
|
|
533
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.time}>
|
|
534
534
|
<div className="space-y-1">
|
|
535
535
|
<div className="flex items-center gap-2 font-medium text-sm">
|
|
536
536
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
@@ -628,7 +628,7 @@ export function DateTimePicker({
|
|
|
628
628
|
|
|
629
629
|
// Inline content (used both for inline mode and popover content)
|
|
630
630
|
const pickerContent = (
|
|
631
|
-
<div className=
|
|
631
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.container}>
|
|
632
632
|
{date && (
|
|
633
633
|
<div className="border-b bg-muted/30 px-3 py-2">
|
|
634
634
|
<div className="flex min-w-0 items-center gap-2 text-sm">
|
|
@@ -646,8 +646,8 @@ export function DateTimePicker({
|
|
|
646
646
|
</div>
|
|
647
647
|
)}
|
|
648
648
|
|
|
649
|
-
<div className=
|
|
650
|
-
<div className=
|
|
649
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.layout}>
|
|
650
|
+
<div className={DATE_TIME_PICKER_LAYOUT_CLASS_NAMES.calendar}>
|
|
651
651
|
<Calendar
|
|
652
652
|
mode="single"
|
|
653
653
|
selected={date}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __imageExtensionPrivate } from '../image-extension';
|
|
3
|
+
|
|
4
|
+
describe('ImageExtension clipboard files', () => {
|
|
5
|
+
it('keeps images when screenshot clipboards advertise text and HTML', () => {
|
|
6
|
+
const imageFile = new File(['image'], 'screenshot.png', {
|
|
7
|
+
type: 'image/png',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const images = __imageExtensionPrivate.getClipboardImageFiles([
|
|
11
|
+
{ type: 'text/plain', getAsFile: () => null } as DataTransferItem,
|
|
12
|
+
{ type: 'text/html', getAsFile: () => null } as DataTransferItem,
|
|
13
|
+
{
|
|
14
|
+
type: 'image/png',
|
|
15
|
+
getAsFile: () => imageFile,
|
|
16
|
+
} as DataTransferItem,
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
expect(images).toEqual([imageFile]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('names an unnamed screenshot from its clipboard MIME type', () => {
|
|
23
|
+
const unnamedImage = new File(['image'], '', { type: 'image/png' });
|
|
24
|
+
|
|
25
|
+
const [image] = __imageExtensionPrivate.getClipboardImageFiles([
|
|
26
|
+
{
|
|
27
|
+
type: 'image/png',
|
|
28
|
+
getAsFile: () => unnamedImage,
|
|
29
|
+
} as DataTransferItem,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
expect(image).toBeInstanceOf(File);
|
|
33
|
+
expect(image?.name).toBe('pasted-image.png');
|
|
34
|
+
expect(image?.type).toBe('image/png');
|
|
35
|
+
expect(image?.size).toBe(unnamedImage.size);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('adds an extension to an extensionless clipboard filename', () => {
|
|
39
|
+
const extensionlessImage = new File(['image'], 'Screenshot', {
|
|
40
|
+
type: 'image/jpeg',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const [image] = __imageExtensionPrivate.getClipboardImageFiles([
|
|
44
|
+
{
|
|
45
|
+
type: 'image/jpeg',
|
|
46
|
+
getAsFile: () => extensionlessImage,
|
|
47
|
+
} as DataTransferItem,
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
expect(image?.name).toBe('Screenshot.jpg');
|
|
51
|
+
expect(image?.type).toBe('image/jpeg');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('restores a missing file MIME type from the clipboard item', () => {
|
|
55
|
+
const imageWithoutType = new File(['image'], '', { type: '' });
|
|
56
|
+
|
|
57
|
+
const [image] = __imageExtensionPrivate.getClipboardImageFiles([
|
|
58
|
+
{
|
|
59
|
+
type: 'image/webp',
|
|
60
|
+
getAsFile: () => imageWithoutType,
|
|
61
|
+
} as DataTransferItem,
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
expect(image?.name).toBe('pasted-image.webp');
|
|
65
|
+
expect(image?.type).toBe('image/webp');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('ignores non-image items and null image files without losing valid images', () => {
|
|
69
|
+
const imageFile = new File(['image'], 'capture.png', {
|
|
70
|
+
type: 'image/png',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const images = __imageExtensionPrivate.getClipboardImageFiles([
|
|
74
|
+
{
|
|
75
|
+
type: 'text/plain',
|
|
76
|
+
getAsFile: () => new File(['text'], 'note.txt'),
|
|
77
|
+
} as DataTransferItem,
|
|
78
|
+
{ type: 'image/png', getAsFile: () => null } as DataTransferItem,
|
|
79
|
+
{
|
|
80
|
+
type: 'image/png',
|
|
81
|
+
getAsFile: () => imageFile,
|
|
82
|
+
} as DataTransferItem,
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
expect(images).toEqual([imageFile]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import type { Editor as TiptapEditor } from '@tiptap/react';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { getEditorExtensions } from '../extensions';
|
|
4
6
|
import { FixedToolbar } from '../tool-bar';
|
|
5
7
|
|
|
6
|
-
function createEditorStub():
|
|
8
|
+
function createEditorStub(): TiptapEditor {
|
|
7
9
|
const chain = {
|
|
8
10
|
focus: vi.fn(() => chain),
|
|
9
11
|
insertTable: vi.fn(() => chain),
|
|
@@ -26,9 +28,16 @@ function createEditorStub(): Editor {
|
|
|
26
28
|
return {
|
|
27
29
|
chain: vi.fn(() => chain),
|
|
28
30
|
isActive: vi.fn(() => false),
|
|
29
|
-
} as unknown as
|
|
31
|
+
} as unknown as TiptapEditor;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
const editors: Editor[] = [];
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
for (const editor of editors.splice(0)) editor.destroy();
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
32
41
|
describe('inline task conversion toolbar', () => {
|
|
33
42
|
it('delegates conversion to the provided callback', async () => {
|
|
34
43
|
const onConvertToTask = vi.fn();
|
|
@@ -73,4 +82,106 @@ describe('inline task conversion toolbar', () => {
|
|
|
73
82
|
screen.getByRole('button', { name: 'Liste ou titre repliable' })
|
|
74
83
|
).toBeInTheDocument();
|
|
75
84
|
});
|
|
85
|
+
|
|
86
|
+
it('keeps every control in a scrollbar-free horizontal lane without shrinking', () => {
|
|
87
|
+
const { container } = render(
|
|
88
|
+
<FixedToolbar
|
|
89
|
+
editor={createEditorStub()}
|
|
90
|
+
leadingContent={<button type="button">99% left</button>}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
const toolbar = container.firstElementChild;
|
|
94
|
+
const capacityButton = screen.getByRole('button', { name: '99% left' });
|
|
95
|
+
const boldButton = screen.getByRole('button', { name: 'Bold' });
|
|
96
|
+
const copyButton = screen.getByRole('button', { name: 'Copy content' });
|
|
97
|
+
|
|
98
|
+
expect(toolbar).toHaveClass(
|
|
99
|
+
'@container',
|
|
100
|
+
'flex-nowrap',
|
|
101
|
+
'max-w-full',
|
|
102
|
+
'overflow-x-auto',
|
|
103
|
+
'overflow-y-hidden',
|
|
104
|
+
'overscroll-x-contain',
|
|
105
|
+
'scrollbar-hide'
|
|
106
|
+
);
|
|
107
|
+
expect(toolbar).not.toHaveClass('flex-wrap');
|
|
108
|
+
expect(copyButton.closest('.ml-auto')).toBeNull();
|
|
109
|
+
expect(capacityButton.compareDocumentPosition(boldButton)).toBe(
|
|
110
|
+
Node.DOCUMENT_POSITION_FOLLOWING
|
|
111
|
+
);
|
|
112
|
+
expect(copyButton).toHaveClass('h-8', 'w-8', 'shrink-0', 'rounded-md');
|
|
113
|
+
expect(boldButton).toHaveClass('h-8', 'w-8', 'shrink-0', 'rounded-md');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('copies the full document in either Markdown or plain-text form', async () => {
|
|
117
|
+
const editor = new Editor({
|
|
118
|
+
extensions: getEditorExtensions(),
|
|
119
|
+
content: {
|
|
120
|
+
type: 'doc',
|
|
121
|
+
content: [
|
|
122
|
+
{
|
|
123
|
+
type: 'heading',
|
|
124
|
+
attrs: { level: 1 },
|
|
125
|
+
content: [{ type: 'text', text: 'Plan' }],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
type: 'bulletList',
|
|
129
|
+
content: [
|
|
130
|
+
{
|
|
131
|
+
type: 'listItem',
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
134
|
+
type: 'paragraph',
|
|
135
|
+
content: [
|
|
136
|
+
{
|
|
137
|
+
type: 'text',
|
|
138
|
+
text: 'Ship it',
|
|
139
|
+
marks: [{ type: 'bold' }],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
editors.push(editor);
|
|
151
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
152
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
153
|
+
configurable: true,
|
|
154
|
+
value: { writeText },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
render(<FixedToolbar editor={editor} />);
|
|
158
|
+
|
|
159
|
+
fireEvent.pointerDown(
|
|
160
|
+
await screen.findByRole('button', { name: 'Copy content' })
|
|
161
|
+
);
|
|
162
|
+
fireEvent.click(
|
|
163
|
+
await screen.findByRole('menuitem', { name: /Copy as Markdown/ })
|
|
164
|
+
);
|
|
165
|
+
await waitFor(() =>
|
|
166
|
+
expect(
|
|
167
|
+
screen.queryByRole('menuitem', { name: /Copy as Markdown/ })
|
|
168
|
+
).not.toBeInTheDocument()
|
|
169
|
+
);
|
|
170
|
+
await waitFor(() =>
|
|
171
|
+
expect(writeText).toHaveBeenLastCalledWith('# Plan\n\n- **Ship it**')
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
fireEvent.pointerDown(screen.getByRole('button', { name: 'Copy content' }));
|
|
175
|
+
fireEvent.click(
|
|
176
|
+
await screen.findByRole('menuitem', { name: /Copy as plain text/ })
|
|
177
|
+
);
|
|
178
|
+
await waitFor(() =>
|
|
179
|
+
expect(
|
|
180
|
+
screen.queryByRole('menuitem', { name: /Copy as plain text/ })
|
|
181
|
+
).not.toBeInTheDocument()
|
|
182
|
+
);
|
|
183
|
+
await waitFor(() =>
|
|
184
|
+
expect(writeText).toHaveBeenLastCalledWith('Plan\n\n• Ship it')
|
|
185
|
+
);
|
|
186
|
+
});
|
|
76
187
|
});
|