@backmatter/studio 0.0.8 → 0.0.9
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/dist/chunks/UiKitApp-b8qFsmA-.js +2401 -0
- package/dist/chunks/WorkspaceRoutes-BKKEkAq7.js +90126 -0
- package/dist/chunks/{dashboard-bNp6AbKE.js → dashboard-BNUcbYU0.js} +380 -382
- package/dist/chunks/{index-DoDE-hwB.js → index-laWAz5_p.js} +111 -112
- package/dist/dashboard.js +2 -2
- package/dist/index.js +287 -146
- package/dist/site-widget.css +1615 -0
- package/dist/site-widget.js +30896 -630
- package/dist/studio.css +268 -67
- package/package.json +4 -4
- package/src/App.tsx +12 -29
- package/src/api.ts +55 -0
- package/src/chat/ChangeCard.tsx +2 -0
- package/src/chat/ChatMessage.tsx +73 -62
- package/src/chat/ChatPanel.tsx +110 -15
- package/src/chat/DocListCard.tsx +2 -0
- package/src/chat/LiveStatus.tsx +78 -8
- package/src/chat/Reveal.tsx +34 -0
- package/src/chat/Shimmer.tsx +65 -23
- package/src/chat/chat-helpers.ts +35 -0
- package/src/chat/motion.ts +73 -0
- package/src/chat/useStickToBottom.ts +129 -14
- package/src/chrome/ContentExplorerSidebar.tsx +23 -8
- package/src/chrome/content-tree-visibility.ts +55 -0
- package/src/devbar-state.ts +125 -0
- package/src/index.css +3 -131
- package/src/pages/CliAuthorizePage.tsx +61 -28
- package/src/pages/MediaGalleryPage.tsx +74 -11
- package/src/pages/MediaLightbox.tsx +93 -1
- package/src/pages/SettingsUsagePage.tsx +23 -1
- package/src/pages/media-grid.ts +13 -0
- package/src/pages/ui-kit/UiKitApp.tsx +164 -0
- package/src/pages/ui-kit/buttons.tsx +30 -0
- package/src/pages/ui-kit/catalog.tsx +41 -0
- package/src/pages/ui-kit/colors.tsx +95 -0
- package/src/pages/ui-kit/devbar.tsx +160 -0
- package/src/pages/ui-kit/feedback.tsx +39 -0
- package/src/pages/ui-kit/fields.tsx +47 -0
- package/src/pages/ui-kit/lists.tsx +19 -0
- package/src/pages/ui-kit/overlays.tsx +39 -0
- package/src/pages/ui-kit/path.ts +9 -0
- package/src/pages/ui-kit/sections.tsx +32 -0
- package/src/pages/ui-kit/surfaces.tsx +121 -0
- package/src/pages/ui-kit/topic.tsx +112 -0
- package/src/pages/ui-kit/topics.ts +71 -0
- package/src/pages/ui-kit/type.tsx +55 -0
- package/src/preview-frame-radius.ts +51 -0
- package/src/preview-frame.tsx +93 -49
- package/src/shell-resets.css +155 -0
- package/src/site-bar.tsx +1791 -425
- package/src/site-dashboard-href.ts +21 -0
- package/src/site-devbar-overlay-inject.ts +15 -0
- package/src/site-devbar-overlay.css +217 -0
- package/src/site-pointer.ts +1 -1
- package/src/site-publish-menu.tsx +33 -0
- package/src/site-widget-styles.ts +7 -0
- package/src/site-widget.tsx +61 -21
- package/src/studio-prose-ui.css +39 -0
- package/src/theme/ThemeProvider.tsx +4 -1
- package/src/workspace-path.ts +10 -6
- package/dist/chunks/EmbedPublishPage-CtIlaeGO.js +0 -149
- package/dist/chunks/UiKitPage-DcnSQ-Hf.js +0 -173
- package/dist/chunks/WorkspaceRoutes-DVpUOaMV.js +0 -59550
- package/dist/chunks/status-bar-action-C0fsAWvD.js +0 -30618
- package/src/pages/UiKitPage.tsx +0 -331
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// packages/studio/src/chrome/content-tree-visibility.ts
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import type { KeyValueStore } from "../last-workspace";
|
|
4
|
+
import { isWorkspaceContentPath, isWorkspaceEditorPath } from "../workspace-path";
|
|
5
|
+
|
|
6
|
+
export const CONTENT_TREE_OPEN_KEY = "backmatter-content-tree-open";
|
|
7
|
+
const CONTENT_TREE_EVENT = "backmatter-content-tree";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The type index is the file tree, so it stays open.
|
|
11
|
+
* A document follows the editor toggle. Other routes have no tree.
|
|
12
|
+
*/
|
|
13
|
+
export function contentTreeVisible(pathname: string, open: boolean) {
|
|
14
|
+
if (!isWorkspaceContentPath(pathname)) return false;
|
|
15
|
+
if (isWorkspaceEditorPath(pathname)) return open;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readContentTreeOpen(storage: KeyValueStore = localStorage) {
|
|
20
|
+
try {
|
|
21
|
+
return storage.getItem(CONTENT_TREE_OPEN_KEY) !== "0";
|
|
22
|
+
} catch {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writeContentTreeOpen(
|
|
28
|
+
open: boolean,
|
|
29
|
+
storage: KeyValueStore = localStorage,
|
|
30
|
+
) {
|
|
31
|
+
try {
|
|
32
|
+
storage.setItem(CONTENT_TREE_OPEN_KEY, open ? "1" : "0");
|
|
33
|
+
} catch {
|
|
34
|
+
// ignore quota / private mode
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function toggleContentTree() {
|
|
39
|
+
writeContentTreeOpen(!readContentTreeOpen());
|
|
40
|
+
window.dispatchEvent(new Event(CONTENT_TREE_EVENT));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useContentTreeOpen() {
|
|
44
|
+
const [open, setOpen] = useState(() => readContentTreeOpen());
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const sync = () => setOpen(readContentTreeOpen());
|
|
47
|
+
window.addEventListener(CONTENT_TREE_EVENT, sync);
|
|
48
|
+
window.addEventListener("storage", sync);
|
|
49
|
+
return () => {
|
|
50
|
+
window.removeEventListener(CONTENT_TREE_EVENT, sync);
|
|
51
|
+
window.removeEventListener("storage", sync);
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
return open;
|
|
55
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// packages/studio/src/devbar-state.ts
|
|
2
|
+
// Which row the on-site bar shows, and the motion numbers the stylesheet shares.
|
|
3
|
+
|
|
4
|
+
export type DevbarMode = "preview" | "live";
|
|
5
|
+
|
|
6
|
+
export type DevbarStep = {
|
|
7
|
+
label: string;
|
|
8
|
+
state: "done" | "active" | "pending";
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type DevbarActivity =
|
|
12
|
+
| { type: "editing"; field: string }
|
|
13
|
+
| { type: "publishing"; steps: readonly DevbarStep[]; progress: number }
|
|
14
|
+
| { type: "published"; sha: string; when: string };
|
|
15
|
+
|
|
16
|
+
export type DevbarRow = "idle" | "changes" | "editing" | "publishing" | "published";
|
|
17
|
+
|
|
18
|
+
export type DevbarChrome = {
|
|
19
|
+
mode: DevbarMode;
|
|
20
|
+
row: DevbarRow;
|
|
21
|
+
modeLabel: string;
|
|
22
|
+
announce: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const DEVBAR_EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
|
|
26
|
+
export const DEVBAR_EASE_IN = "cubic-bezier(0.55, 0, 1, 0.45)";
|
|
27
|
+
export const DEVBAR_EASE_DRAWER = "cubic-bezier(0.32, 0.72, 0, 1)";
|
|
28
|
+
|
|
29
|
+
/** Entering the violet flood is slower than leaving it. */
|
|
30
|
+
export const DEVBAR_FLOOD_ENTER_MS = 550;
|
|
31
|
+
export const DEVBAR_FLOOD_EXIT_MS = 300;
|
|
32
|
+
|
|
33
|
+
/** The viewport frame draws out slower than it retracts. */
|
|
34
|
+
export const DEVBAR_FRAME_ENTER_MS = 750;
|
|
35
|
+
export const DEVBAR_FRAME_EXIT_MS = 360;
|
|
36
|
+
|
|
37
|
+
const MODE_KEY = "backmatter.devbar.mode";
|
|
38
|
+
|
|
39
|
+
export function devbarChrome(input: {
|
|
40
|
+
siteMode: DevbarMode;
|
|
41
|
+
changeCount: number;
|
|
42
|
+
activity?: DevbarActivity | null;
|
|
43
|
+
}): DevbarChrome {
|
|
44
|
+
const activity = input.activity ?? null;
|
|
45
|
+
const row: DevbarRow = activity
|
|
46
|
+
? activity.type
|
|
47
|
+
: input.siteMode === "preview" && input.changeCount > 0
|
|
48
|
+
? "changes"
|
|
49
|
+
: "idle";
|
|
50
|
+
const mode: DevbarMode = row === "published" ? "live" : input.siteMode;
|
|
51
|
+
const modeLabel = mode === "preview" ? "Exit preview" : "Enter preview";
|
|
52
|
+
const announce = mode === "preview" ? "Preview on, drafts visible" : "Live site";
|
|
53
|
+
return { mode, row, modeLabel, announce };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function originWithin(
|
|
57
|
+
container: { left: number; top: number },
|
|
58
|
+
target: { left: number; top: number; width: number; height: number },
|
|
59
|
+
) {
|
|
60
|
+
return {
|
|
61
|
+
x: target.left + target.width / 2 - container.left,
|
|
62
|
+
y: target.top + target.height / 2 - container.top,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function shouldAnimateWidth(before: number, after: number) {
|
|
67
|
+
return Math.abs(after - before) > 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function prefersReducedMotion() {
|
|
71
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readStoredMode(): DevbarMode | null {
|
|
75
|
+
if (typeof sessionStorage === "undefined") return null;
|
|
76
|
+
try {
|
|
77
|
+
const value = sessionStorage.getItem(MODE_KEY);
|
|
78
|
+
return value === "live" || value === "preview" ? value : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type SiteThemePreference = "light" | "dark" | "system";
|
|
85
|
+
|
|
86
|
+
export const HIGHLIGHT_BLOCKS_KEY = "backmatter.devbar.highlight";
|
|
87
|
+
export const PREVIEW_WITH_AGENT_KEY = "backmatter.devbar.agent-preview";
|
|
88
|
+
const THEME_KEY = "backmatter-theme";
|
|
89
|
+
|
|
90
|
+
type StorageLike = { getItem(key: string): string | null; setItem(key: string, value: string): void };
|
|
91
|
+
|
|
92
|
+
export function readSiteTheme(storage: StorageLike | null): SiteThemePreference {
|
|
93
|
+
const value = storage?.getItem(THEME_KEY);
|
|
94
|
+
if (value === "light" || value === "dark" || value === "system") return value;
|
|
95
|
+
return "system";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function rememberSiteTheme(storage: StorageLike | null, preference: SiteThemePreference) {
|
|
99
|
+
storage?.setItem(THEME_KEY, preference);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveSiteTheme(preference: SiteThemePreference, prefersDark: boolean): "light" | "dark" {
|
|
103
|
+
if (preference === "system") return prefersDark ? "dark" : "light";
|
|
104
|
+
return preference;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function readStoredFlag(storage: StorageLike | null, key: string, fallback: boolean) {
|
|
108
|
+
const value = storage?.getItem(key);
|
|
109
|
+
if (value === "1") return true;
|
|
110
|
+
if (value === "0") return false;
|
|
111
|
+
return fallback;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function rememberStoredFlag(storage: StorageLike | null, key: string, on: boolean) {
|
|
115
|
+
storage?.setItem(key, on ? "1" : "0");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function rememberMode(mode: DevbarMode) {
|
|
119
|
+
if (typeof sessionStorage === "undefined") return;
|
|
120
|
+
try {
|
|
121
|
+
sessionStorage.setItem(MODE_KEY, mode);
|
|
122
|
+
} catch {
|
|
123
|
+
/* private mode */
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/index.css
CHANGED
|
@@ -1,131 +1,3 @@
|
|
|
1
|
-
/* packages/studio/src/index.css */
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*::after {
|
|
5
|
-
box-sizing: border-box;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
html,
|
|
9
|
-
body,
|
|
10
|
-
#root {
|
|
11
|
-
margin: 0;
|
|
12
|
-
min-height: 100dvh;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
html {
|
|
16
|
-
background: #f7f7f8;
|
|
17
|
-
color-scheme: light;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
html[data-theme="dark"] {
|
|
21
|
-
background: #18181b;
|
|
22
|
-
color-scheme: dark;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
body {
|
|
26
|
-
-webkit-font-smoothing: antialiased;
|
|
27
|
-
font-family:
|
|
28
|
-
"Geist Sans",
|
|
29
|
-
Inter,
|
|
30
|
-
ui-sans-serif,
|
|
31
|
-
system-ui,
|
|
32
|
-
sans-serif;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
button,
|
|
36
|
-
input,
|
|
37
|
-
textarea,
|
|
38
|
-
select {
|
|
39
|
-
/* Don't use `font: inherit` — it overrides StyleX sizes in @layer. */
|
|
40
|
-
font-family: inherit;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
button,
|
|
44
|
-
[role="button"] {
|
|
45
|
-
cursor: default;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
button:disabled,
|
|
49
|
-
button[aria-disabled="true"] {
|
|
50
|
-
cursor: not-allowed;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
[data-drag-handle] {
|
|
54
|
-
cursor: grab;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
[data-drag-handle][data-dragging] {
|
|
58
|
-
cursor: grabbing;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/* StyleX colors live in @layer priorityN, which the Vite config orders after
|
|
62
|
-
`reset`. An unlayered color on `a` would beat them, so a primary link
|
|
63
|
-
button inherits dark text on a dark fill and the label disappears. */
|
|
64
|
-
@layer reset {
|
|
65
|
-
a {
|
|
66
|
-
color: inherit;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
a {
|
|
71
|
-
text-decoration: none;
|
|
72
|
-
cursor: default;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
a[href^="http"],
|
|
76
|
-
a[href^="//"],
|
|
77
|
-
a[target="_blank"] {
|
|
78
|
-
cursor: pointer;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
:focus-visible {
|
|
82
|
-
outline: 2px solid #18181b;
|
|
83
|
-
outline-offset: 2px;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
html[data-theme="dark"] :focus-visible {
|
|
87
|
-
outline-color: #fafafa;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/* Controls whose container already indicates focus (e.g. the chat composer). */
|
|
91
|
-
[data-no-ring]:focus-visible {
|
|
92
|
-
outline: none;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/* Prose UI reads these on the rendered body. Match the app fonts. */
|
|
96
|
-
.prose-ui {
|
|
97
|
-
--p-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
98
|
-
--p-font-family-heading: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
99
|
-
--p-font-family-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
|
100
|
-
monospace;
|
|
101
|
-
--p-body-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
102
|
-
--p-h1-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
103
|
-
--p-h2-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
104
|
-
--p-h3-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
105
|
-
--p-h4-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
106
|
-
--p-h5-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
107
|
-
--p-h6-font-family: "Geist Sans", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
.prose-ui img {
|
|
111
|
-
max-width: 100%;
|
|
112
|
-
height: auto;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
.prose-ui > :first-child {
|
|
116
|
-
margin-top: 0;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
.prose-ui > :last-child {
|
|
120
|
-
margin-bottom: 0;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
.prose-ui [data-body-block] :is(p, h1, h2, h3, h4, h5, h6, ul, ol, blockquote) {
|
|
124
|
-
margin-top: 0;
|
|
125
|
-
margin-bottom: 0;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
.prose-ui [data-body-block] :focus,
|
|
129
|
-
.prose-ui [data-body-block] :focus-visible {
|
|
130
|
-
outline: none;
|
|
131
|
-
}
|
|
1
|
+
/* packages/studio/src/index.css — full studio stylesheet entry (dashboard + SiteStudio). */
|
|
2
|
+
@import "./shell-resets.css";
|
|
3
|
+
@import "./studio-prose-ui.css";
|
|
@@ -1,43 +1,76 @@
|
|
|
1
1
|
// packages/studio/src/pages/CliAuthorizePage.tsx
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { useSearchParams } from "react-router";
|
|
4
|
+
import * as stylex from "@stylexjs/stylex";
|
|
4
5
|
import { approveCliLogin } from "../api";
|
|
6
|
+
import { colors, radius, space, type } from "../styles/tokens.stylex";
|
|
5
7
|
import { Button } from "../ui/Button";
|
|
6
|
-
import { Stack } from "../ui/Page";
|
|
7
8
|
import { Text } from "../ui/Text";
|
|
8
9
|
|
|
9
|
-
export function CliAuthorizePage() {
|
|
10
|
+
export function CliAuthorizePage({ email }: { email: string }) {
|
|
10
11
|
const [params] = useSearchParams();
|
|
11
12
|
const id = params.get("id") ?? "";
|
|
12
13
|
const [status, setStatus] = useState<"idle" | "done" | "error">("idle");
|
|
13
14
|
const [error, setError] = useState<string | null>(null);
|
|
14
15
|
|
|
15
16
|
return (
|
|
16
|
-
<
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
17
|
+
<main {...stylex.props(styles.screen)}>
|
|
18
|
+
<div {...stylex.props(styles.card)}>
|
|
19
|
+
<Text as="h1" size="xl" weight="medium">
|
|
20
|
+
Allow the CLI
|
|
21
|
+
</Text>
|
|
22
|
+
<Text tone="muted">
|
|
23
|
+
This signs the CLI in as {email}. It can act in any workspace you belong to. It does not pick one.
|
|
24
|
+
</Text>
|
|
25
|
+
{status === "done" ? <Text>You can return to the terminal.</Text> : null}
|
|
26
|
+
{error ? <Text tone="danger">{error}</Text> : null}
|
|
27
|
+
{status === "done" ? null : (
|
|
28
|
+
<Button
|
|
29
|
+
disabled={!id}
|
|
30
|
+
onClick={() => {
|
|
31
|
+
setError(null);
|
|
32
|
+
void approveCliLogin(id)
|
|
33
|
+
.then(() => setStatus("done"))
|
|
34
|
+
.catch((err: unknown) => {
|
|
35
|
+
setStatus("error");
|
|
36
|
+
setError(err instanceof Error ? err.message : "Could not approve");
|
|
37
|
+
});
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
Allow
|
|
41
|
+
</Button>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
</main>
|
|
42
45
|
);
|
|
43
46
|
}
|
|
47
|
+
|
|
48
|
+
const styles = stylex.create({
|
|
49
|
+
screen: {
|
|
50
|
+
display: "flex",
|
|
51
|
+
boxSizing: "border-box",
|
|
52
|
+
width: "100%",
|
|
53
|
+
minHeight: "100dvh",
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
justifyContent: "center",
|
|
56
|
+
padding: space.xxl,
|
|
57
|
+
backgroundColor: colors.bgApp,
|
|
58
|
+
color: colors.text,
|
|
59
|
+
},
|
|
60
|
+
card: {
|
|
61
|
+
display: "flex",
|
|
62
|
+
width: "100%",
|
|
63
|
+
maxWidth: "320px",
|
|
64
|
+
flexDirection: "column",
|
|
65
|
+
alignItems: "flex-start",
|
|
66
|
+
gap: space.lg,
|
|
67
|
+
boxSizing: "border-box",
|
|
68
|
+
borderWidth: 1,
|
|
69
|
+
borderStyle: "solid",
|
|
70
|
+
borderColor: colors.border,
|
|
71
|
+
borderRadius: radius.md,
|
|
72
|
+
backgroundColor: colors.bgSurface,
|
|
73
|
+
padding: space.xxl,
|
|
74
|
+
fontSize: type.sm,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
10
10
|
import { useOutletContext, useSearchParams } from "react-router";
|
|
11
11
|
import * as stylex from "@stylexjs/stylex";
|
|
12
|
-
import { deleteMedia, fetchMedia, uploadMedia } from "../api";
|
|
12
|
+
import { deleteMedia, describeMedia, fetchMedia, uploadMedia } from "../api";
|
|
13
|
+
import { Input } from "../ui/Input";
|
|
13
14
|
import { colors, radius, space, type, z } from "../styles/tokens.stylex";
|
|
14
15
|
import { Button } from "../ui/Button";
|
|
15
16
|
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
|
@@ -19,6 +20,7 @@ import { canWriteContent } from "../workspace/permissions";
|
|
|
19
20
|
import type { WorkspaceOutletContext } from "../workspace/types";
|
|
20
21
|
import {
|
|
21
22
|
beginUploads,
|
|
23
|
+
describeMedia as rememberDescription,
|
|
22
24
|
emptyMediaGrid,
|
|
23
25
|
failUpload,
|
|
24
26
|
finishUpload,
|
|
@@ -56,28 +58,56 @@ export function MediaGalleryPage({
|
|
|
56
58
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
57
59
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
58
60
|
const [settled, setSettled] = useState(false);
|
|
61
|
+
const urlQuery = searchParams.get("q") ?? "";
|
|
62
|
+
const [draft, setDraft] = useState(urlQuery);
|
|
63
|
+
const [query, setQuery] = useState(urlQuery);
|
|
59
64
|
const dragDepth = useRef(0);
|
|
60
65
|
const scroller = useRef<HTMLDivElement>(null);
|
|
61
66
|
const grid = useRef<HTMLDivElement>(null);
|
|
62
67
|
const [columns, setColumns] = useState(1);
|
|
63
68
|
const loadingPage = useRef(false);
|
|
69
|
+
const request = useRef(0);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const handle = window.setTimeout(() => setQuery(draft), 200);
|
|
73
|
+
return () => window.clearTimeout(handle);
|
|
74
|
+
}, [draft]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
setSearchParams(
|
|
78
|
+
(current) => {
|
|
79
|
+
const next = new URLSearchParams(current);
|
|
80
|
+
if (query) next.set("q", query);
|
|
81
|
+
else next.delete("q");
|
|
82
|
+
if (next.get("q") === current.get("q")) return current;
|
|
83
|
+
return next;
|
|
84
|
+
},
|
|
85
|
+
{ replace: true },
|
|
86
|
+
);
|
|
87
|
+
}, [query, setSearchParams]);
|
|
64
88
|
|
|
65
89
|
const load = useCallback(
|
|
66
90
|
async (cursor?: string) => {
|
|
67
|
-
if (loadingPage.current) return;
|
|
91
|
+
if (cursor && loadingPage.current) return;
|
|
92
|
+
const ticket = ++request.current;
|
|
68
93
|
loadingPage.current = true;
|
|
94
|
+
if (!cursor) setState((current) => ({ ...current, nextCursor: null }));
|
|
69
95
|
try {
|
|
70
|
-
const page = await fetchMedia(workspace.id, { cursor });
|
|
96
|
+
const page = await fetchMedia(workspace.id, { query: query || undefined, cursor });
|
|
97
|
+
if (ticket !== request.current) return;
|
|
71
98
|
setState((current) => receivePage(current, page, cursor ? "append" : "replace"));
|
|
72
99
|
setError(null);
|
|
73
100
|
} catch (err) {
|
|
101
|
+
if (ticket !== request.current) return;
|
|
74
102
|
setError(err instanceof Error ? err.message : "Could not load media");
|
|
75
103
|
} finally {
|
|
76
|
-
|
|
77
|
-
|
|
104
|
+
if (ticket === request.current) {
|
|
105
|
+
loadingPage.current = false;
|
|
106
|
+
if (!cursor) setSettled(true);
|
|
107
|
+
}
|
|
78
108
|
}
|
|
79
109
|
},
|
|
80
|
-
[workspace.id],
|
|
110
|
+
[query, workspace.id],
|
|
81
111
|
);
|
|
82
112
|
|
|
83
113
|
useEffect(() => {
|
|
@@ -349,6 +379,14 @@ export function MediaGalleryPage({
|
|
|
349
379
|
}, [columns, selectedId, sharedId]);
|
|
350
380
|
|
|
351
381
|
const open = lightbox >= 0 ? browsable[lightbox] : undefined;
|
|
382
|
+
const saveText = useCallback(
|
|
383
|
+
async (text: { alt?: string; caption?: string }) => {
|
|
384
|
+
if (!open) return;
|
|
385
|
+
const result = await describeMedia(workspace.id, open.id, text);
|
|
386
|
+
setState((current) => rememberDescription(current, result.media));
|
|
387
|
+
},
|
|
388
|
+
[open, workspace.id],
|
|
389
|
+
);
|
|
352
390
|
const showEmpty =
|
|
353
391
|
settled && state.pending.length === 0 && state.loaded.length === 0 && state.total === 0;
|
|
354
392
|
|
|
@@ -358,9 +396,19 @@ export function MediaGalleryPage({
|
|
|
358
396
|
<Text as="h1" size="xl" weight="medium">
|
|
359
397
|
Media
|
|
360
398
|
</Text>
|
|
361
|
-
<
|
|
362
|
-
|
|
363
|
-
|
|
399
|
+
<div {...stylex.props(styles.toolbarActions)}>
|
|
400
|
+
<div {...stylex.props(styles.search)}>
|
|
401
|
+
<Input
|
|
402
|
+
aria-label="Search media"
|
|
403
|
+
placeholder="Search"
|
|
404
|
+
value={draft}
|
|
405
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
406
|
+
/>
|
|
407
|
+
</div>
|
|
408
|
+
<Button variant="secondary" onClick={chooseFiles}>
|
|
409
|
+
Upload
|
|
410
|
+
</Button>
|
|
411
|
+
</div>
|
|
364
412
|
</div>
|
|
365
413
|
{error ? <Text tone="danger">{error}</Text> : null}
|
|
366
414
|
{rejected.length > 0 ? <Text tone="danger">{rejected.join(" · ")}</Text> : null}
|
|
@@ -371,7 +419,11 @@ export function MediaGalleryPage({
|
|
|
371
419
|
maybeLoadMore();
|
|
372
420
|
}}
|
|
373
421
|
>
|
|
374
|
-
{showEmpty ? (
|
|
422
|
+
{showEmpty && query ? (
|
|
423
|
+
<div {...stylex.props(styles.emptyState)}>
|
|
424
|
+
<Text tone="muted">No images match this search.</Text>
|
|
425
|
+
</div>
|
|
426
|
+
) : showEmpty ? (
|
|
375
427
|
<div
|
|
376
428
|
{...stylex.props(styles.emptyState)}
|
|
377
429
|
onDragOver={(event) => event.preventDefault()}
|
|
@@ -461,6 +513,8 @@ export function MediaGalleryPage({
|
|
|
461
513
|
onPrev={prevLightbox}
|
|
462
514
|
onNext={nextLightbox}
|
|
463
515
|
onDelete={canDelete ? () => requestDelete(open) : undefined}
|
|
516
|
+
canEdit={canDelete}
|
|
517
|
+
onDescribe={saveText}
|
|
464
518
|
/>
|
|
465
519
|
) : null}
|
|
466
520
|
<ConfirmDialog
|
|
@@ -505,7 +559,7 @@ function MediaHit({
|
|
|
505
559
|
{...stylex.props(styles.hit, !menu && styles.placed, selected && styles.selected)}
|
|
506
560
|
onClick={onOpen}
|
|
507
561
|
>
|
|
508
|
-
<img src={item.thumbUrl} alt={item.filename} {...stylex.props(styles.image)} />
|
|
562
|
+
<img src={item.thumbUrl} alt={item.alt?.trim() || item.filename} {...stylex.props(styles.image)} />
|
|
509
563
|
</button>
|
|
510
564
|
);
|
|
511
565
|
if (!menu) return image;
|
|
@@ -589,6 +643,15 @@ const styles = stylex.create({
|
|
|
589
643
|
gap: space.lg,
|
|
590
644
|
padding: space.xl,
|
|
591
645
|
},
|
|
646
|
+
toolbarActions: {
|
|
647
|
+
display: "flex",
|
|
648
|
+
alignItems: "center",
|
|
649
|
+
gap: space.md,
|
|
650
|
+
minWidth: 0,
|
|
651
|
+
},
|
|
652
|
+
search: {
|
|
653
|
+
width: "220px",
|
|
654
|
+
},
|
|
592
655
|
scroller: {
|
|
593
656
|
flex: 1,
|
|
594
657
|
minHeight: 0,
|