minecodex 0.1.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/LICENSE +21 -0
- package/README.md +56 -0
- package/apps/dashboard/app.js +387 -0
- package/apps/dashboard/favicon.svg +18 -0
- package/apps/dashboard/index.html +70 -0
- package/apps/dashboard/styles.css +297 -0
- package/features/images/README.md +106 -0
- package/features/images/codex-feature.json +24 -0
- package/features/images/src/http-server.mjs +302 -0
- package/features/images/src/image-library.mjs +582 -0
- package/features/images/src/main.mjs +52 -0
- package/features/images/src/prompt-index.mjs +182 -0
- package/features/images/src/save-as.mjs +40 -0
- package/features/images/src/thread-metadata.mjs +93 -0
- package/features/images/web/app.js +1045 -0
- package/features/images/web/index.html +162 -0
- package/features/images/web/styles.css +994 -0
- package/features/model-slider/README.md +30 -0
- package/features/model-slider/THIRD_PARTY_NOTICES.md +24 -0
- package/features/model-slider/assets/providers/anthropic.svg +1 -0
- package/features/model-slider/assets/providers/deepseek.svg +1 -0
- package/features/model-slider/assets/providers/gemini.svg +1 -0
- package/features/model-slider/assets/providers/grok.svg +1 -0
- package/features/model-slider/assets/providers/hunyuan.svg +1 -0
- package/features/model-slider/assets/providers/kimi.svg +1 -0
- package/features/model-slider/assets/providers/minimax.svg +1 -0
- package/features/model-slider/assets/providers/openai.svg +1 -0
- package/features/model-slider/assets/providers/qwen.svg +1 -0
- package/features/model-slider/assets/providers/xiaomimimo.svg +1 -0
- package/features/model-slider/assets/providers/zhipu.svg +1 -0
- package/features/model-slider/assets/ui/sparkles.svg +18 -0
- package/features/model-slider/assets/ui/star-off.svg +17 -0
- package/features/model-slider/assets/ui/star.svg +15 -0
- package/features/model-slider/codex-feature.json +56 -0
- package/features/model-slider/vendor/cc-switch-LICENSE +21 -0
- package/features/model-slider/vendor/lucide-LICENSE +35 -0
- package/features/notes/README.md +106 -0
- package/features/notes/assets/icons/circle.svg +15 -0
- package/features/notes/assets/icons/ellipsis.svg +17 -0
- package/features/notes/assets/icons/external-link.svg +17 -0
- package/features/notes/assets/icons/list-todo.svg +19 -0
- package/features/notes/assets/icons/message-square-quote.svg +17 -0
- package/features/notes/assets/icons/paperclip.svg +15 -0
- package/features/notes/assets/icons/star-off.svg +17 -0
- package/features/notes/assets/icons/star.svg +15 -0
- package/features/notes/assets/icons/sticky-note.svg +16 -0
- package/features/notes/assets/icons/trash-2.svg +19 -0
- package/features/notes/codex-feature.json +90 -0
- package/features/notes/src/http-server.mjs +541 -0
- package/features/notes/src/main.mjs +54 -0
- package/features/notes/src/product.mjs +771 -0
- package/features/notes/web/app.js +1495 -0
- package/features/notes/web/codex-artifact-document.svg +1 -0
- package/features/notes/web/codex-attachment.svg +1 -0
- package/features/notes/web/codex-build.svg +1 -0
- package/features/notes/web/codex-code.svg +1 -0
- package/features/notes/web/codex-cplusplus.svg +1 -0
- package/features/notes/web/codex-css.svg +1 -0
- package/features/notes/web/codex-document.svg +1 -0
- package/features/notes/web/codex-file.svg +1 -0
- package/features/notes/web/codex-folder.svg +1 -0
- package/features/notes/web/codex-hashes.svg +1 -0
- package/features/notes/web/codex-html.svg +1 -0
- package/features/notes/web/codex-image.svg +1 -0
- package/features/notes/web/codex-java.svg +1 -0
- package/features/notes/web/codex-javascript.svg +1 -0
- package/features/notes/web/codex-json.svg +1 -0
- package/features/notes/web/codex-notebook.svg +1 -0
- package/features/notes/web/codex-pdf.svg +1 -0
- package/features/notes/web/codex-pencil.svg +3 -0
- package/features/notes/web/codex-php.svg +1 -0
- package/features/notes/web/codex-plus.svg +3 -0
- package/features/notes/web/codex-presentation.svg +1 -0
- package/features/notes/web/codex-python.svg +1 -0
- package/features/notes/web/codex-react.svg +1 -0
- package/features/notes/web/codex-rust.svg +1 -0
- package/features/notes/web/codex-shell.svg +1 -0
- package/features/notes/web/codex-skill.svg +1 -0
- package/features/notes/web/codex-spreadsheet.svg +1 -0
- package/features/notes/web/codex-task.svg +3 -0
- package/features/notes/web/codex-terminal.svg +1 -0
- package/features/notes/web/codex-toml.svg +1 -0
- package/features/notes/web/codex-typescript.svg +1 -0
- package/features/notes/web/codex-view-all.svg +3 -0
- package/features/notes/web/index.html +15 -0
- package/features/notes/web/styles.css +1155 -0
- package/package.json +67 -0
- package/packages/cli/bin/mcx.mjs +7 -0
- package/packages/cli/src/browser-exchange.mjs +31 -0
- package/packages/cli/src/commands.mjs +389 -0
- package/packages/cli/src/config.mjs +101 -0
- package/packages/cli/src/control-server.mjs +371 -0
- package/packages/cli/src/npm-adapter.mjs +40 -0
- package/packages/cli/src/paths.mjs +33 -0
- package/packages/cli/src/platform.mjs +368 -0
- package/packages/cli/src/runtime-manager.mjs +338 -0
- package/packages/runtime-host/README.md +171 -0
- package/packages/runtime-host/src/codex-runtime.mjs +4311 -0
- package/packages/runtime-host/src/feature-registry.mjs +827 -0
- package/packages/runtime-host/src/main.mjs +98 -0
- package/packages/runtime-host/src/pid-file.mjs +23 -0
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
const shell = document.querySelector(".library-shell");
|
|
2
|
+
const scrollArea = document.querySelector(".library-scroll");
|
|
3
|
+
const grid = document.querySelector(".image-grid");
|
|
4
|
+
const pageSentinel = document.querySelector(".page-sentinel");
|
|
5
|
+
const empty = document.querySelector(".empty-state");
|
|
6
|
+
const emptyTitle = empty.querySelector("h2");
|
|
7
|
+
const emptyDescription = empty.querySelector("p");
|
|
8
|
+
const rescan = document.querySelector(".rescan-button");
|
|
9
|
+
const filterButton = document.querySelector(".source-filter-button");
|
|
10
|
+
const filterSourceIcon = document.querySelector(".filter-source-icon");
|
|
11
|
+
const filterLabel = document.querySelector(".filter-label");
|
|
12
|
+
const filterCount = document.querySelector(".filter-count");
|
|
13
|
+
const sourceMenu = document.querySelector(".source-menu");
|
|
14
|
+
const densityButtonValue = document.querySelector(".density-button-value");
|
|
15
|
+
const densitySlider = document.querySelector(".density-slider");
|
|
16
|
+
const groupButton = document.querySelector(".group-button");
|
|
17
|
+
const drawer = document.querySelector(".detail-drawer");
|
|
18
|
+
const drawerImage = drawer.querySelector("img");
|
|
19
|
+
const drawerProjectRow = drawer.querySelector(".drawer-project-row");
|
|
20
|
+
const drawerProject = drawer.querySelector(".drawer-project");
|
|
21
|
+
const drawerTaskLabel = drawer.querySelector(".drawer-task-label");
|
|
22
|
+
const drawerThread = drawer.querySelector(".drawer-thread");
|
|
23
|
+
const drawerDate = drawer.querySelector(".drawer-date");
|
|
24
|
+
const drawerSize = drawer.querySelector(".drawer-size");
|
|
25
|
+
const drawerFileSize = drawer.querySelector(".drawer-file-size");
|
|
26
|
+
const drawerPrompt = drawer.querySelector(".drawer-prompt");
|
|
27
|
+
const promptCopyButton = drawer.querySelector(".prompt-copy-button");
|
|
28
|
+
const promptCopyIcon = promptCopyButton.querySelector(".copy-icon");
|
|
29
|
+
const promptCheckIcon = promptCopyButton.querySelector(".check-icon");
|
|
30
|
+
const sourceAction = drawer.querySelector(".source-action");
|
|
31
|
+
const sourceTooltip = drawer.querySelector(".source-tooltip");
|
|
32
|
+
const sourceButton = drawer.querySelector(".source-button");
|
|
33
|
+
const saveButton = drawer.querySelector(".save-button");
|
|
34
|
+
const statusAnnouncer = document.querySelector(".status-announcer");
|
|
35
|
+
const root = document.documentElement;
|
|
36
|
+
|
|
37
|
+
const PAGE_SIZE = 36;
|
|
38
|
+
const DENSITY_STORAGE_KEY = "codex-image-host:grid-density";
|
|
39
|
+
|
|
40
|
+
// Project uses Codex's folder icon. All images and Chat use official Lucide icons.
|
|
41
|
+
const SOURCE_ICON_MARKUP = {
|
|
42
|
+
all: [
|
|
43
|
+
'<rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect>',
|
|
44
|
+
'<circle cx="9" cy="9" r="2"></circle>',
|
|
45
|
+
'<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>',
|
|
46
|
+
].join(""),
|
|
47
|
+
project: '<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75488 2.1416C5.30942 2.14164 5.74594 2.23705 6.11816 2.38965C6.48323 2.53934 6.76728 2.73817 7.00391 2.9043L7.02148 2.91699C7.47057 3.23238 7.8162 3.47463 8.55176 3.47461H11.333C12.7194 3.47484 13.8311 4.61217 13.8311 6L13.875 6.38281H13.8594C14.8729 6.38292 15.5982 7.3629 15.3018 8.33203L14.0068 12.5586C13.7703 13.3297 13.0576 13.8563 12.251 13.8564H3.83984C3.4199 13.8564 3.04144 13.7174 2.73828 13.4883L2.67383 13.4346C1.99907 12.9811 1.55577 12.2065 1.55566 11.3311L0.941406 4.66699C0.941406 3.2792 2.05315 2.1419 3.43945 2.1416H4.75488ZM4.7627 7.42969C4.56039 7.42972 4.3807 7.5625 4.32129 7.75586L3.08594 11.7891C2.96123 12.1965 3.18214 12.6072 3.54883 12.7529C3.63476 12.7768 3.74102 12.7958 3.88184 12.8086H12.251C12.5974 12.8085 12.9033 12.5821 13.0049 12.251L14.2998 8.02539C14.3901 7.72947 14.1688 7.42979 13.8594 7.42969H4.7627ZM3.43945 3.19141C2.64724 3.1917 1.99121 3.84481 1.99121 4.66699L2.49316 10.1201L3.32031 7.44922C3.51452 6.81571 4.10008 6.38284 4.7627 6.38281H12.8252L12.7812 6C12.7812 5.22902 12.2045 4.607 11.4795 4.53223L11.333 4.52441H8.55176C8.05756 4.52442 7.64464 4.44062 7.2666 4.2793C6.91453 4.12896 6.6274 3.92345 6.41797 3.77637L6.40039 3.76367C6.16212 3.59639 5.96404 3.46151 5.71973 3.36133C5.54113 3.28812 5.32754 3.2289 5.05176 3.2041L4.75488 3.19141H3.43945Z" fill="currentColor"></path>',
|
|
48
|
+
chat: '<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"></path>',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let facets = { total: 0, chats: 0, projects: [] };
|
|
52
|
+
let selectedScope = "all";
|
|
53
|
+
let groupByThread = false;
|
|
54
|
+
let viewContext = "browse";
|
|
55
|
+
let activeThreadGroup = null;
|
|
56
|
+
let savedBrowseState = null;
|
|
57
|
+
let items = [];
|
|
58
|
+
let totalItems = 0;
|
|
59
|
+
let filteredImageCount = 0;
|
|
60
|
+
let pageRequest = null;
|
|
61
|
+
let loadGeneration = 0;
|
|
62
|
+
let activeImage = null;
|
|
63
|
+
let detailRequest = 0;
|
|
64
|
+
let masonryTimer = null;
|
|
65
|
+
let pendingScrollTop = null;
|
|
66
|
+
let appliedHostTokens = new Set();
|
|
67
|
+
let densityColumns = null;
|
|
68
|
+
let copyFeedbackTimer = null;
|
|
69
|
+
let updateCheckTimer = null;
|
|
70
|
+
let hostSurfaceActive = true;
|
|
71
|
+
let documentVisible = document.visibilityState !== "hidden";
|
|
72
|
+
let windowFocused = typeof document.hasFocus !== "function" || document.hasFocus();
|
|
73
|
+
let shellIntersecting = true;
|
|
74
|
+
let drawerOrigin = null;
|
|
75
|
+
let drawerOriginImageId = null;
|
|
76
|
+
let drawerOriginThreadId = null;
|
|
77
|
+
let groupOrigin = null;
|
|
78
|
+
let groupOriginImageId = null;
|
|
79
|
+
let groupOriginThreadId = null;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const storedDensity = Number.parseInt(localStorage.getItem(DENSITY_STORAGE_KEY) ?? "", 10);
|
|
83
|
+
if (storedDensity >= 2 && storedDensity <= 6) {
|
|
84
|
+
densityColumns = storedDensity;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Storage may be unavailable in a hardened webview; the current selection still works.
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function applyHostTheme(payload) {
|
|
91
|
+
if (!payload || !new Set(["light", "dark"]).has(payload.theme)) return;
|
|
92
|
+
root.dataset.host = "codex";
|
|
93
|
+
root.dataset.theme = payload.theme;
|
|
94
|
+
root.style.colorScheme = payload.theme;
|
|
95
|
+
for (const name of appliedHostTokens) root.style.removeProperty(name);
|
|
96
|
+
appliedHostTokens = new Set();
|
|
97
|
+
for (const [name, value] of Object.entries(payload.tokens ?? {})) {
|
|
98
|
+
if (!name.startsWith("--") || typeof value !== "string") continue;
|
|
99
|
+
root.style.setProperty(name, value);
|
|
100
|
+
appliedHostTokens.add(name);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function announce(message) {
|
|
105
|
+
if (!statusAnnouncer) return;
|
|
106
|
+
statusAnnouncer.textContent = "";
|
|
107
|
+
requestAnimationFrame(() => {
|
|
108
|
+
statusAnnouncer.textContent = message;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
window.addEventListener("message", (event) => {
|
|
113
|
+
if (event.source !== window.parent) return;
|
|
114
|
+
if (event.data?.type === "codex-personal:theme") applyHostTheme(event.data);
|
|
115
|
+
if (["codex-personal:surface-active", "codex-personal:surface-visibility"].includes(event.data?.type)) {
|
|
116
|
+
setHostSurfaceActive(event.data.active ?? event.data.visible);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (window.parent !== window) {
|
|
121
|
+
window.parent.postMessage({ type: "codex-personal:ready" }, "*");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
|
125
|
+
year: "numeric",
|
|
126
|
+
month: "short",
|
|
127
|
+
day: "numeric",
|
|
128
|
+
hour: "2-digit",
|
|
129
|
+
minute: "2-digit",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function formatBytes(bytes) {
|
|
133
|
+
if (!Number.isFinite(bytes)) return "Original file";
|
|
134
|
+
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
135
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function createSourceIcon(kind) {
|
|
139
|
+
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
140
|
+
icon.setAttribute("class", "source-icon");
|
|
141
|
+
icon.setAttribute("viewBox", kind === "project" ? "0 0 16 16" : "0 0 24 24");
|
|
142
|
+
icon.setAttribute("aria-hidden", "true");
|
|
143
|
+
if (kind !== "project") {
|
|
144
|
+
icon.setAttribute("fill", "none");
|
|
145
|
+
icon.setAttribute("stroke", "currentColor");
|
|
146
|
+
icon.setAttribute("stroke-width", "1.5");
|
|
147
|
+
icon.setAttribute("stroke-linecap", "round");
|
|
148
|
+
icon.setAttribute("stroke-linejoin", "round");
|
|
149
|
+
}
|
|
150
|
+
icon.innerHTML = SOURCE_ICON_MARKUP[kind];
|
|
151
|
+
return icon;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sourcePresentation(image) {
|
|
155
|
+
if (image.sourceKind === "project") {
|
|
156
|
+
return { kind: "project", label: image.projectName || "Project" };
|
|
157
|
+
}
|
|
158
|
+
return { kind: "chat", label: image.threadTitle || "Untitled chat" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function createSourceValue(kind, label, className = "") {
|
|
162
|
+
const value = document.createElement("span");
|
|
163
|
+
value.className = ["source-value", className].filter(Boolean).join(" ");
|
|
164
|
+
const text = document.createElement("span");
|
|
165
|
+
text.textContent = label;
|
|
166
|
+
value.append(createSourceIcon(kind), text);
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function selectedFacet() {
|
|
171
|
+
if (selectedScope === "all") return { label: "All images", count: facets.total, kind: "all" };
|
|
172
|
+
if (selectedScope === "chat") return { label: "Chats", count: facets.chats, kind: "chat" };
|
|
173
|
+
const project = facets.projects.find((item) => item.scope === selectedScope);
|
|
174
|
+
return project
|
|
175
|
+
? { label: project.name, count: project.count, kind: "project" }
|
|
176
|
+
: { label: "All images", count: facets.total, kind: "all" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function updateFilterTrigger() {
|
|
180
|
+
const selected = selectedFacet();
|
|
181
|
+
filterSourceIcon.replaceChildren(createSourceIcon(selected.kind));
|
|
182
|
+
filterLabel.textContent = selected.label;
|
|
183
|
+
filterCount.textContent = String(selected.count);
|
|
184
|
+
filterButton.title = `${selected.label} · ${selected.count} images`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function createSourceOption({ scope, label, count, kind }) {
|
|
188
|
+
const option = document.createElement("button");
|
|
189
|
+
option.className = "source-option";
|
|
190
|
+
option.type = "button";
|
|
191
|
+
option.setAttribute("role", "option");
|
|
192
|
+
option.setAttribute("aria-selected", String(scope === selectedScope));
|
|
193
|
+
option.tabIndex = scope === selectedScope ? 0 : -1;
|
|
194
|
+
option.dataset.scope = scope;
|
|
195
|
+
|
|
196
|
+
const icon = document.createElement("span");
|
|
197
|
+
icon.className = "source-option-icon";
|
|
198
|
+
icon.append(createSourceIcon(kind));
|
|
199
|
+
const optionLabel = document.createElement("span");
|
|
200
|
+
optionLabel.className = "source-option-label";
|
|
201
|
+
optionLabel.textContent = label;
|
|
202
|
+
const optionCount = document.createElement("span");
|
|
203
|
+
optionCount.className = "source-option-count";
|
|
204
|
+
optionCount.textContent = String(count);
|
|
205
|
+
option.append(icon, optionLabel, optionCount);
|
|
206
|
+
option.addEventListener("click", () => selectSource(scope));
|
|
207
|
+
return option;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderSourceMenu() {
|
|
211
|
+
const all = createSourceOption({ scope: "all", label: "All images", count: facets.total, kind: "all" });
|
|
212
|
+
const chats = createSourceOption({ scope: "chat", label: "Chats", count: facets.chats, kind: "chat" });
|
|
213
|
+
const divider = document.createElement("div");
|
|
214
|
+
divider.className = "source-menu-divider";
|
|
215
|
+
divider.setAttribute("role", "separator");
|
|
216
|
+
const projects = facets.projects.map((project) => createSourceOption({
|
|
217
|
+
scope: project.scope,
|
|
218
|
+
label: project.name,
|
|
219
|
+
count: project.count,
|
|
220
|
+
kind: "project",
|
|
221
|
+
}));
|
|
222
|
+
sourceMenu.replaceChildren(all, chats, ...(projects.length ? [divider, ...projects] : []));
|
|
223
|
+
updateFilterTrigger();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function sourceOptions() {
|
|
227
|
+
return [...sourceMenu.querySelectorAll('[role="option"]')];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function setSourceOptionFocus(index, { focus = true } = {}) {
|
|
231
|
+
const options = sourceOptions();
|
|
232
|
+
if (!options.length) return;
|
|
233
|
+
const activeIndex = Math.max(0, Math.min(index, options.length - 1));
|
|
234
|
+
options.forEach((option, optionIndex) => {
|
|
235
|
+
option.tabIndex = optionIndex === activeIndex ? 0 : -1;
|
|
236
|
+
option.setAttribute("aria-selected", String(option.dataset.scope === selectedScope));
|
|
237
|
+
});
|
|
238
|
+
if (focus) options[activeIndex].focus();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function moveSourceOption(delta) {
|
|
242
|
+
const options = sourceOptions();
|
|
243
|
+
const currentIndex = Math.max(0, options.indexOf(document.activeElement));
|
|
244
|
+
setSourceOptionFocus((currentIndex + delta + options.length) % options.length);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function openSourceMenu({ focusIndex = null } = {}) {
|
|
248
|
+
sourceMenu.hidden = false;
|
|
249
|
+
filterButton.setAttribute("aria-expanded", "true");
|
|
250
|
+
const options = sourceOptions();
|
|
251
|
+
const selectedIndex = options.findIndex((option) => option.dataset.scope === selectedScope);
|
|
252
|
+
setSourceOptionFocus(focusIndex ?? (selectedIndex < 0 ? 0 : selectedIndex));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function loadFacets() {
|
|
256
|
+
const response = await fetch("/api/image-facets");
|
|
257
|
+
if (!response.ok) throw new Error("Could not load image sources");
|
|
258
|
+
facets = await response.json();
|
|
259
|
+
if (
|
|
260
|
+
selectedScope !== "all"
|
|
261
|
+
&& selectedScope !== "chat"
|
|
262
|
+
&& !facets.projects.some((project) => project.scope === selectedScope)
|
|
263
|
+
) {
|
|
264
|
+
selectedScope = "all";
|
|
265
|
+
}
|
|
266
|
+
renderSourceMenu();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function recommendedColumnsForWidth(width) {
|
|
270
|
+
if (width < 920) return 2;
|
|
271
|
+
if (width < 1900) return 3;
|
|
272
|
+
if (width < 2500) return 4;
|
|
273
|
+
if (width < 3150) return 5;
|
|
274
|
+
return 6;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function applyDensity() {
|
|
278
|
+
if (densityColumns === null) {
|
|
279
|
+
densityColumns = recommendedColumnsForWidth(scrollArea.clientWidth || window.innerWidth);
|
|
280
|
+
}
|
|
281
|
+
grid.style.setProperty("--grid-columns", String(densityColumns));
|
|
282
|
+
densitySlider.value = String(densityColumns);
|
|
283
|
+
densityButtonValue.textContent = String(densityColumns);
|
|
284
|
+
densitySlider.setAttribute("aria-valuetext", `${densityColumns} columns`);
|
|
285
|
+
scheduleMasonry();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function closeSourceMenu({ restoreFocus = false } = {}) {
|
|
289
|
+
sourceMenu.hidden = true;
|
|
290
|
+
filterButton.setAttribute("aria-expanded", "false");
|
|
291
|
+
if (restoreFocus) filterButton.focus();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function closeToolbarPopups(options = {}) {
|
|
295
|
+
closeSourceMenu(options);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function layoutMasonry() {
|
|
299
|
+
masonryTimer = null;
|
|
300
|
+
const styles = getComputedStyle(grid);
|
|
301
|
+
const rowHeight = Number.parseFloat(styles.gridAutoRows);
|
|
302
|
+
if (!rowHeight) return;
|
|
303
|
+
const cards = [...grid.children];
|
|
304
|
+
if (!cards.length) return;
|
|
305
|
+
|
|
306
|
+
// Keep DOM writes and geometry reads in separate batches so the browser only
|
|
307
|
+
// needs to resolve layout once, even for a full page of boards.
|
|
308
|
+
for (const card of cards) card.style.gridRowEnd = "auto";
|
|
309
|
+
const blockGap = Number.parseFloat(getComputedStyle(cards[0]).marginBottom);
|
|
310
|
+
const spans = cards.map((card) => (
|
|
311
|
+
Math.ceil((card.getBoundingClientRect().height + blockGap) / rowHeight)
|
|
312
|
+
));
|
|
313
|
+
cards.forEach((card, index) => {
|
|
314
|
+
card.style.gridRowEnd = `span ${spans[index]}`;
|
|
315
|
+
});
|
|
316
|
+
if (pendingScrollTop !== null) {
|
|
317
|
+
scrollArea.scrollTop = pendingScrollTop;
|
|
318
|
+
pendingScrollTop = null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function scheduleMasonry() {
|
|
323
|
+
if (masonryTimer !== null) return;
|
|
324
|
+
masonryTimer = requestAnimationFrame(layoutMasonry);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function createCard(image) {
|
|
328
|
+
const button = document.createElement("button");
|
|
329
|
+
button.className = "image-card";
|
|
330
|
+
button.type = "button";
|
|
331
|
+
button.dataset.imageId = image.id;
|
|
332
|
+
const createdAt = dateFormatter.format(new Date(image.createdAt));
|
|
333
|
+
const source = sourcePresentation(image);
|
|
334
|
+
button.setAttribute("aria-label", `Open image from ${source.label}, created ${createdAt}`);
|
|
335
|
+
|
|
336
|
+
const preview = document.createElement("img");
|
|
337
|
+
preview.src = image.fileUrl;
|
|
338
|
+
preview.alt = image.prompt || "Generated image";
|
|
339
|
+
preview.loading = "lazy";
|
|
340
|
+
preview.decoding = "async";
|
|
341
|
+
if (image.width && image.height) {
|
|
342
|
+
preview.width = image.width;
|
|
343
|
+
preview.height = image.height;
|
|
344
|
+
}
|
|
345
|
+
preview.addEventListener("load", scheduleMasonry, { once: true });
|
|
346
|
+
|
|
347
|
+
const caption = document.createElement("span");
|
|
348
|
+
caption.className = "card-caption";
|
|
349
|
+
const sourceValue = createSourceValue(source.kind, source.label, "card-source");
|
|
350
|
+
const time = document.createElement("span");
|
|
351
|
+
time.className = "card-time";
|
|
352
|
+
time.textContent = createdAt;
|
|
353
|
+
caption.append(sourceValue, time);
|
|
354
|
+
|
|
355
|
+
button.append(preview, caption);
|
|
356
|
+
button.addEventListener("click", () => openImage(image, button));
|
|
357
|
+
return button;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function createThreadBoard(group) {
|
|
361
|
+
const button = document.createElement("button");
|
|
362
|
+
button.className = "thread-board";
|
|
363
|
+
button.type = "button";
|
|
364
|
+
button.dataset.threadId = group.threadId;
|
|
365
|
+
const sourceLabel = group.sourceKind === "project" ? (group.projectName || "Project") : "Chat";
|
|
366
|
+
const threadTitle = group.threadTitle || (group.sourceKind === "project" ? "Untitled task" : "Untitled chat");
|
|
367
|
+
button.setAttribute("aria-label", `Open ${threadTitle}, ${group.imageCount} images`);
|
|
368
|
+
|
|
369
|
+
const preview = document.createElement("span");
|
|
370
|
+
preview.className = "thread-board-preview";
|
|
371
|
+
preview.classList.toggle("is-single", group.previews.length === 1);
|
|
372
|
+
for (const image of group.previews) {
|
|
373
|
+
const thumbnail = document.createElement("img");
|
|
374
|
+
thumbnail.src = image.fileUrl;
|
|
375
|
+
thumbnail.alt = "";
|
|
376
|
+
thumbnail.loading = "lazy";
|
|
377
|
+
thumbnail.decoding = "async";
|
|
378
|
+
if (image.width && image.height) {
|
|
379
|
+
thumbnail.width = image.width;
|
|
380
|
+
thumbnail.height = image.height;
|
|
381
|
+
}
|
|
382
|
+
thumbnail.addEventListener("load", scheduleMasonry, { once: true });
|
|
383
|
+
preview.append(thumbnail);
|
|
384
|
+
}
|
|
385
|
+
const hiddenCount = group.imageCount - group.previews.length;
|
|
386
|
+
if (hiddenCount > 0) {
|
|
387
|
+
const overflow = document.createElement("span");
|
|
388
|
+
overflow.className = "thread-board-overflow";
|
|
389
|
+
overflow.textContent = `+${hiddenCount}`;
|
|
390
|
+
preview.append(overflow);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const caption = document.createElement("span");
|
|
394
|
+
caption.className = "thread-board-caption";
|
|
395
|
+
caption.append(createSourceValue(group.sourceKind === "project" ? "project" : "chat", sourceLabel, "card-source"));
|
|
396
|
+
const title = document.createElement("span");
|
|
397
|
+
title.className = "thread-board-title";
|
|
398
|
+
title.textContent = threadTitle;
|
|
399
|
+
const meta = document.createElement("span");
|
|
400
|
+
meta.className = "thread-board-meta";
|
|
401
|
+
const imageCount = document.createElement("span");
|
|
402
|
+
imageCount.textContent = `${group.imageCount} ${group.imageCount === 1 ? "image" : "images"}`;
|
|
403
|
+
const updatedAt = document.createElement("span");
|
|
404
|
+
updatedAt.textContent = dateFormatter.format(new Date(group.updatedAt));
|
|
405
|
+
meta.append(imageCount, updatedAt);
|
|
406
|
+
caption.append(title, meta);
|
|
407
|
+
|
|
408
|
+
button.append(preview, caption);
|
|
409
|
+
button.addEventListener("click", () => openThreadGroup(group, button));
|
|
410
|
+
return button;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function updateEmptyState() {
|
|
414
|
+
empty.hidden = totalItems !== 0;
|
|
415
|
+
if (viewContext === "thread") {
|
|
416
|
+
emptyTitle.textContent = "No images in this Thread";
|
|
417
|
+
emptyDescription.textContent = "Return to the library and choose another Thread.";
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const selected = selectedFacet();
|
|
421
|
+
if (selectedScope === "all") {
|
|
422
|
+
emptyTitle.textContent = "No images yet";
|
|
423
|
+
emptyDescription.textContent = "Generate an image in any Task and it will appear here automatically.";
|
|
424
|
+
} else {
|
|
425
|
+
emptyTitle.textContent = `No images in ${selected.label}`;
|
|
426
|
+
emptyDescription.textContent = "Choose another source or generate a new image in this Task.";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function updateLibrarySummary() {
|
|
431
|
+
updateFilterTrigger();
|
|
432
|
+
updateEmptyState();
|
|
433
|
+
pageSentinel.hidden = items.length >= totalItems;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function render() {
|
|
437
|
+
const factory = viewContext === "browse" && groupByThread ? createThreadBoard : createCard;
|
|
438
|
+
grid.replaceChildren(...items.map(factory));
|
|
439
|
+
grid.classList.toggle("is-grouped", viewContext === "browse" && groupByThread);
|
|
440
|
+
updateLibrarySummary();
|
|
441
|
+
if (activeImage) {
|
|
442
|
+
grid.querySelector(`[data-image-id="${activeImage.id}"]`)?.classList.add("is-selected");
|
|
443
|
+
}
|
|
444
|
+
scheduleMasonry();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function pageUrl({ offset, limit = PAGE_SIZE } = {}) {
|
|
448
|
+
const parameters = new URLSearchParams({ limit: String(limit), offset: String(offset ?? 0) });
|
|
449
|
+
if (viewContext === "thread") {
|
|
450
|
+
parameters.set("threadId", activeThreadGroup.threadId);
|
|
451
|
+
parameters.set("sort", "asc");
|
|
452
|
+
return `/api/images?${parameters}`;
|
|
453
|
+
}
|
|
454
|
+
parameters.set("source", selectedScope);
|
|
455
|
+
return groupByThread
|
|
456
|
+
? `/api/image-groups?${parameters}`
|
|
457
|
+
: `/api/images?${parameters}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function loadPage({ reset = false, preserveScroll = false } = {}) {
|
|
461
|
+
if (pageRequest && !reset) return pageRequest;
|
|
462
|
+
const generation = reset ? ++loadGeneration : loadGeneration;
|
|
463
|
+
const offset = reset ? 0 : items.length;
|
|
464
|
+
const preservedScrollTop = reset && preserveScroll ? scrollArea.scrollTop : null;
|
|
465
|
+
const request = (async () => {
|
|
466
|
+
const response = await fetch(pageUrl({ offset }));
|
|
467
|
+
if (!response.ok) throw new Error("Could not load images");
|
|
468
|
+
const payload = await response.json();
|
|
469
|
+
if (generation !== loadGeneration) return;
|
|
470
|
+
|
|
471
|
+
const nextItems = groupByThread && viewContext === "browse" ? payload.groups : payload.images;
|
|
472
|
+
totalItems = payload.count;
|
|
473
|
+
filteredImageCount = payload.imageCount ?? payload.count;
|
|
474
|
+
|
|
475
|
+
if (reset) {
|
|
476
|
+
items = nextItems;
|
|
477
|
+
if (preservedScrollTop === null) scrollArea.scrollTop = 0;
|
|
478
|
+
else pendingScrollTop = preservedScrollTop;
|
|
479
|
+
render();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const key = groupByThread && viewContext === "browse" ? "threadId" : "id";
|
|
484
|
+
const existingIds = new Set(items.map((item) => item[key]));
|
|
485
|
+
const uniqueItems = nextItems.filter((item) => !existingIds.has(item[key]));
|
|
486
|
+
items.push(...uniqueItems);
|
|
487
|
+
const factory = groupByThread && viewContext === "browse" ? createThreadBoard : createCard;
|
|
488
|
+
grid.append(...uniqueItems.map(factory));
|
|
489
|
+
updateLibrarySummary();
|
|
490
|
+
scheduleMasonry();
|
|
491
|
+
})();
|
|
492
|
+
pageRequest = request;
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
await request;
|
|
496
|
+
} finally {
|
|
497
|
+
if (pageRequest === request) pageRequest = null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function selectSource(scope) {
|
|
502
|
+
closeSourceMenu({ restoreFocus: true });
|
|
503
|
+
if (scope === selectedScope) return;
|
|
504
|
+
if (shell.classList.contains("drawer-open")) closeDrawer();
|
|
505
|
+
selectedScope = scope;
|
|
506
|
+
renderSourceMenu();
|
|
507
|
+
await loadPage({ reset: true });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function toggleThreadGrouping() {
|
|
511
|
+
if (viewContext !== "browse") return;
|
|
512
|
+
closeToolbarPopups();
|
|
513
|
+
if (shell.classList.contains("drawer-open")) closeDrawer();
|
|
514
|
+
groupByThread = !groupByThread;
|
|
515
|
+
groupButton.setAttribute("aria-pressed", String(groupByThread));
|
|
516
|
+
await loadPage({ reset: true });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function checkForUpdates() {
|
|
520
|
+
if (!canPollForUpdates()) return;
|
|
521
|
+
const response = await fetch(pageUrl({ offset: 0, limit: 1 }));
|
|
522
|
+
if (!response.ok) throw new Error("Could not check for new images");
|
|
523
|
+
const payload = await response.json();
|
|
524
|
+
const nextItems = groupByThread ? payload.groups : payload.images;
|
|
525
|
+
const nextFirst = nextItems[0];
|
|
526
|
+
const currentFirst = items[0];
|
|
527
|
+
const changed = groupByThread
|
|
528
|
+
? payload.count !== totalItems
|
|
529
|
+
|| payload.imageCount !== filteredImageCount
|
|
530
|
+
|| nextFirst?.threadId !== currentFirst?.threadId
|
|
531
|
+
|| nextFirst?.updatedAt !== currentFirst?.updatedAt
|
|
532
|
+
: payload.count !== totalItems || nextFirst?.id !== currentFirst?.id;
|
|
533
|
+
if (changed) {
|
|
534
|
+
await Promise.all([loadFacets(), loadPage({ reset: true, preserveScroll: true })]);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function canPollForUpdates() {
|
|
539
|
+
return hostSurfaceActive
|
|
540
|
+
&& documentVisible
|
|
541
|
+
&& windowFocused
|
|
542
|
+
&& shellIntersecting
|
|
543
|
+
&& viewContext === "browse"
|
|
544
|
+
&& !shell.classList.contains("drawer-open");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function refreshUpdatePolling({ immediate = false } = {}) {
|
|
548
|
+
clearUpdateCheckTimer();
|
|
549
|
+
if (canPollForUpdates()) scheduleUpdateCheck(immediate ? 0 : 3000);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function clearUpdateCheckTimer() {
|
|
553
|
+
if (updateCheckTimer === null) return;
|
|
554
|
+
clearTimeout(updateCheckTimer);
|
|
555
|
+
updateCheckTimer = null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function scheduleUpdateCheck(delay = 3000) {
|
|
559
|
+
clearUpdateCheckTimer();
|
|
560
|
+
if (!canPollForUpdates()) return;
|
|
561
|
+
updateCheckTimer = setTimeout(async () => {
|
|
562
|
+
updateCheckTimer = null;
|
|
563
|
+
if (!canPollForUpdates()) return;
|
|
564
|
+
try {
|
|
565
|
+
await checkForUpdates();
|
|
566
|
+
} catch (error) {
|
|
567
|
+
console.error(error);
|
|
568
|
+
} finally {
|
|
569
|
+
scheduleUpdateCheck();
|
|
570
|
+
}
|
|
571
|
+
}, delay);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function setHostSurfaceActive(active) {
|
|
575
|
+
const changed = hostSurfaceActive !== Boolean(active);
|
|
576
|
+
hostSurfaceActive = Boolean(active);
|
|
577
|
+
refreshUpdatePolling({ immediate: changed && hostSurfaceActive });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function setDocumentVisible(visible) {
|
|
581
|
+
const changed = documentVisible !== Boolean(visible);
|
|
582
|
+
documentVisible = Boolean(visible);
|
|
583
|
+
refreshUpdatePolling({ immediate: changed && documentVisible });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function setWindowFocused(focused) {
|
|
587
|
+
const changed = windowFocused !== Boolean(focused);
|
|
588
|
+
windowFocused = Boolean(focused);
|
|
589
|
+
refreshUpdatePolling({ immediate: changed && windowFocused });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function setShellIntersecting(intersecting) {
|
|
593
|
+
const changed = shellIntersecting !== Boolean(intersecting);
|
|
594
|
+
shellIntersecting = Boolean(intersecting);
|
|
595
|
+
refreshUpdatePolling({ immediate: changed && shellIntersecting });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function resetCopyFeedback() {
|
|
599
|
+
if (copyFeedbackTimer !== null) {
|
|
600
|
+
clearTimeout(copyFeedbackTimer);
|
|
601
|
+
copyFeedbackTimer = null;
|
|
602
|
+
}
|
|
603
|
+
promptCopyIcon.removeAttribute("hidden");
|
|
604
|
+
promptCheckIcon.setAttribute("hidden", "");
|
|
605
|
+
promptCopyButton.classList.remove("is-copied");
|
|
606
|
+
promptCopyButton.setAttribute("aria-label", "Copy generation prompt");
|
|
607
|
+
promptCopyButton.title = "Copy prompt";
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function updateSourceAction(image) {
|
|
611
|
+
const archived = Boolean(image.archived);
|
|
612
|
+
sourceButton.disabled = archived;
|
|
613
|
+
sourceAction.classList.toggle("is-disabled", archived);
|
|
614
|
+
sourceTooltip.hidden = !archived;
|
|
615
|
+
if (archived) {
|
|
616
|
+
sourceButton.setAttribute("aria-describedby", sourceTooltip.id);
|
|
617
|
+
sourceButton.setAttribute("aria-label", "Open Thread unavailable: this Thread has been archived");
|
|
618
|
+
} else {
|
|
619
|
+
sourceButton.removeAttribute("aria-describedby");
|
|
620
|
+
sourceButton.removeAttribute("aria-label");
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function copyText(text) {
|
|
625
|
+
const textarea = document.createElement("textarea");
|
|
626
|
+
textarea.value = text;
|
|
627
|
+
textarea.setAttribute("readonly", "");
|
|
628
|
+
textarea.style.position = "fixed";
|
|
629
|
+
textarea.style.opacity = "0";
|
|
630
|
+
document.body.append(textarea);
|
|
631
|
+
textarea.select();
|
|
632
|
+
const copied = document.execCommand("copy");
|
|
633
|
+
textarea.remove();
|
|
634
|
+
if (copied) return true;
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
await navigator.clipboard.writeText(text);
|
|
638
|
+
return true;
|
|
639
|
+
} catch {
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function rememberGroupOrigin(element) {
|
|
645
|
+
const origin = element?.closest?.(".image-card, .thread-board") ?? element;
|
|
646
|
+
if (!origin) return;
|
|
647
|
+
groupOrigin = origin;
|
|
648
|
+
groupOriginImageId = origin.dataset.imageId ?? null;
|
|
649
|
+
groupOriginThreadId = origin.dataset.threadId ?? null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function rememberDrawerOrigin(element) {
|
|
653
|
+
if (viewContext === "thread" && groupOrigin) return;
|
|
654
|
+
const origin = element?.closest?.(".image-card, .thread-board") ?? element;
|
|
655
|
+
if (!origin) return;
|
|
656
|
+
drawerOrigin = origin;
|
|
657
|
+
drawerOriginImageId = origin.dataset.imageId ?? null;
|
|
658
|
+
drawerOriginThreadId = origin.dataset.threadId ?? null;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function clearGroupOrigin() {
|
|
662
|
+
groupOrigin = null;
|
|
663
|
+
groupOriginImageId = null;
|
|
664
|
+
groupOriginThreadId = null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function clearDrawerOrigin() {
|
|
668
|
+
drawerOrigin = null;
|
|
669
|
+
drawerOriginImageId = null;
|
|
670
|
+
drawerOriginThreadId = null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function restoreDrawerFocus() {
|
|
674
|
+
const origin = groupOrigin ?? drawerOrigin;
|
|
675
|
+
const originImageId = groupOrigin ? groupOriginImageId : drawerOriginImageId;
|
|
676
|
+
const originThreadId = groupOrigin ? groupOriginThreadId : drawerOriginThreadId;
|
|
677
|
+
if (origin?.isConnected) {
|
|
678
|
+
origin.focus({ preventScroll: true });
|
|
679
|
+
clearGroupOrigin();
|
|
680
|
+
clearDrawerOrigin();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const fallback = originImageId
|
|
684
|
+
? grid.querySelector(`[data-image-id="${originImageId}"]`)
|
|
685
|
+
: originThreadId
|
|
686
|
+
? grid.querySelector(`[data-thread-id="${originThreadId}"]`)
|
|
687
|
+
: null;
|
|
688
|
+
clearGroupOrigin();
|
|
689
|
+
clearDrawerOrigin();
|
|
690
|
+
fallback?.focus({ preventScroll: true });
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function detailContextFocusableElements() {
|
|
694
|
+
const visible = (element) => !element.hidden && element.getClientRects().length;
|
|
695
|
+
const imageSelectors = [...grid.querySelectorAll("button.image-card, button.thread-board")].filter(visible);
|
|
696
|
+
const detailControls = [...drawer.querySelectorAll(
|
|
697
|
+
'button:not([disabled]), a[href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
698
|
+
)].filter(visible);
|
|
699
|
+
return [...imageSelectors, ...detailControls];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function trapDetailContextFocus(event) {
|
|
703
|
+
if (!sourceMenu.hidden || !shell.classList.contains("drawer-open") || event.key !== "Tab") return;
|
|
704
|
+
const focusable = detailContextFocusableElements();
|
|
705
|
+
if (!focusable.length) {
|
|
706
|
+
event.preventDefault();
|
|
707
|
+
drawer.focus();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const currentIndex = focusable.indexOf(document.activeElement);
|
|
711
|
+
const baseIndex = currentIndex < 0 ? (event.shiftKey ? 0 : -1) : currentIndex;
|
|
712
|
+
const nextIndex = event.shiftKey
|
|
713
|
+
? (baseIndex - 1 + focusable.length) % focusable.length
|
|
714
|
+
: (baseIndex + 1) % focusable.length;
|
|
715
|
+
event.preventDefault();
|
|
716
|
+
focusable[nextIndex].focus();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function showImageDetails(image) {
|
|
720
|
+
activeImage = image;
|
|
721
|
+
const isProject = image.sourceKind === "project";
|
|
722
|
+
drawerProjectRow.hidden = !isProject;
|
|
723
|
+
if (isProject) drawerProject.textContent = image.projectName || "Project";
|
|
724
|
+
drawerTaskLabel.textContent = isProject ? "Thread" : "Chat";
|
|
725
|
+
drawerThread.textContent = image.threadTitle || (isProject ? "Untitled task" : "Untitled chat");
|
|
726
|
+
drawerImage.src = image.fileUrl;
|
|
727
|
+
drawerImage.alt = image.prompt || "Generated image";
|
|
728
|
+
if (image.width && image.height) {
|
|
729
|
+
drawerImage.width = image.width;
|
|
730
|
+
drawerImage.height = image.height;
|
|
731
|
+
} else {
|
|
732
|
+
drawerImage.removeAttribute("width");
|
|
733
|
+
drawerImage.removeAttribute("height");
|
|
734
|
+
}
|
|
735
|
+
drawerDate.textContent = dateFormatter.format(new Date(image.createdAt));
|
|
736
|
+
drawerSize.textContent = image.width && image.height ? `${image.width} × ${image.height}` : "Original";
|
|
737
|
+
drawerFileSize.textContent = formatBytes(image.byteLength);
|
|
738
|
+
drawerPrompt.textContent = image.prompt || "Prompt is not available for this image.";
|
|
739
|
+
drawerPrompt.classList.toggle("is-missing", !image.prompt);
|
|
740
|
+
promptCopyButton.disabled = !image.prompt;
|
|
741
|
+
updateSourceAction(image);
|
|
742
|
+
resetCopyFeedback();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function openImage(image, originatingElement = null) {
|
|
746
|
+
if (originatingElement) rememberDrawerOrigin(originatingElement);
|
|
747
|
+
const request = ++detailRequest;
|
|
748
|
+
showImageDetails(image);
|
|
749
|
+
drawerPrompt.textContent = "Loading generation prompt…";
|
|
750
|
+
drawerPrompt.classList.add("is-loading");
|
|
751
|
+
drawerPrompt.classList.remove("is-missing");
|
|
752
|
+
promptCopyButton.disabled = true;
|
|
753
|
+
shell.classList.add("drawer-open");
|
|
754
|
+
scheduleMasonry();
|
|
755
|
+
drawer.setAttribute("aria-hidden", "false");
|
|
756
|
+
drawer.inert = false;
|
|
757
|
+
drawer.querySelector(".drawer-close")?.focus({ preventScroll: true });
|
|
758
|
+
grid.querySelectorAll(".is-selected").forEach((card) => card.classList.remove("is-selected"));
|
|
759
|
+
grid.querySelector(`[data-image-id="${image.id}"]`)?.classList.add("is-selected");
|
|
760
|
+
|
|
761
|
+
try {
|
|
762
|
+
const response = await fetch(`/api/images/${image.id}`);
|
|
763
|
+
if (!response.ok) throw new Error("Could not load image details");
|
|
764
|
+
const payload = await response.json();
|
|
765
|
+
if (request !== detailRequest) return;
|
|
766
|
+
showImageDetails(payload.image);
|
|
767
|
+
} catch (error) {
|
|
768
|
+
if (request !== detailRequest) return;
|
|
769
|
+
drawerPrompt.textContent = "Prompt could not be loaded.";
|
|
770
|
+
drawerPrompt.classList.add("is-missing");
|
|
771
|
+
announce("Prompt could not be loaded.");
|
|
772
|
+
console.error(error);
|
|
773
|
+
} finally {
|
|
774
|
+
if (request === detailRequest) drawerPrompt.classList.remove("is-loading");
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function openThreadGroup(group, originatingElement = null) {
|
|
779
|
+
clearGroupOrigin();
|
|
780
|
+
if (originatingElement) rememberGroupOrigin(originatingElement);
|
|
781
|
+
savedBrowseState = {
|
|
782
|
+
items: items.slice(),
|
|
783
|
+
totalItems,
|
|
784
|
+
filteredImageCount,
|
|
785
|
+
scrollTop: scrollArea.scrollTop,
|
|
786
|
+
nodes: [...grid.children],
|
|
787
|
+
};
|
|
788
|
+
viewContext = "thread";
|
|
789
|
+
activeThreadGroup = group;
|
|
790
|
+
activeImage = null;
|
|
791
|
+
shell.classList.add("thread-context");
|
|
792
|
+
try {
|
|
793
|
+
await loadPage({ reset: true });
|
|
794
|
+
if (items[0]) await openImage(items[0]);
|
|
795
|
+
} catch (error) {
|
|
796
|
+
console.error(error);
|
|
797
|
+
leaveThreadGroup();
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function leaveThreadGroup() {
|
|
802
|
+
if (viewContext !== "thread") return;
|
|
803
|
+
detailRequest += 1;
|
|
804
|
+
const restore = savedBrowseState;
|
|
805
|
+
viewContext = "browse";
|
|
806
|
+
activeThreadGroup = null;
|
|
807
|
+
savedBrowseState = null;
|
|
808
|
+
activeImage = null;
|
|
809
|
+
shell.classList.remove("drawer-open", "thread-context");
|
|
810
|
+
drawer.setAttribute("aria-hidden", "true");
|
|
811
|
+
drawer.inert = true;
|
|
812
|
+
resetCopyFeedback();
|
|
813
|
+
if (restore) {
|
|
814
|
+
items = restore.items;
|
|
815
|
+
totalItems = restore.totalItems;
|
|
816
|
+
filteredImageCount = restore.filteredImageCount;
|
|
817
|
+
pendingScrollTop = restore.scrollTop;
|
|
818
|
+
grid.replaceChildren(...restore.nodes);
|
|
819
|
+
grid.classList.toggle("is-grouped", groupByThread);
|
|
820
|
+
updateLibrarySummary();
|
|
821
|
+
scheduleMasonry();
|
|
822
|
+
scheduleUpdateCheck(0);
|
|
823
|
+
} else {
|
|
824
|
+
loadPage({ reset: true }).catch(console.error);
|
|
825
|
+
}
|
|
826
|
+
restoreDrawerFocus();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function closeDrawer() {
|
|
830
|
+
if (viewContext === "thread") {
|
|
831
|
+
leaveThreadGroup();
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
detailRequest += 1;
|
|
835
|
+
activeImage = null;
|
|
836
|
+
shell.classList.remove("drawer-open");
|
|
837
|
+
drawer.setAttribute("aria-hidden", "true");
|
|
838
|
+
drawer.inert = true;
|
|
839
|
+
resetCopyFeedback();
|
|
840
|
+
grid.querySelectorAll(".is-selected").forEach((card) => card.classList.remove("is-selected"));
|
|
841
|
+
scheduleMasonry();
|
|
842
|
+
scheduleUpdateCheck(0);
|
|
843
|
+
restoreDrawerFocus();
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function openSourceThread(threadId) {
|
|
847
|
+
if (window.parent !== window) {
|
|
848
|
+
window.parent.postMessage({ type: "codex-personal:open-thread", threadId }, "*");
|
|
849
|
+
} else {
|
|
850
|
+
window.location.href = `codex://threads/${threadId}`;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
filterButton.addEventListener("click", () => {
|
|
855
|
+
if (sourceMenu.hidden) openSourceMenu();
|
|
856
|
+
else closeSourceMenu({ restoreFocus: true });
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
filterButton.addEventListener("keydown", (event) => {
|
|
860
|
+
if (event.key === "ArrowDown") {
|
|
861
|
+
event.preventDefault();
|
|
862
|
+
if (sourceMenu.hidden) openSourceMenu();
|
|
863
|
+
else moveSourceOption(1);
|
|
864
|
+
} else if (event.key === "ArrowUp") {
|
|
865
|
+
event.preventDefault();
|
|
866
|
+
if (sourceMenu.hidden) openSourceMenu();
|
|
867
|
+
else moveSourceOption(-1);
|
|
868
|
+
} else if (event.key === "Home") {
|
|
869
|
+
event.preventDefault();
|
|
870
|
+
if (sourceMenu.hidden) openSourceMenu({ focusIndex: 0 });
|
|
871
|
+
else setSourceOptionFocus(0);
|
|
872
|
+
} else if (event.key === "End") {
|
|
873
|
+
event.preventDefault();
|
|
874
|
+
if (sourceMenu.hidden) openSourceMenu({ focusIndex: sourceOptions().length - 1 });
|
|
875
|
+
else setSourceOptionFocus(sourceOptions().length - 1);
|
|
876
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
877
|
+
event.preventDefault();
|
|
878
|
+
if (sourceMenu.hidden) openSourceMenu();
|
|
879
|
+
} else if (event.key === "Escape" && !sourceMenu.hidden) {
|
|
880
|
+
event.preventDefault();
|
|
881
|
+
closeSourceMenu({ restoreFocus: true });
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
sourceMenu.addEventListener("keydown", (event) => {
|
|
886
|
+
if (event.key === "Tab") {
|
|
887
|
+
setTimeout(() => closeSourceMenu(), 0);
|
|
888
|
+
} else if (event.key === "ArrowDown") {
|
|
889
|
+
event.preventDefault();
|
|
890
|
+
moveSourceOption(1);
|
|
891
|
+
} else if (event.key === "ArrowUp") {
|
|
892
|
+
event.preventDefault();
|
|
893
|
+
moveSourceOption(-1);
|
|
894
|
+
} else if (event.key === "Home") {
|
|
895
|
+
event.preventDefault();
|
|
896
|
+
setSourceOptionFocus(0);
|
|
897
|
+
} else if (event.key === "End") {
|
|
898
|
+
event.preventDefault();
|
|
899
|
+
setSourceOptionFocus(sourceOptions().length - 1);
|
|
900
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
901
|
+
event.preventDefault();
|
|
902
|
+
document.activeElement?.closest?.('[role="option"]')?.click();
|
|
903
|
+
} else if (event.key === "Escape") {
|
|
904
|
+
event.preventDefault();
|
|
905
|
+
closeSourceMenu({ restoreFocus: true });
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
densitySlider.addEventListener("input", () => {
|
|
910
|
+
densityColumns = Number.parseInt(densitySlider.value, 10);
|
|
911
|
+
try {
|
|
912
|
+
localStorage.setItem(DENSITY_STORAGE_KEY, String(densityColumns));
|
|
913
|
+
} catch {
|
|
914
|
+
// The selected density still applies for the current window.
|
|
915
|
+
}
|
|
916
|
+
applyDensity();
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
groupButton.addEventListener("click", () => toggleThreadGrouping().catch(console.error));
|
|
920
|
+
|
|
921
|
+
document.addEventListener("click", (event) => {
|
|
922
|
+
if (!event.target.closest(".source-filter")) closeToolbarPopups();
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
rescan.addEventListener("click", async () => {
|
|
926
|
+
rescan.disabled = true;
|
|
927
|
+
rescan.classList.add("is-loading");
|
|
928
|
+
rescan.setAttribute("aria-busy", "true");
|
|
929
|
+
try {
|
|
930
|
+
await Promise.all([
|
|
931
|
+
(async () => {
|
|
932
|
+
const response = await fetch("/api/rescan", { method: "POST" });
|
|
933
|
+
if (!response.ok) throw new Error("Could not refresh images");
|
|
934
|
+
const result = await response.json();
|
|
935
|
+
announce(result.inProgress ? "Image refresh is already in progress." : "Images refreshed.");
|
|
936
|
+
await loadFacets();
|
|
937
|
+
if (shell.classList.contains("drawer-open") && activeImage) {
|
|
938
|
+
const detailResponse = await fetch(`/api/images/${activeImage.id}`);
|
|
939
|
+
if (detailResponse.ok) {
|
|
940
|
+
const payload = await detailResponse.json();
|
|
941
|
+
activeImage = payload.image;
|
|
942
|
+
updateSourceAction(activeImage);
|
|
943
|
+
}
|
|
944
|
+
} else if (viewContext === "browse") {
|
|
945
|
+
await loadPage({ reset: true, preserveScroll: true });
|
|
946
|
+
}
|
|
947
|
+
})(),
|
|
948
|
+
new Promise((resolve) => setTimeout(resolve, 1000)),
|
|
949
|
+
]);
|
|
950
|
+
} catch (error) {
|
|
951
|
+
announce(error.message);
|
|
952
|
+
console.error(error);
|
|
953
|
+
} finally {
|
|
954
|
+
rescan.disabled = false;
|
|
955
|
+
rescan.classList.remove("is-loading");
|
|
956
|
+
rescan.removeAttribute("aria-busy");
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
drawer.querySelector(".drawer-close").addEventListener("click", closeDrawer);
|
|
961
|
+
sourceButton.addEventListener("click", () => {
|
|
962
|
+
if (activeImage && !activeImage.archived) openSourceThread(activeImage.threadId);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
promptCopyButton.addEventListener("click", async () => {
|
|
966
|
+
const prompt = activeImage?.prompt;
|
|
967
|
+
if (!prompt) return;
|
|
968
|
+
if (!(await copyText(prompt))) {
|
|
969
|
+
announce("Prompt could not be copied.");
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
resetCopyFeedback();
|
|
973
|
+
promptCopyIcon.setAttribute("hidden", "");
|
|
974
|
+
promptCheckIcon.removeAttribute("hidden");
|
|
975
|
+
promptCopyButton.classList.add("is-copied");
|
|
976
|
+
promptCopyButton.setAttribute("aria-label", "Prompt copied");
|
|
977
|
+
promptCopyButton.title = "Copied";
|
|
978
|
+
announce("Prompt copied.");
|
|
979
|
+
copyFeedbackTimer = setTimeout(resetCopyFeedback, 2000);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
saveButton.addEventListener("click", async () => {
|
|
983
|
+
if (!activeImage) return;
|
|
984
|
+
const imageId = activeImage.id;
|
|
985
|
+
saveButton.disabled = true;
|
|
986
|
+
saveButton.textContent = "Saving…";
|
|
987
|
+
try {
|
|
988
|
+
const response = await fetch(`/api/images/${imageId}/save-as`, { method: "POST" });
|
|
989
|
+
if (!response.ok) throw new Error("Could not save image");
|
|
990
|
+
const result = await response.json();
|
|
991
|
+
if (result.saved) {
|
|
992
|
+
saveButton.textContent = "Saved";
|
|
993
|
+
announce("Image saved.");
|
|
994
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
995
|
+
}
|
|
996
|
+
} catch (error) {
|
|
997
|
+
announce(error.message);
|
|
998
|
+
console.error(error);
|
|
999
|
+
} finally {
|
|
1000
|
+
saveButton.disabled = false;
|
|
1001
|
+
saveButton.textContent = "Save as";
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
document.addEventListener("keydown", (event) => {
|
|
1006
|
+
if (shell.classList.contains("drawer-open")) {
|
|
1007
|
+
trapDetailContextFocus(event);
|
|
1008
|
+
}
|
|
1009
|
+
if (event.key !== "Escape") return;
|
|
1010
|
+
if (!sourceMenu.hidden) {
|
|
1011
|
+
event.preventDefault();
|
|
1012
|
+
closeSourceMenu({ restoreFocus: true });
|
|
1013
|
+
} else if (shell.classList.contains("drawer-open")) {
|
|
1014
|
+
event.preventDefault();
|
|
1015
|
+
closeDrawer();
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
drawer.inert = true;
|
|
1020
|
+
applyDensity();
|
|
1021
|
+
await Promise.all([loadFacets(), loadPage({ reset: true })]);
|
|
1022
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1023
|
+
|
|
1024
|
+
new ResizeObserver(() => {
|
|
1025
|
+
scheduleMasonry();
|
|
1026
|
+
}).observe(scrollArea);
|
|
1027
|
+
|
|
1028
|
+
new IntersectionObserver(
|
|
1029
|
+
(entries) => {
|
|
1030
|
+
if (entries.some((entry) => entry.isIntersecting)) loadPage().catch(console.error);
|
|
1031
|
+
},
|
|
1032
|
+
{ root: scrollArea, rootMargin: "600px 0px" },
|
|
1033
|
+
).observe(pageSentinel);
|
|
1034
|
+
|
|
1035
|
+
document.addEventListener("visibilitychange", () => {
|
|
1036
|
+
setDocumentVisible(document.visibilityState !== "hidden");
|
|
1037
|
+
});
|
|
1038
|
+
window.addEventListener("focus", () => setWindowFocused(true));
|
|
1039
|
+
window.addEventListener("blur", () => setWindowFocused(false));
|
|
1040
|
+
if (typeof IntersectionObserver === "function") {
|
|
1041
|
+
new IntersectionObserver((entries) => {
|
|
1042
|
+
setShellIntersecting(entries.some((entry) => entry.isIntersecting));
|
|
1043
|
+
}).observe(shell);
|
|
1044
|
+
}
|
|
1045
|
+
scheduleUpdateCheck();
|