prime-ui-kit 0.11.1 → 0.13.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 (171) hide show
  1. package/README.md +4 -1
  2. package/dist/chunks/{chunk-WDTSHOTD.js → chunk-E6SQBSDF.js} +140 -30
  3. package/dist/chunks/{chunk-CUH6DJ57.js → chunk-Z3WKKAS6.js} +6572 -5024
  4. package/dist/chunks/{chunk-GKX3QAUC.js → chunk-ZSJMNA46.js} +14 -5
  5. package/dist/color-picker.js +2 -2
  6. package/dist/components/chat/Chat.d.ts +11 -12
  7. package/dist/components/chat/chatContext.d.ts +2 -0
  8. package/dist/components/chat-list/ChatList.d.ts +191 -0
  9. package/dist/components/chat-list/formatChatTime.d.ts +6 -0
  10. package/dist/components/command-menu/CommandMenu.d.ts +10 -5
  11. package/dist/components/composer/Composer.d.ts +3 -10
  12. package/dist/components/data-table/DataTable.d.ts +16 -2
  13. package/dist/components/data-table/Row.d.ts +23 -7
  14. package/dist/components/data-table/StateRows.d.ts +5 -1
  15. package/dist/components/data-table/reorder.d.ts +3 -1
  16. package/dist/components/data-table/rows.d.ts +20 -4
  17. package/dist/components/file-upload/FileUpload.d.ts +7 -0
  18. package/dist/components/gantt/Gantt.d.ts +19 -1
  19. package/dist/components/gantt/GanttBar.d.ts +4 -1
  20. package/dist/components/gantt/GanttRow.d.ts +8 -1
  21. package/dist/components/index.d.ts +11 -7
  22. package/dist/components/kanban/Kanban.d.ts +17 -2
  23. package/dist/components/list/List.d.ts +120 -0
  24. package/dist/components/message/Message.d.ts +13 -0
  25. package/dist/components/message/MessageParts.d.ts +2 -0
  26. package/dist/components/modal/DialogParts.d.ts +5 -1
  27. package/dist/components/select/Select.d.ts +11 -5
  28. package/dist/components/select/SelectItem.d.ts +19 -0
  29. package/dist/components/select/selectContext.d.ts +4 -0
  30. package/dist/components/sparkline/Sparkline.d.ts +14 -3
  31. package/dist/components/textarea/Textarea.d.ts +11 -3
  32. package/dist/hooks/useUploadQueue.d.ts +5 -1
  33. package/dist/icons/registry.d.ts +2 -0
  34. package/dist/index.css +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +10 -2
  37. package/dist/internal/VisuallyHidden.d.ts +6 -1
  38. package/dist/internal/attachmentTile.d.ts +5 -1
  39. package/dist/internal/richText.d.ts +7 -2
  40. package/dist/internal/textFormatting.d.ts +52 -0
  41. package/dist/internal/textFormattingToolbar.d.ts +26 -0
  42. package/dist/skill/api-contract.md +1 -1
  43. package/dist/skill/cheatsheet.md +13 -1
  44. package/dist/skill/choosing.md +9 -4
  45. package/dist/skill/components.md +7 -5
  46. package/dist/skill/composition.md +9 -1
  47. package/dist/skill/layouts.md +4 -1
  48. package/dist/skill/patterns/app-shell.module.css +18 -0
  49. package/dist/skill/patterns/app-shell.tsx +301 -0
  50. package/dist/skill/patterns/chat.module.css +0 -25
  51. package/dist/skill/patterns/chat.tsx +72 -29
  52. package/dist/skill/patterns/kanban-board.module.css +28 -0
  53. package/dist/skill/patterns/kanban-board.tsx +612 -0
  54. package/dist/skill/patterns/media-library.module.css +66 -0
  55. package/dist/skill/patterns/media-library.tsx +638 -0
  56. package/dist/skill/patterns/schedule.module.css +22 -0
  57. package/dist/skill/patterns/schedule.tsx +722 -0
  58. package/dist/skill/reference/components/chat/COMPONENT.md +13 -4
  59. package/dist/skill/reference/components/chat/examples/states.tsx +100 -0
  60. package/dist/skill/reference/components/chat-list/COMPONENT.md +256 -0
  61. package/dist/skill/reference/components/chat-list/examples/controlled.tsx +103 -0
  62. package/dist/skill/reference/components/chat-list/examples/examples.module.css +66 -0
  63. package/dist/skill/reference/components/chat-list/examples/live.tsx +133 -0
  64. package/dist/skill/reference/components/chat-list/examples/long-list.tsx +87 -0
  65. package/dist/skill/reference/components/chat-list/examples/narrow.tsx +88 -0
  66. package/dist/skill/reference/components/chat-list/examples/overview.tsx +194 -0
  67. package/dist/skill/reference/components/chat-list/examples/search.tsx +71 -0
  68. package/dist/skill/reference/components/chat-list/examples/sizes.tsx +61 -0
  69. package/dist/skill/reference/components/chat-list/examples/states.tsx +107 -0
  70. package/dist/skill/reference/components/chat-list/examples/structure.tsx +135 -0
  71. package/dist/skill/reference/components/command-menu/COMPONENT.md +15 -11
  72. package/dist/skill/reference/components/command-menu/examples/server-search.tsx +11 -9
  73. package/dist/skill/reference/components/composer/COMPONENT.md +8 -7
  74. package/dist/skill/reference/components/composer/examples/formatting.tsx +1 -1
  75. package/dist/skill/reference/components/confirm-dialog/COMPONENT.md +1 -0
  76. package/dist/skill/reference/components/data-table/COMPONENT.md +21 -6
  77. package/dist/skill/reference/components/data-table/examples/groups.tsx +2 -1
  78. package/dist/skill/reference/components/data-table/examples/trend.tsx +85 -0
  79. package/dist/skill/reference/components/drawer/COMPONENT.md +7 -6
  80. package/dist/skill/reference/components/drawer/examples/client-card.tsx +50 -0
  81. package/dist/skill/reference/components/fieldset/COMPONENT.md +1 -0
  82. package/dist/skill/reference/components/file-upload/COMPONENT.md +12 -8
  83. package/dist/skill/reference/components/file-upload/examples/overview.tsx +14 -3
  84. package/dist/skill/reference/components/gantt/COMPONENT.md +15 -5
  85. package/dist/skill/reference/components/gantt/examples/row-link.tsx +96 -0
  86. package/dist/skill/reference/components/kanban/COMPONENT.md +12 -4
  87. package/dist/skill/reference/components/kanban/examples/item-link.tsx +92 -0
  88. package/dist/skill/reference/components/list/COMPONENT.md +195 -0
  89. package/dist/skill/reference/components/list/examples/as-link.tsx +37 -0
  90. package/dist/skill/reference/components/list/examples/examples.module.css +18 -0
  91. package/dist/skill/reference/components/list/examples/overflow.tsx +46 -0
  92. package/dist/skill/reference/components/list/examples/overview.tsx +63 -0
  93. package/dist/skill/reference/components/list/examples/reorder.tsx +48 -0
  94. package/dist/skill/reference/components/list/examples/sizes.tsx +35 -0
  95. package/dist/skill/reference/components/list/examples/states.tsx +27 -0
  96. package/dist/skill/reference/components/list/examples/with-icon.tsx +60 -0
  97. package/dist/skill/reference/components/message/COMPONENT.md +18 -2
  98. package/dist/skill/reference/components/message/examples/bot-actions.tsx +51 -0
  99. package/dist/skill/reference/components/modal/COMPONENT.md +6 -4
  100. package/dist/skill/reference/components/page-content/COMPONENT.md +2 -2
  101. package/dist/skill/reference/components/page-content/examples/examples.module.css +5 -12
  102. package/dist/skill/reference/components/page-content/examples/list-detail.tsx +13 -18
  103. package/dist/skill/reference/components/select/COMPONENT.md +38 -7
  104. package/dist/skill/reference/components/select/examples/create-action.tsx +107 -0
  105. package/dist/skill/reference/components/select/examples/saving.tsx +47 -0
  106. package/dist/skill/reference/components/sheet/COMPONENT.md +4 -4
  107. package/dist/skill/reference/components/sparkline/COMPONENT.md +16 -2
  108. package/dist/skill/reference/components/sparkline/examples/compact.tsx +41 -0
  109. package/dist/skill/reference/components/sparkline/examples/examples.module.css +12 -0
  110. package/dist/skill/reference/components/textarea/COMPONENT.md +22 -2
  111. package/dist/skill/reference/components/textarea/examples/formatting.tsx +20 -0
  112. package/dist/skill/reference/components/visually-hidden/COMPONENT.md +6 -3
  113. package/dist/skill/reference/components/visually-hidden/examples/overview.tsx +2 -2
  114. package/package.json +2 -2
  115. package/src/components/chat/COMPONENT.md +13 -4
  116. package/src/components/chat/examples/states.tsx +100 -0
  117. package/src/components/chat-list/COMPONENT.md +256 -0
  118. package/src/components/chat-list/examples/controlled.tsx +103 -0
  119. package/src/components/chat-list/examples/examples.module.css +66 -0
  120. package/src/components/chat-list/examples/live.tsx +133 -0
  121. package/src/components/chat-list/examples/long-list.tsx +87 -0
  122. package/src/components/chat-list/examples/narrow.tsx +88 -0
  123. package/src/components/chat-list/examples/overview.tsx +194 -0
  124. package/src/components/chat-list/examples/search.tsx +71 -0
  125. package/src/components/chat-list/examples/sizes.tsx +61 -0
  126. package/src/components/chat-list/examples/states.tsx +107 -0
  127. package/src/components/chat-list/examples/structure.tsx +135 -0
  128. package/src/components/command-menu/COMPONENT.md +15 -11
  129. package/src/components/command-menu/examples/server-search.tsx +11 -9
  130. package/src/components/composer/COMPONENT.md +8 -7
  131. package/src/components/composer/examples/formatting.tsx +1 -1
  132. package/src/components/confirm-dialog/COMPONENT.md +1 -0
  133. package/src/components/data-table/COMPONENT.md +21 -6
  134. package/src/components/data-table/examples/groups.tsx +2 -1
  135. package/src/components/data-table/examples/trend.tsx +85 -0
  136. package/src/components/drawer/COMPONENT.md +7 -6
  137. package/src/components/drawer/examples/client-card.tsx +50 -0
  138. package/src/components/fieldset/COMPONENT.md +1 -0
  139. package/src/components/file-upload/COMPONENT.md +12 -8
  140. package/src/components/file-upload/examples/overview.tsx +14 -3
  141. package/src/components/gantt/COMPONENT.md +15 -5
  142. package/src/components/gantt/examples/row-link.tsx +96 -0
  143. package/src/components/kanban/COMPONENT.md +12 -4
  144. package/src/components/kanban/examples/item-link.tsx +92 -0
  145. package/src/components/list/COMPONENT.md +195 -0
  146. package/src/components/list/examples/as-link.tsx +37 -0
  147. package/src/components/list/examples/examples.module.css +18 -0
  148. package/src/components/list/examples/overflow.tsx +46 -0
  149. package/src/components/list/examples/overview.tsx +63 -0
  150. package/src/components/list/examples/reorder.tsx +48 -0
  151. package/src/components/list/examples/sizes.tsx +35 -0
  152. package/src/components/list/examples/states.tsx +27 -0
  153. package/src/components/list/examples/with-icon.tsx +60 -0
  154. package/src/components/message/COMPONENT.md +18 -2
  155. package/src/components/message/examples/bot-actions.tsx +51 -0
  156. package/src/components/modal/COMPONENT.md +6 -4
  157. package/src/components/page-content/COMPONENT.md +2 -2
  158. package/src/components/page-content/examples/examples.module.css +5 -12
  159. package/src/components/page-content/examples/list-detail.tsx +13 -18
  160. package/src/components/select/COMPONENT.md +38 -7
  161. package/src/components/select/examples/create-action.tsx +107 -0
  162. package/src/components/select/examples/saving.tsx +47 -0
  163. package/src/components/sheet/COMPONENT.md +4 -4
  164. package/src/components/sparkline/COMPONENT.md +16 -2
  165. package/src/components/sparkline/examples/compact.tsx +41 -0
  166. package/src/components/sparkline/examples/examples.module.css +12 -0
  167. package/src/components/textarea/COMPONENT.md +22 -2
  168. package/src/components/textarea/examples/formatting.tsx +20 -0
  169. package/src/components/visually-hidden/COMPONENT.md +6 -3
  170. package/src/components/visually-hidden/examples/overview.tsx +2 -2
  171. package/dist/components/composer/format.d.ts +0 -39
