@k2b/cloud 0.27.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.
Files changed (155) hide show
  1. package/package.json +7 -5
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/browser-code-contracts.ts +33 -63
  10. package/src/ai/browser.ts +1 -0
  11. package/src/ai/capabilities.ts +35 -9
  12. package/src/ai/chat/blocks.tsx +113 -24
  13. package/src/ai/chat/builtin-tools.tsx +3 -1
  14. package/src/ai/chat/composer-adapter.ts +8 -0
  15. package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
  16. package/src/ai/chat/message-actions.tsx +124 -102
  17. package/src/ai/chat/message-utils.ts +57 -6
  18. package/src/ai/chat/messages.ts +214 -0
  19. package/src/ai/chat/presentation.tsx +67 -28
  20. package/src/ai/chat/tool-groups.ts +1 -1
  21. package/src/ai/chat/tool-names.ts +157 -0
  22. package/src/ai/chat/turn-error.ts +21 -0
  23. package/src/ai/chat/turn-layout.ts +12 -0
  24. package/src/ai/chat/turn-view.tsx +69 -10
  25. package/src/ai/chat/user-message.tsx +19 -14
  26. package/src/ai/chat/visual-tools.tsx +1 -1
  27. package/src/ai/chat-tasks.ts +16 -3
  28. package/src/ai/client/controller.ts +23 -6
  29. package/src/ai/code-mode-skill.ts +21 -25
  30. package/src/ai/code-runtime-tools.ts +5 -1
  31. package/src/ai/code-source-contracts.ts +2 -2
  32. package/src/ai/compaction.ts +22 -3
  33. package/src/ai/data-analysis-skill.ts +3 -3
  34. package/src/ai/default-tools.ts +23 -17
  35. package/src/ai/executor.ts +262 -99
  36. package/src/ai/file-media-type.ts +4 -0
  37. package/src/ai/files-store.ts +17 -10
  38. package/src/ai/http.ts +11 -10
  39. package/src/ai/index.ts +3 -0
  40. package/src/ai/memories.ts +2 -0
  41. package/src/ai/memory-learning.ts +59 -5
  42. package/src/ai/memory-workflow-evidence.ts +35 -10
  43. package/src/ai/open-tool-calls.ts +87 -0
  44. package/src/ai/projects-routes.ts +5 -1
  45. package/src/ai/projects.ts +5 -1
  46. package/src/ai/protocol.ts +34 -1
  47. package/src/ai/provider.ts +16 -1
  48. package/src/ai/quota-provider.ts +4 -1
  49. package/src/ai/routes.ts +26 -10
  50. package/src/ai/run-timeout.ts +4 -5
  51. package/src/ai/runtime.ts +8 -0
  52. package/src/ai/settings.ts +54 -17
  53. package/src/ai/skill-seeds.ts +14 -6
  54. package/src/ai/store.ts +102 -34
  55. package/src/ai/structured.ts +2 -1
  56. package/src/ai/system-prompt.ts +9 -22
  57. package/src/ai/transcription.ts +58 -13
  58. package/src/ai/turn-failure.ts +104 -0
  59. package/src/ai/turn-guidance.ts +505 -0
  60. package/src/ai/turn-policy.ts +2 -1
  61. package/src/ai/types.ts +29 -1
  62. package/src/ai/ui.tsx +1 -0
  63. package/src/api/admin-core-settings.ts +16 -1
  64. package/src/api/admin-outgoing-mail.ts +185 -5
  65. package/src/api/me.ts +42 -1
  66. package/src/browser/FileChooser.tsx +82 -295
  67. package/src/browser/FileSaver.tsx +407 -0
  68. package/src/browser/app-badges.ts +16 -0
  69. package/src/browser/choose-files.browser-harness.tsx +22 -6
  70. package/src/browser/choose-files.tsx +23 -36
  71. package/src/browser/file-chooser-messages.ts +66 -0
  72. package/src/browser/file-providers.ts +258 -8
  73. package/src/browser/files.tsx +26 -3
  74. package/src/browser/notifications.ts +37 -1
  75. package/src/browser/provider-folder.tsx +301 -0
  76. package/src/browser/provider-list.ts +39 -0
  77. package/src/browser/save-files.browser-harness.tsx +35 -0
  78. package/src/browser/save-files.tsx +107 -0
  79. package/src/cli/admin/index.ts +3 -1
  80. package/src/cli/admin/notifications.ts +6 -0
  81. package/src/cli/admin/outgoing-mail.ts +104 -7
  82. package/src/contracts/app.ts +10 -0
  83. package/src/contracts/commands.ts +3 -1
  84. package/src/contracts/notification-group.ts +6 -0
  85. package/src/contracts/notification-types.ts +6 -0
  86. package/src/contracts/outgoing-mail.ts +189 -1
  87. package/src/contracts/registry.ts +1 -0
  88. package/src/contracts/user-notifications.ts +74 -0
  89. package/src/server/middleware/request-logger.ts +11 -2
  90. package/src/services/accounts/avatar.ts +5 -2
  91. package/src/services/help/store.ts +2 -1
  92. package/src/services/image-metadata.ts +438 -0
  93. package/src/services/index.ts +12 -1
  94. package/src/services/logging/redaction.ts +50 -0
  95. package/src/services/mandates/index.ts +4 -2
  96. package/src/services/mandates/policy.ts +20 -0
  97. package/src/services/notifications/batches.ts +139 -79
  98. package/src/services/notifications/browser.ts +48 -15
  99. package/src/services/notifications/channels.ts +33 -13
  100. package/src/services/notifications/dispatcher.ts +88 -6
  101. package/src/services/notifications/email-frame.fixture.html +51 -0
  102. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  103. package/src/services/notifications/email-mail.ts +101 -0
  104. package/src/services/notifications/index.ts +49 -36
  105. package/src/services/notifications/observability.ts +9 -1
  106. package/src/services/notifications/platform.ts +33 -5
  107. package/src/services/notifications/preview.ts +23 -0
  108. package/src/services/notifications/quiet.ts +153 -0
  109. package/src/services/notifications/runtime.ts +9 -3
  110. package/src/services/notifications/user.ts +6 -0
  111. package/src/services/outgoing-mail/admin.ts +84 -0
  112. package/src/services/outgoing-mail/attachments.ts +135 -0
  113. package/src/services/outgoing-mail/bulk.ts +11 -0
  114. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  115. package/src/services/outgoing-mail/drain.ts +60 -0
  116. package/src/services/outgoing-mail/enqueue.ts +180 -0
  117. package/src/services/outgoing-mail/index.ts +103 -10
  118. package/src/services/outgoing-mail/message.ts +14 -0
  119. package/src/services/outgoing-mail/messages.ts +431 -0
  120. package/src/services/outgoing-mail/retention.ts +48 -0
  121. package/src/services/outgoing-mail/runtime.ts +72 -0
  122. package/src/services/outgoing-mail/send.ts +130 -0
  123. package/src/services/outgoing-mail/store.ts +115 -4
  124. package/src/services/outgoing-mail/sync.ts +39 -0
  125. package/src/services/outgoing-mail/transport.ts +5 -1
  126. package/src/services/pdf/markdown.ts +22 -4
  127. package/src/services/pdf/template-preview.ts +2 -1
  128. package/src/services/postgres.ts +15 -0
  129. package/src/services/settings/app.ts +3 -0
  130. package/src/services/settings/core-settings.ts +18 -0
  131. package/src/services/settings/store.ts +4 -1
  132. package/src/services/weather/geo.ts +6 -2
  133. package/src/shared/ai-platform-prompt.ts +48 -4
  134. package/src/shared/markdown/extensions/links.ts +28 -20
  135. package/src/shared/markdown/index.ts +14 -5
  136. package/src/shared/markdown/shared.ts +0 -7
  137. package/src/shared/redirect.ts +2 -1
  138. package/src/shared/template-rendering.ts +120 -7
  139. package/src/ssr/AppBadge.tsx +30 -0
  140. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  141. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  142. package/src/ssr/IslandError.tsx +33 -0
  143. package/src/ssr/Layout.tsx +3 -0
  144. package/src/ssr/LayoutHelp.tsx +4 -2
  145. package/src/ssr/RailApps.island.tsx +9 -2
  146. package/src/ssr/app-badges.ts +93 -0
  147. package/src/ssr/platform-messages.ts +1 -1
  148. package/src/ssr/rail-messages.ts +4 -0
  149. package/src/ssr/rail-navigation.ts +2 -0
  150. package/src/styles/effects.css +38 -17
  151. package/src/styles/file-chooser.css +25 -0
  152. package/src/styles/tokens.css +2 -0
  153. package/src/styles/utilities-markdown-editor.css +4 -39
  154. package/src/styles/utilities-markdown-table.css +14 -17
  155. package/src/styles/utilities-navigation.css +41 -0