@@ -0,0 +1,638 @@
1
+ /** A media library of a catalog object: PageToolbar with sections, search, order and upload; uploads in FileUpload tiles through a queue; a grid of Thumbnails that open MediaViewer out of the tile or are picked for bulk download and delete; a Skeleton first load and a filtered empty state in a Crossfade. */
2
+ import {
3
+ Button,
4
+ ConfirmDialog,
5
+ Crossfade,
6
+ EmptyPage,
7
+ FileUpload,
8
+ formatFileSize,
9
+ Icon,
10
+ MediaViewer,
11
+ type MediaViewerItem,
12
+ PageContent,
13
+ PageToolbar,
14
+ SegmentedControl,
15
+ Select,
16
+ Skeleton,
17
+ SmartFilter,
18
+ Thumbnail,
19
+ Typography,
20
+ type UploadContext,
21
+ type UploadItem,
22
+ useNotifications,
23
+ useUploadQueue,
24
+ } from "prime-ui-kit";
25
+ import * as React from "react";
26
+
27
+ import styles from "./media-library.module.css";
28
+
29
+ type Kind = "photo" | "video" | "document";
30
+ type Section = "all" | Kind;
31
+ type Order = "new" | "old" | "name";
32
+
33
+ type Media = {
34
+ id: string;
35
+ kind: Kind;
36
+ name: string;
37
+ /** The full file: the picture, the video or the scan the viewer shows. */
38
+ src: string;
39
+ /** A small copy for the grid; the viewer shows it at once while `src` loads. */
40
+ thumbnail?: string;
41
+ width?: number;
42
+ height?: number;
43
+ size: number;
44
+ added: string;
45
+ };
46
+
47
+ const CLIP = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4";
48
+
49
+ const photo = (seed: string): Pick<Media, "src" | "thumbnail" | "width" | "height"> => ({
50
+ src: `https://picsum.photos/seed/tower-${seed}/1600/1200`,
51
+ thumbnail: `https://picsum.photos/seed/tower-${seed}/480/360`,
52
+ width: 1600,
53
+ height: 1200,
54
+ });
55
+
56
+ const scan = (seed: string): Pick<Media, "src" | "thumbnail" | "width" | "height"> => ({
57
+ src: `https://picsum.photos/seed/tower-${seed}/1240/1754`,
58
+ thumbnail: `https://picsum.photos/seed/tower-${seed}/360/510`,
59
+ width: 1240,
60
+ height: 1754,
61
+ });
62
+
63
+ /** What the server returns for the object's library. */
64
+ const LIBRARY: Media[] = [
65
+ {
66
+ id: "m01",
67
+ kind: "photo",
68
+ name: "Фасад с Невского.jpg",
69
+ size: 2_480_000,
70
+ added: "2026-10-09",
71
+ ...photo("facade"),
72
+ },
73
+ {
74
+ id: "m02",
75
+ kind: "photo",
76
+ name: "Входная группа.jpg",
77
+ size: 1_960_000,
78
+ added: "2026-10-09",
79
+ ...photo("entrance"),
80
+ },
81
+ {
82
+ id: "m03",
83
+ kind: "video",
84
+ name: "Обход этажа 7.mp4",
85
+ size: 48_200_000,
86
+ added: "2026-10-08",
87
+ src: CLIP,
88
+ width: 1280,
89
+ height: 720,
90
+ },
91
+ {
92
+ id: "m04",
93
+ kind: "photo",
94
+ name: "Лобби и ресепшен.jpg",
95
+ size: 2_120_000,
96
+ added: "2026-10-07",
97
+ ...photo("lobby"),
98
+ },
99
+ {
100
+ id: "m05",
101
+ kind: "photo",
102
+ name: "Опенспейс, этаж 7.jpg",
103
+ size: 2_730_000,
104
+ added: "2026-10-07",
105
+ ...photo("openspace"),
106
+ },
107
+ {
108
+ id: "m06",
109
+ kind: "document",
110
+ name: "Поэтажный план.jpg",
111
+ size: 860_000,
112
+ added: "2026-10-06",
113
+ ...scan("plan"),
114
+ },
115
+ {
116
+ id: "m07",
117
+ kind: "photo",
118
+ name: "Переговорная «Нева».jpg",
119
+ size: 1_840_000,
120
+ added: "2026-10-05",
121
+ ...photo("meeting"),
122
+ },
123
+ {
124
+ id: "m08",
125
+ kind: "video",
126
+ name: "Подъезд к парковке.mp4",
127
+ size: 31_500_000,
128
+ added: "2026-10-04",
129
+ src: CLIP,
130
+ width: 1280,
131
+ height: 720,
132
+ },
133
+ {
134
+ id: "m09",
135
+ kind: "photo",
136
+ name: "Терраса на крыше.jpg",
137
+ size: 3_050_000,
138
+ added: "2026-10-03",
139
+ ...photo("terrace"),
140
+ },
141
+ {
142
+ id: "m10",
143
+ kind: "document",
144
+ name: "Выписка из ЕГРН.jpg",
145
+ size: 640_000,
146
+ added: "2026-10-02",
147
+ ...scan("egrn"),
148
+ },
149
+ {
150
+ id: "m11",
151
+ kind: "photo",
152
+ name: "Паркинг, уровень −1.jpg",
153
+ size: 1_720_000,
154
+ added: "2026-09-30",
155
+ ...photo("parking"),
156
+ },
157
+ {
158
+ id: "m12",
159
+ kind: "document",
160
+ name: "Техпаспорт, стр. 1.jpg",
161
+ size: 720_000,
162
+ added: "2026-09-28",
163
+ ...scan("passport"),
164
+ },
165
+ ];
166
+
167
+ const SECTIONS: { value: Section; label: string }[] = [
168
+ { value: "all", label: "Все" },
169
+ { value: "photo", label: "Фото" },
170
+ { value: "video", label: "Видео" },
171
+ { value: "document", label: "Документы" },
172
+ ];
173
+
174
+ const ORDERS: { value: Order; label: string }[] = [
175
+ { value: "new", label: "Сначала новые" },
176
+ { value: "old", label: "Сначала старые" },
177
+ { value: "name", label: "По названию" },
178
+ ];
179
+
180
+ const FALLBACK_ICON = {
181
+ photo: "object.image",
182
+ video: "action.video",
183
+ document: "object.document",
184
+ } as const;
185
+
186
+ /** Placeholder tiles of the first load: the grid's own geometry. */
187
+ const PLACEHOLDER_TILES = ["p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8"];
188
+
189
+ /** How long a finished upload keeps its tile, so its ring fills and the disk fades before it moves. */
190
+ const DONE_HOLD_MS = 900;
191
+
192
+ const DAY = new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short" });
193
+
194
+ const filesWord = (count: number) => {
195
+ const tens = count % 100;
196
+ const units = count % 10;
197
+ if (tens >= 11 && tens <= 14) return "файлов";
198
+ if (units === 1) return "файл";
199
+ if (units >= 2 && units <= 4) return "файла";
200
+ return "файлов";
201
+ };
202
+
203
+ const today = () => new Date().toISOString().slice(0, 10);
204
+
205
+ /** Upload attempts per file, and how many files have started: the stand-in drops every third one once. */
206
+ const attempts = new WeakMap<File, number>();
207
+ let started = 0;
208
+
209
+ /** A stand-in for the server: sends the file in steps and answers with its address (a local URL here). */
210
+ function sendFile(file: File, { signal, onProgress }: UploadContext): Promise<string> {
211
+ const attempt = (attempts.get(file) ?? 0) + 1;
212
+ attempts.set(file, attempt);
213
+ if (attempt === 1) started += 1;
214
+ const flaky = attempt === 1 && started % 3 === 0;
215
+ return new Promise((resolve, reject) => {
216
+ let sent = 0;
217
+ const timer = window.setInterval(() => {
218
+ sent = Math.min(1, sent + 0.08 + Math.random() * 0.1);
219
+ if (flaky && sent > 0.5) {
220
+ window.clearInterval(timer);
221
+ reject(new Error("Нет связи с сервером"));
222
+ return;
223
+ }
224
+ onProgress(sent);
225
+ if (sent < 1) return;
226
+ window.clearInterval(timer);
227
+ resolve(URL.createObjectURL(file));
228
+ }, 350);
229
+ signal.addEventListener("abort", () => {
230
+ window.clearInterval(timer);
231
+ reject(signal.reason);
232
+ });
233
+ });
234
+ }
235
+
236
+ /** A finished upload as a file of the library. */
237
+ const toMedia = (item: UploadItem<string>): Media => ({
238
+ id: item.id,
239
+ kind: item.file.type.startsWith("video/") ? "video" : "photo",
240
+ name: item.file.name,
241
+ src: item.result ?? "",
242
+ size: item.file.size,
243
+ added: today(),
244
+ });
245
+
246
+ const toViewerItem = (media: Media): MediaViewerItem => ({
247
+ type: media.kind === "video" ? "video" : "image",
248
+ src: media.src,
249
+ thumbnail: media.thumbnail,
250
+ width: media.width,
251
+ height: media.height,
252
+ alt: media.name,
253
+ caption: `${media.name} · ${formatFileSize(media.size)} · ${DAY.format(new Date(media.added))}`,
254
+ });
255
+
256
+ export default function MediaLibraryPattern() {
257
+ const { notify } = useNotifications();
258
+ const [loading, setLoading] = React.useState(true);
259
+ const [library, setLibrary] = React.useState<Media[]>([]);
260
+ const [section, setSection] = React.useState<Section>("all");
261
+ const [search, setSearch] = React.useState("");
262
+ const [order, setOrder] = React.useState<Order>("new");
263
+ const [selecting, setSelecting] = React.useState(false);
264
+ const [selected, setSelected] = React.useState<ReadonlySet<string>>(new Set());
265
+ const [open, setOpen] = React.useState(false);
266
+ const [index, setIndex] = React.useState(0);
267
+
268
+ const queue = useUploadQueue({ upload: sendFile, concurrency: 2 });
269
+ const inputRef = React.useRef<HTMLInputElement>(null);
270
+ const tiles = React.useRef(new Map<string, HTMLElement>());
271
+ const moving = React.useRef(new Set<string>());
272
+
273
+ // First load: the server answers after a moment; the grid holds its shape meanwhile.
274
+ React.useEffect(() => {
275
+ const timer = window.setTimeout(() => {
276
+ setLibrary(LIBRARY);
277
+ setLoading(false);
278
+ }, 1200);
279
+ return () => window.clearTimeout(timer);
280
+ }, []);
281
+
282
+ // A finished upload leaves its tile for the library, first in it.
283
+ const { items: uploads, remove: removeUpload } = queue;
284
+ React.useEffect(() => {
285
+ for (const item of uploads) {
286
+ if (item.status !== "done" || moving.current.has(item.id)) continue;
287
+ moving.current.add(item.id);
288
+ window.setTimeout(() => {
289
+ setLibrary((current) => [toMedia(item), ...current]);
290
+ removeUpload(item.id);
291
+ }, DONE_HOLD_MS);
292
+ }
293
+ }, [uploads, removeUpload]);
294
+
295
+ const query = search.trim().toLowerCase();
296
+ const visible = library
297
+ .filter(
298
+ (media) =>
299
+ (section === "all" || media.kind === section) && media.name.toLowerCase().includes(query),
300
+ )
301
+ .sort((a, b) => {
302
+ if (order === "name") return a.name.localeCompare(b.name, "ru");
303
+ const byDate = a.added.localeCompare(b.added);
304
+ return order === "new" ? -byDate : byDate;
305
+ });
306
+ const viewerItems = visible.map(toViewerItem);
307
+ const count = (value: Section) =>
308
+ value === "all" ? library.length : library.filter((media) => media.kind === value).length;
309
+
310
+ const state = loading
311
+ ? "loading"
312
+ : visible.length > 0
313
+ ? `ready:${section}:${order}`
314
+ : library.length > 0
315
+ ? "nothing"
316
+ : "empty";
317
+
318
+ const toggle = (id: string, checked: boolean) =>
319
+ setSelected((current) => {
320
+ const next = new Set(current);
321
+ if (checked) next.add(id);
322
+ else next.delete(id);
323
+ return next;
324
+ });
325
+
326
+ const stopSelecting = () => {
327
+ setSelecting(false);
328
+ setSelected(new Set());
329
+ };
330
+
331
+ const resetFilters = () => {
332
+ setSection("all");
333
+ setSearch("");
334
+ };
335
+
336
+ const download = () => {
337
+ notify({
338
+ tone: "success",
339
+ title: `Скачиваем ${selected.size} ${filesWord(selected.size)}`,
340
+ description: "Архив сохранится в папку загрузок.",
341
+ });
342
+ stopSelecting();
343
+ };
344
+
345
+ // A stand-in for the delete request: the dialog stays busy until it answers.
346
+ const removeSelected = () =>
347
+ new Promise<void>((resolve) => window.setTimeout(resolve, 800)).then(() => {
348
+ const removed = selected.size;
349
+ for (const media of library) {
350
+ if (selected.has(media.id) && media.src.startsWith("blob:")) URL.revokeObjectURL(media.src);
351
+ }
352
+ setLibrary((current) => current.filter((media) => !selected.has(media.id)));
353
+ stopSelecting();
354
+ notify({ tone: "success", title: `Удалено: ${removed} ${filesWord(removed)}` });
355
+ });
356
+
357
+ const openAt = (at: number) => {
358
+ setIndex(at);
359
+ setOpen(true);
360
+ };
361
+
362
+ const totalSize = visible.reduce((sum, media) => sum + media.size, 0);
363
+
364
+ return (
365
+ <PageContent.Section aria-labelledby="media-title">
366
+ <PageContent.Header>
367
+ <PageContent.Title id="media-title">Медиатека объекта</PageContent.Title>
368
+ <PageContent.Description>
369
+ БЦ «Северная башня», Невский пр., 112: фото, видео и сканы для карточки в каталоге.
370
+ </PageContent.Description>
371
+ </PageContent.Header>
372
+ <PageContent.Body>
373
+ <SmartFilter.Root
374
+ fields={[]}
375
+ search={search}
376
+ onSearchChange={setSearch}
377
+ labels={{ searchPlaceholder: "Поиск по названию" }}
378
+ >
379
+ <PageToolbar.Root aria-label="Медиатека">
380
+ <PageToolbar.Tools>
381
+ <SmartFilter.Toolbar />
382
+ </PageToolbar.Tools>
383
+ <PageToolbar.Sections>
384
+ <SegmentedControl.Root
385
+ fullWidth
386
+ value={section}
387
+ onValueChange={(value) => setSection(value as Section)}
388
+ aria-label="Раздел"
389
+ >
390
+ {SECTIONS.map((item) => (
391
+ <SegmentedControl.Item key={item.value} value={item.value}>
392
+ {item.label}
393
+ {loading ? null : (
394
+ <SegmentedControl.Count>{count(item.value)}</SegmentedControl.Count>
395
+ )}
396
+ </SegmentedControl.Item>
397
+ ))}
398
+ </SegmentedControl.Root>
399
+ </PageToolbar.Sections>
400
+ <PageToolbar.View>
401
+ <Select.Root value={order} onValueChange={(value) => setOrder(value as Order)}>
402
+ <Select.Trigger aria-label="Порядок">
403
+ <Select.TriggerIcon>
404
+ <Icon name="sort.descending" />
405
+ </Select.TriggerIcon>
406
+ <Select.Value />
407
+ </Select.Trigger>
408
+ <Select.Content>
409
+ {ORDERS.map((item) => (
410
+ <Select.Item key={item.value} value={item.value}>
411
+ {item.label}
412
+ </Select.Item>
413
+ ))}
414
+ </Select.Content>
415
+ </Select.Root>
416
+ </PageToolbar.View>
417
+ <PageToolbar.Actions>
418
+ <Button.Root onClick={() => inputRef.current?.click()}>
419
+ <Button.Icon>
420
+ <Icon name="action.upload" />
421
+ </Button.Icon>
422
+ Загрузить
423
+ </Button.Root>
424
+ </PageToolbar.Actions>
425
+ </PageToolbar.Root>
426
+ </SmartFilter.Root>
427
+
428
+ {/* Uploads in flight: the add tile is also the drop zone; a done file moves into the grid. */}
429
+ <FileUpload.Root
430
+ label="Новые файлы"
431
+ hint="Фото и видео до 200 МБ. Загруженные появятся в медиатеке первыми."
432
+ trigger="tile"
433
+ layout="grid"
434
+ accept="image/*,video/*"
435
+ multiple
436
+ queue={queue}
437
+ inputRef={inputRef}
438
+ />
439
+
440
+ <section className={styles.library} aria-labelledby="files-title">
441
+ <div className={styles.libraryHeader}>
442
+ <div className={styles.heading}>
443
+ <Typography as="h2" variant="title-m" id="files-title">
444
+ Файлы
445
+ </Typography>
446
+ <Typography as="p" variant="caption" tone="muted" aria-live="polite">
447
+ {selecting
448
+ ? `Выбрано: ${selected.size}`
449
+ : loading
450
+ ? "Загружаем…"
451
+ : `${visible.length} ${filesWord(visible.length)} · ${formatFileSize(totalSize)}`}
452
+ </Typography>
453
+ </div>
454
+ <Crossfade state={selecting ? "select" : "browse"}>
455
+ {selecting ? (
456
+ <div className={styles.bar}>
457
+ <Button.Root
458
+ variant="soft"
459
+ tone="neutral"
460
+ disabled={selected.size === 0}
461
+ onClick={download}
462
+ >
463
+ <Button.Icon>
464
+ <Icon name="action.download" />
465
+ </Button.Icon>
466
+ Скачать
467
+ </Button.Root>
468
+ <ConfirmDialog.Root
469
+ title={`Удалить ${selected.size} ${filesWord(selected.size)}?`}
470
+ description="Файлы пропадут из карточки объекта в каталоге. Восстановить их будет нельзя."
471
+ confirmLabel="Удалить"
472
+ onConfirm={removeSelected}
473
+ >
474
+ <ConfirmDialog.Trigger>
475
+ <Button.Root variant="outline" tone="danger" disabled={selected.size === 0}>
476
+ <Button.Icon>
477
+ <Icon name="action.delete" />
478
+ </Button.Icon>
479
+ Удалить
480
+ </Button.Root>
481
+ </ConfirmDialog.Trigger>
482
+ </ConfirmDialog.Root>
483
+ <Button.Root variant="ghost" tone="neutral" onClick={stopSelecting}>
484
+ Отмена
485
+ </Button.Root>
486
+ </div>
487
+ ) : (
488
+ <div className={styles.bar}>
489
+ <Button.Root
490
+ variant="soft"
491
+ tone="neutral"
492
+ disabled={loading || visible.length === 0}
493
+ onClick={() => setSelecting(true)}
494
+ >
495
+ <Button.Icon>
496
+ <Icon name="action.check" />
497
+ </Button.Icon>
498
+ Выбрать
499
+ </Button.Root>
500
+ </div>
501
+ )}
502
+ </Crossfade>
503
+ </div>
504
+
505
+ <Crossfade state={state} aria-busy={loading}>
506
+ {loading ? (
507
+ <ul className={styles.grid}>
508
+ {PLACEHOLDER_TILES.map((key) => (
509
+ <li key={key} className={styles.tile}>
510
+ <Skeleton shape="block" className={styles.picture} />
511
+ <div className={styles.tileText}>
512
+ <Skeleton size="s" className={styles.nameBar} />
513
+ <Skeleton size="xs" className={styles.metaBar} />
514
+ </div>
515
+ </li>
516
+ ))}
517
+ </ul>
518
+ ) : null}
519
+
520
+ {state.startsWith("ready") ? (
521
+ <ul className={styles.grid}>
522
+ {visible.map((media, at) => {
523
+ const picture =
524
+ media.kind === "video" ? (
525
+ <Thumbnail.Video src={media.src} aria-label={media.name} />
526
+ ) : (
527
+ <Thumbnail.Image
528
+ src={media.thumbnail ?? media.src}
529
+ fit={media.kind === "document" ? "contain" : "cover"}
530
+ />
531
+ );
532
+ const fallback = (
533
+ <Thumbnail.Fallback>
534
+ <Icon name={FALLBACK_ICON[media.kind]} />
535
+ </Thumbnail.Fallback>
536
+ );
537
+ return (
538
+ <li key={media.id} className={styles.tile}>
539
+ {/* Browsing, a press opens the viewer out of the tile; selecting, it picks the file. */}
540
+ {selecting ? (
541
+ <Thumbnail.Root
542
+ fullWidth
543
+ ratio="4:3"
544
+ aria-label={media.name}
545
+ checked={selected.has(media.id)}
546
+ onCheckedChange={(checked) => toggle(media.id, checked)}
547
+ >
548
+ {picture}
549
+ {fallback}
550
+ </Thumbnail.Root>
551
+ ) : (
552
+ <Thumbnail.Root asChild fullWidth ratio="4:3">
553
+ <button
554
+ ref={(node) => {
555
+ if (node) tiles.current.set(media.id, node);
556
+ else tiles.current.delete(media.id);
557
+ }}
558
+ type="button"
559
+ aria-label={`Открыть: ${media.name}`}
560
+ aria-haspopup="dialog"
561
+ onClick={() => openAt(at)}
562
+ >
563
+ {picture}
564
+ {fallback}
565
+ </button>
566
+ </Thumbnail.Root>
567
+ )}
568
+ <div className={styles.tileText}>
569
+ <Typography as="span" variant="body-s" truncate title={media.name}>
570
+ {media.name}
571
+ </Typography>
572
+ <Typography as="span" variant="caption" tone="muted">
573
+ {formatFileSize(media.size)} · {DAY.format(new Date(media.added))}
574
+ </Typography>
575
+ </div>
576
+ </li>
577
+ );
578
+ })}
579
+ </ul>
580
+ ) : null}
581
+
582
+ {state === "nothing" ? (
583
+ <EmptyPage.Root size="s" role="status" aria-labelledby="media-nothing">
584
+ <EmptyPage.Icon>
585
+ <Icon name="action.search" />
586
+ </EmptyPage.Icon>
587
+ <EmptyPage.Title as="h3" id="media-nothing">
588
+ Ничего не найдено
589
+ </EmptyPage.Title>
590
+ <EmptyPage.Description>
591
+ В этом разделе нет файлов с таким названием.
592
+ </EmptyPage.Description>
593
+ <EmptyPage.Actions>
594
+ <Button.Root variant="soft" tone="neutral" onClick={resetFilters}>
595
+ Показать все файлы
596
+ </Button.Root>
597
+ </EmptyPage.Actions>
598
+ </EmptyPage.Root>
599
+ ) : null}
600
+
601
+ {state === "empty" ? (
602
+ <EmptyPage.Root size="s" aria-labelledby="media-empty">
603
+ <EmptyPage.Icon tone="accent">
604
+ <Icon name="object.image" />
605
+ </EmptyPage.Icon>
606
+ <EmptyPage.Title as="h3" id="media-empty">
607
+ Файлов пока нет
608
+ </EmptyPage.Title>
609
+ <EmptyPage.Description>
610
+ Перетащите фото и видео объекта в поле выше или нажмите «Загрузить».
611
+ </EmptyPage.Description>
612
+ </EmptyPage.Root>
613
+ ) : null}
614
+ </Crossfade>
615
+ </section>
616
+
617
+ <MediaViewer
618
+ items={viewerItems}
619
+ open={open}
620
+ onOpenChange={setOpen}
621
+ index={index}
622
+ onIndexChange={setIndex}
623
+ getOrigin={(at) => tiles.current.get(visible[at]?.id ?? "") ?? null}
624
+ thumbnails
625
+ renderActions={(item) => (
626
+ <Button.Root shape="circle" variant="shade" aria-label="Скачать файл" asChild>
627
+ <a href={item.src} download>
628
+ <Button.Icon>
629
+ <Icon name="action.download" />
630
+ </Button.Icon>
631
+ </a>
632
+ </Button.Root>
633
+ )}
634
+ />
635
+ </PageContent.Body>
636
+ </PageContent.Section>
637
+ );
638
+ }
@@ -0,0 +1,22 @@
1
+ /* Schedule: the page fills the app panel (`AppShell.Root fillViewport` + `PageContent.Root fill`);
2
+ the page panel sits close over the chart it filters and the chart takes the free height. */
3
+
4
+ .board {
5
+ display: flex;
6
+ flex: 1 1 auto;
7
+ flex-direction: column;
8
+ gap: var(--prime-space-4);
9
+ min-width: 0;
10
+ min-height: 0;
11
+ }
12
+
13
+ /* On a short screen the chart keeps room for a few rows; the page scrolls instead. */
14
+ .chart {
15
+ min-height: calc(var(--prime-space-24) * 3);
16
+ }
17
+
18
+ /* First column: the name over the role. */
19
+ .person {
20
+ display: grid;
21
+ min-width: 0;
22
+ }