@@ -1,18 +1,15 @@
1
1
  import { fileIcons, text } from "@k2b/stdlib";
2
- import { timed } from "@k2b/stdlib/solid";
3
2
  import {
4
- announce,
5
3
  Button,
6
4
  createCollectionSelection,
5
+ FileDropTarget,
7
6
  FileGrid,
8
7
  Format,
9
- IconButton,
10
8
  PanelDialog,
11
9
  Placeholder,
12
10
  ProgressBar,
13
11
  StatusBadge,
14
12
  type StatusTone,
15
- TextInput,
16
13
  useLocale,
17
14
  } from "@k2b/ui";
18
15
  import { batch, createMemo, createSignal, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js";
@@ -27,10 +24,10 @@ import {
27
24
  type FileProviderCaller,
28
25
  FileProviderError,
29
26
  type FileProviderSource,
30
- listProviderFolder,
31
27
  providerIcon,
32
28
  readProviderFile,
33
29
  } from "./file-providers";
30
+ import { createProviderFolder, FolderContents, FolderToolbar } from "./provider-folder";
34
31
 
35
32
  export type ChooseFilesOptions = {
36
33
  /** `<input accept>` syntax, for example `"image/*,.pdf"`. */
@@ -49,8 +46,6 @@ export type ChooseFilesOptions = {
49
46
  export type FileProviderList = { state: "loading" } | { state: "ready"; providers: readonly FileProviderSource[] } | { state: "error" };
50
47
 
51
48
  type FileEntry = Extract<FileProviderEntry, { kind: "file" }>;
52
- type Crumb = { id?: string; name: string };
53
- type Location = { provider: FileProviderSource; trail: readonly Crumb[] } | null;
54
49
  type SourceRow = { id: string; name: string; icon: string; provider?: FileProviderSource };
55
50
  type Download = {
56
51
  entry: FileEntry;
@@ -62,9 +57,6 @@ type Download = {
62
57
  reason?: EntryProblem;
63
58
  };
64
59
 
65
- /** Empty pages may still continue; the chooser follows at most this many of them before it shows "Load more". */
66
- const EMPTY_PAGES_FOLLOWED = 5;
67
-
68
60
  const TAG_TONES: Record<NonNullable<FileProviderTag["tone"]>, StatusTone> = {
69
61
  neutral: "neutral",
70
62
  info: "info",
@@ -73,8 +65,8 @@ const TAG_TONES: Record<NonNullable<FileProviderTag["tone"]>, StatusTone> = {
73
65
  danger: "error",
74
66
  };
75
67
 
76
- const localTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
77
- const entryIcon = (entry: FileProviderEntry) =>
68
+ export const localTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
69
+ export const entryIcon = (entry: FileProviderEntry) =>
78
70
  providerIcon(entry.icon, `ti ${fileIcons.getFileIcon({ name: entry.name, type: entry.kind === "folder" ? "directory" : "file" })}`);
79
71
 
80
72
  /**
@@ -94,21 +86,21 @@ export function FileChooser(props: {
94
86
  let deviceInput: HTMLInputElement | undefined;
95
87
  let root: HTMLDivElement | undefined;
96
88
 
97
- const [location, setLocation] = createSignal<Location>(null);
98
- const [filter, setFilter] = createSignal("");
99
- const [query, setQuery] = createSignal("");
100
- const [items, setItems] = createSignal<FileProviderEntry[]>([]);
101
- const [next, setNext] = createSignal<string | null>(null);
102
- const [status, setStatus] = createSignal<"loading" | "ready" | "more" | "error">("ready");
103
- const [error, setError] = createSignal<FileProviderError | null>(null);
89
+ /** After navigation the activated row is gone; give focus to the first row so the keyboard continues there. */
90
+ const refocus = () => {
91
+ queueMicrotask(() => {
92
+ const active = document.activeElement;
93
+ if (active && root?.contains(active) && active.isConnected && active.tagName !== "DIALOG") return;
94
+ const first = location() ? items()[0]?.id : sources()[0]?.id;
95
+ if (first) (location() ? selection : sourceSelection).focus(first);
96
+ });
97
+ };
98
+ const folder = createProviderFolder({ caller: props.caller, t, settled: refocus });
99
+ const { location, items, openFolder } = folder;
104
100
  const [downloads, setDownloads] = createStore<Download[]>([]);
105
101
  const [downloading, setDownloading] = createSignal(false);
106
- let request: AbortController | undefined;
107
102
  let transfer: AbortController | undefined;
108
- onCleanup(() => {
109
- request?.abort();
110
- transfer?.abort();
111
- });
103
+ onCleanup(() => transfer?.abort());
112
104
 
113
105
  const budget = props.options.maxBytes ?? Number.POSITIVE_INFINITY;
114
106
  /** The largest single file: the consumer's budget or the provider's read limit, whichever is smaller. */
@@ -126,7 +118,7 @@ export function FileChooser(props: {
126
118
  const sources = createMemo<SourceRow[]>(() => {
127
119
  const list = props.providers();
128
120
  const rows = [device(), ...(list.state === "ready" ? list.providers.map(providerRow) : [])];
129
- const needle = filter().trim().toLocaleLowerCase();
121
+ const needle = folder.filter().trim().toLocaleLowerCase();
130
122
  return needle ? rows.filter((row) => row.name.toLocaleLowerCase().includes(needle)) : rows;
131
123
  });
132
124
  // Sources open on activation and keep no selection, so Escape always closes the chooser from here.
@@ -144,106 +136,11 @@ export function FileChooser(props: {
144
136
  );
145
137
  const overBudget = () => chosen().reduce((sum, entry) => sum + entry.size, 0) > budget;
146
138
 
147
- const folderName = () => location()?.trail.at(-1)?.name ?? t().sources;
148
-
149
- const load = async (more = false) => {
150
- const current = location();
151
- if (!current) return;
152
- request?.abort();
153
- const pending = new AbortController();
154
- request = pending;
155
- if (!more) {
156
- batch(() => {
157
- setItems([]);
158
- setNext(null);
159
- });
160
- }
161
- setStatus(more ? "more" : "loading");
162
- setError(null);
163
- try {
164
- let cursor = more ? (next() ?? undefined) : undefined;
165
- let followed = 0;
166
- for (;;) {
167
- const page = await listProviderFolder(
168
- current.provider,
169
- { parent: current.trail.at(-1)?.id, query: query() || undefined, cursor },
170
- props.caller(),
171
- pending.signal,
172
- );
173
- if (pending.signal.aborted) return;
174
- batch(() => {
175
- setItems((previous) => [...previous, ...page.items]);
176
- setNext(page.next);
177
- });
178
- if (items().length > 0 || !page.next || ++followed >= EMPTY_PAGES_FOLLOWED) break;
179
- cursor = page.next;
180
- }
181
- setStatus("ready");
182
- if (!more) {
183
- announce(t().folderAnnounced({ folder: folderName(), count: items().length, more: Boolean(next()) }));
184
- refocus();
185
- }
186
- } catch (cause) {
187
- if (pending.signal.aborted) return;
188
- setError(
189
- cause instanceof FileProviderError ? cause : new FileProviderError("INTERNAL", cause instanceof Error ? cause.message : "", 500),
190
- );
191
- setStatus(more ? "ready" : "error");
192
- }
193
- };
194
-
195
- /** After navigation the activated row is gone; give focus to the first row so the keyboard continues there. */
196
- const refocus = () => {
197
- queueMicrotask(() => {
198
- const active = document.activeElement;
199
- if (active && root?.contains(active) && active.isConnected && active.tagName !== "DIALOG") return;
200
- const first = location() ? items()[0]?.id : sources()[0]?.id;
201
- if (first) (location() ? selection : sourceSelection).focus(first);
202
- });
203
- };
204
-
205
- const applyQuery = (value: string) => {
206
- setQuery(value.trim());
207
- void load();
208
- };
209
- const { debouncedFn: applyFilter, cancel: cancelFilter } = timed.debounce(applyQuery, 250);
210
- const clearFilter = () => {
211
- cancelFilter();
212
- setFilter("");
213
- if (location() && query()) applyQuery("");
214
- };
215
-
216
- const navigate = (next: Location) => {
217
- cancelFilter();
218
- request?.abort();
219
- batch(() => {
220
- setFilter("");
221
- setQuery("");
222
- setLocation(next);
223
- setItems([]);
224
- setNext(null);
225
- setError(null);
226
- setStatus(next ? "loading" : "ready");
227
- });
228
- if (next) void load();
229
- else refocus();
230
- };
231
- const openProvider = (provider: FileProviderSource) => navigate({ provider, trail: [{ name: provider.name }] });
232
- const openFolder = (entry: FileProviderEntry) => {
233
- const current = location();
234
- if (current) navigate({ provider: current.provider, trail: [...current.trail, { id: entry.id, name: entry.name }] });
235
- };
236
- const up = () => {
237
- const current = location();
238
- if (!current) return;
239
- navigate(current.trail.length > 1 ? { provider: current.provider, trail: current.trail.slice(0, -1) } : null);
240
- };
241
-
242
139
  const openSource = (row: SourceRow) => {
243
140
  sourceSelection.clear();
244
141
  // A click or key press on the row is the user activation the native picker needs.
245
142
  if (!row.provider) deviceInput?.click();
246
- else openProvider(row.provider);
143
+ else folder.openProvider(row.provider);
247
144
  };
248
145
 
249
146
  const add = async () => {
@@ -301,18 +198,6 @@ export function FileChooser(props: {
301
198
  const ready = () => downloads.filter((item) => item.state === "done").length;
302
199
  const progress = () => t().downloading({ done: ready(), total: downloads.length, failed: failures() });
303
200
 
304
- const errorView = createMemo(() => {
305
- const value = error();
306
- if (!value) return null;
307
- const app = location()?.provider.name ?? "";
308
- if (value.status === 0) return { title: t().offline, description: t().offlineHint, retry: true };
309
- if (value.status === 403) return { title: t().noAccess, description: t().backHint, retry: false };
310
- if (value.status === 404) return { title: t().notFound, description: t().backHint, retry: false };
311
- if (value.status >= 502 || value.code === "APP_UNAVAILABLE")
312
- return { title: t().unavailable({ app }), description: t().unavailableHint, retry: true };
313
- return { title: t().loadFailed, description: value.message || undefined, retry: true };
314
- });
315
-
316
201
  const renderTags = (tags: readonly FileProviderTag[] | undefined) => (
317
202
  <For each={tags ?? []}>
318
203
  {(tag) => <StatusBadge tone={TAG_TONES[tag.tone ?? "neutral"]} icon={null} label={tag.label} class="cloud-file-chooser__tag" />}
@@ -324,64 +209,17 @@ export function FileChooser(props: {
324
209
  {/* `display: contents`, so header, body, and footer stay the panel's own rows. */}
325
210
  <div ref={root} class="cloud-file-chooser">
326
211
  <PanelDialog.Header title={t().title({ multiple })} close={() => props.close([])} />
327
- <div class="cloud-file-chooser__toolbar">
328
- <nav class="cloud-file-chooser__crumbs" aria-label={t().breadcrumbs}>
329
- <IconButton size="sm" variant="ghost" label={t().up} disabled={!location() || downloading()} onClick={up}>
330
- <i class="ti ti-arrow-up" aria-hidden="true" />
331
- </IconButton>
332
- <ol>
333
- <li>
334
- <Button
335
- size="sm"
336
- variant={location() ? "text" : "subtle"}
337
- aria-current={location() ? undefined : "location"}
338
- disabled={downloading()}
339
- onClick={() => navigate(null)}
340
- >
341
- {t().sources}
342
- </Button>
343
- </li>
344
- <For each={location()?.trail ?? []}>
345
- {(crumb, index) => {
346
- const last = () => index() === (location()?.trail.length ?? 0) - 1;
347
- return (
348
- <li>
349
- <span aria-hidden="true">/</span>
350
- <Button
351
- size="sm"
352
- variant={last() ? "subtle" : "text"}
353
- aria-current={last() ? "location" : undefined}
354
- disabled={downloading()}
355
- onClick={() => {
356
- const current = location();
357
- if (current && !last()) navigate({ provider: current.provider, trail: current.trail.slice(0, index() + 1) });
358
- }}
359
- >
360
- {crumb.name}
361
- </Button>
362
- </li>
363
- );
364
- }}
365
- </For>
366
- </ol>
367
- </nav>
368
- <TextInput
369
- class="cloud-file-chooser__filter"
370
- type="search"
371
- icon="ti ti-filter"
372
- aria-label={t().filter}
373
- placeholder={t().filter}
374
- maxLength={200}
375
- disabled={downloading()}
376
- value={filter}
377
- onValueChange={(value) => {
378
- setFilter(value);
379
- if (location()) applyFilter(value);
380
- }}
381
- clearable
382
- onClear={clearFilter}
383
- />
384
- </div>
212
+ {/* Files dropped anywhere on the open chooser are taken like files picked from this device. */}
213
+ <FileDropTarget
214
+ label={t().dropToChoose({ multiple })}
215
+ accept={props.options.accept}
216
+ multiple={multiple}
217
+ disabled={downloading()}
218
+ onDrop={(files) => {
219
+ if (files.length > 0) props.close(files);
220
+ }}
221
+ />
222
+ <FolderToolbar folder={folder} t={t} disabled={downloading()} sources />
385
223
  <PanelDialog.Body>
386
224
  <input
387
225
  ref={deviceInput}
@@ -458,113 +296,62 @@ export function FileChooser(props: {
458
296
  </Match>
459
297
  </Switch>
460
298
  </Match>
461
- <Match when={status() === "loading"}>
462
- <Placeholder state="loading" variant="panel" class="cloud-file-chooser__state" description={t().loading} />
463
- </Match>
464
- <Match when={status() === "error" && errorView()}>
465
- {(view) => (
466
- <Placeholder
467
- state="error"
468
- variant="panel"
469
- class="cloud-file-chooser__state"
470
- title={view().title}
471
- description={view().description}
472
- action={
473
- view().retry ? (
474
- <Button size="sm" variant="secondary" onClick={() => void load()}>
475
- {t().retry}
476
- </Button>
477
- ) : (
478
- <Button size="sm" variant="secondary" onClick={up}>
479
- <i class="ti ti-arrow-up" aria-hidden="true" /> {t().up}
480
- </Button>
299
+ <Match when={true}>
300
+ <FolderContents folder={folder} t={t}>
301
+ <FileGrid
302
+ rows={items()}
303
+ getRowId={(entry) => entry.id}
304
+ selection={selection}
305
+ label={t().entries({ folder: folder.folderName() })}
306
+ layout="list"
307
+ renderPreview={(entry) => (
308
+ <i
309
+ class={`${selection.selected().has(entry.id) && entry.kind === "file" ? "ti ti-circle-check" : entryIcon(entry)} cloud-file-chooser__icon`}
310
+ data-selected={selection.selected().has(entry.id) && entry.kind === "file" ? "true" : undefined}
311
+ aria-hidden="true"
312
+ />
313
+ )}
314
+ renderLabel={(entry) => <span title={entry.name}>{entry.name}</span>}
315
+ renderMeta={(entry) =>
316
+ entry.kind === "folder" && !entry.tags?.length ? null : (
317
+ <span class="cloud-file-chooser__meta">
318
+ <Switch>
319
+ <Match when={entry.kind === "file" && problem(entry) === "size"}>
320
+ <span>{t().tooLarge({ limit: text.pprintBytes(limit(), { locale: locale() }) })}</span>
321
+ </Match>
322
+ <Match when={entry.kind === "file" && problem(entry) === "type"}>
323
+ <span>{t().wrongType}</span>
324
+ </Match>
325
+ <Match when={entry.kind === "file" ? entry : undefined}>
326
+ {(file) => (
327
+ <>
328
+ <Format.Bytes value={file().size} />
329
+ <Show when={file().updatedAt}>
330
+ {(updated) => (
331
+ <>
332
+ <span aria-hidden="true">·</span>
333
+ <Format.Date value={updated()} timeZone={localTimeZone()} />
334
+ </>
335
+ )}
336
+ </Show>
337
+ </>
338
+ )}
339
+ </Match>
340
+ </Switch>
341
+ {renderTags(entry.tags)}
342
+ </span>
481
343
  )
482
344
  }
345
+ onRowClick={(entry) => {
346
+ if (entry.kind === "folder") openFolder(entry);
347
+ }}
348
+ onOpen={(entry) => {
349
+ if (entry.kind === "folder") return openFolder(entry);
350
+ if (!selection.selected().has(entry.id)) selection.toggle(entry.id);
351
+ void add();
352
+ }}
483
353
  />
484
- )}
485
- </Match>
486
- <Match when={items().length === 0 && !next()}>
487
- <Show
488
- when={query()}
489
- fallback={<Placeholder variant="panel" class="cloud-file-chooser__state" icon="ti ti-folder" title={t().emptyFolder} />}
490
- >
491
- <Placeholder
492
- variant="panel"
493
- class="cloud-file-chooser__state"
494
- icon="ti ti-filter-off"
495
- title={t().noMatches({ query: query() })}
496
- action={
497
- <Button size="sm" variant="secondary" onClick={clearFilter}>
498
- {t().clearFilter}
499
- </Button>
500
- }
501
- />
502
- </Show>
503
- </Match>
504
- <Match when={true}>
505
- <FileGrid
506
- rows={items()}
507
- getRowId={(entry) => entry.id}
508
- selection={selection}
509
- label={t().entries({ folder: folderName() })}
510
- layout="list"
511
- renderPreview={(entry) => (
512
- <i
513
- class={`${selection.selected().has(entry.id) && entry.kind === "file" ? "ti ti-circle-check" : entryIcon(entry)} cloud-file-chooser__icon`}
514
- data-selected={selection.selected().has(entry.id) && entry.kind === "file" ? "true" : undefined}
515
- aria-hidden="true"
516
- />
517
- )}
518
- renderLabel={(entry) => <span title={entry.name}>{entry.name}</span>}
519
- renderMeta={(entry) =>
520
- entry.kind === "folder" && !entry.tags?.length ? null : (
521
- <span class="cloud-file-chooser__meta">
522
- <Switch>
523
- <Match when={entry.kind === "file" && problem(entry) === "size"}>
524
- <span>{t().tooLarge({ limit: text.pprintBytes(limit(), { locale: locale() }) })}</span>
525
- </Match>
526
- <Match when={entry.kind === "file" && problem(entry) === "type"}>
527
- <span>{t().wrongType}</span>
528
- </Match>
529
- <Match when={entry.kind === "file" ? entry : undefined}>
530
- {(file) => (
531
- <>
532
- <Format.Bytes value={file().size} />
533
- <Show when={file().updatedAt}>
534
- {(updated) => (
535
- <>
536
- <span aria-hidden="true">·</span>
537
- <Format.Date value={updated()} timeZone={localTimeZone()} />
538
- </>
539
- )}
540
- </Show>
541
- </>
542
- )}
543
- </Match>
544
- </Switch>
545
- {renderTags(entry.tags)}
546
- </span>
547
- )
548
- }
549
- onRowClick={(entry) => {
550
- if (entry.kind === "folder") openFolder(entry);
551
- }}
552
- onOpen={(entry) => {
553
- if (entry.kind === "folder") return openFolder(entry);
554
- if (!selection.selected().has(entry.id)) selection.toggle(entry.id);
555
- void add();
556
- }}
557
- />
558
- <div class="cloud-file-chooser__more">
559
- <Show when={error() && status() === "ready"}>
560
- <Placeholder state="error" variant="inline" align="left" description={errorView()?.title} />
561
- </Show>
562
- <Show when={next()}>
563
- <Button variant="secondary" size="sm" loading={status() === "more"} onClick={() => void load(true)}>
564
- {error() ? t().retry : t().loadMore}
565
- </Button>
566
- </Show>
567
- </div>
354
+ </FolderContents>
568
355
  </Match>
569
356
  </Switch>
570
357
  </PanelDialog.Body>