@lionrapid/core 0.1.2 → 0.1.4-beta.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 (39) hide show
  1. package/dist/browser/editor/editor-api.d.ts +61 -0
  2. package/dist/browser/editor/editor-api.d.ts.map +1 -1
  3. package/dist/browser/editor/editor-api.js +165 -0
  4. package/dist/browser/editor/editor-api.js.map +1 -1
  5. package/dist/browser/editor/editor-init.js +119 -7
  6. package/dist/browser/editor/editor-init.js.map +1 -1
  7. package/dist/browser/editor/editor-styles.d.ts +1 -1
  8. package/dist/browser/editor/editor-styles.d.ts.map +1 -1
  9. package/dist/browser/editor/editor-styles.js +81 -0
  10. package/dist/browser/editor/editor-styles.js.map +1 -1
  11. package/dist/browser/editor/inspector.d.ts +5 -1
  12. package/dist/browser/editor/inspector.d.ts.map +1 -1
  13. package/dist/browser/editor/inspector.js +32 -1
  14. package/dist/browser/editor/inspector.js.map +1 -1
  15. package/dist/browser/editor/pii-redactor.d.ts +39 -0
  16. package/dist/browser/editor/pii-redactor.d.ts.map +1 -0
  17. package/dist/browser/editor/pii-redactor.js +149 -0
  18. package/dist/browser/editor/pii-redactor.js.map +1 -0
  19. package/dist/browser/editor/sidebar.d.ts +17 -1
  20. package/dist/browser/editor/sidebar.d.ts.map +1 -1
  21. package/dist/browser/editor/sidebar.js +77 -1
  22. package/dist/browser/editor/sidebar.js.map +1 -1
  23. package/dist/browser/editor/snapshot-capture.d.ts +57 -0
  24. package/dist/browser/editor/snapshot-capture.d.ts.map +1 -0
  25. package/dist/browser/editor/snapshot-capture.js +235 -0
  26. package/dist/browser/editor/snapshot-capture.js.map +1 -0
  27. package/dist/browser/editor/snapshot-viewer.d.ts +35 -0
  28. package/dist/browser/editor/snapshot-viewer.d.ts.map +1 -0
  29. package/dist/browser/editor/snapshot-viewer.js +90 -0
  30. package/dist/browser/editor/snapshot-viewer.js.map +1 -0
  31. package/dist/browser/editor/toolbar.d.ts +7 -1
  32. package/dist/browser/editor/toolbar.d.ts.map +1 -1
  33. package/dist/browser/editor/toolbar.js +26 -1
  34. package/dist/browser/editor/toolbar.js.map +1 -1
  35. package/dist/browser/lionrapid.editor.js +707 -8
  36. package/dist/browser/lionrapid.editor.js.map +4 -4
  37. package/dist/browser/lionrapid.editor.min.js +84 -2
  38. package/dist/browser/lionrapid.editor.min.js.map +4 -4
  39. package/package.json +1 -1
@@ -153,6 +153,87 @@ var LionRapidEditor = (() => {
153
153
  background: rgba(255,255,255,0.1);
154
154
  }
155
155
 
156
+ .lr-fab-snapshot {
157
+ background: #2d7f7f;
158
+ border: 1px solid #2d7f7f;
159
+ color: #fff;
160
+ padding: 4px 10px;
161
+ border-radius: 4px;
162
+ cursor: pointer;
163
+ font-size: 13px;
164
+ font-weight: 600;
165
+ }
166
+
167
+ .lr-fab-snapshot:hover {
168
+ background: #3a9999;
169
+ border-color: #3a9999;
170
+ }
171
+
172
+ .lr-fab-snapshot:disabled {
173
+ opacity: 0.5;
174
+ cursor: not-allowed;
175
+ }
176
+
177
+ /* Workflow action buttons (Phase 3) */
178
+ .lr-workflow-actions {
179
+ display: flex;
180
+ flex-wrap: wrap;
181
+ gap: 6px;
182
+ margin: 12px 0 4px;
183
+ padding: 8px 0 0;
184
+ border-top: 1px solid #e5e7eb;
185
+ }
186
+
187
+ .lr-wf-btn {
188
+ font-size: 12px;
189
+ font-weight: 500;
190
+ padding: 5px 10px;
191
+ border-radius: 4px;
192
+ border: 1px solid transparent;
193
+ cursor: pointer;
194
+ transition: background-color 0.1s;
195
+ }
196
+
197
+ .lr-wf-btn:disabled {
198
+ opacity: 0.5;
199
+ cursor: not-allowed;
200
+ }
201
+
202
+ .lr-wf-primary {
203
+ background: #2563eb;
204
+ color: white;
205
+ border-color: #2563eb;
206
+ }
207
+ .lr-wf-primary:hover:not(:disabled) {
208
+ background: #1d4ed8;
209
+ }
210
+
211
+ .lr-wf-secondary {
212
+ background: white;
213
+ color: #374151;
214
+ border-color: #d1d5db;
215
+ }
216
+ .lr-wf-secondary:hover:not(:disabled) {
217
+ background: #f3f4f6;
218
+ }
219
+
220
+ .lr-wf-danger {
221
+ background: white;
222
+ color: #b91c1c;
223
+ border-color: #fca5a5;
224
+ }
225
+ .lr-wf-danger:hover:not(:disabled) {
226
+ background: #fef2f2;
227
+ }
228
+
229
+ /* Snapshot viewer sandbox wrapper */
230
+ #__lr_snapshot_root {
231
+ isolation: isolate;
232
+ position: relative;
233
+ width: 100%;
234
+ min-height: 100vh;
235
+ }
236
+
156
237
  /* \u2500\u2500 Overlay \u2500\u2500 */
157
238
  .lr-overlay {
158
239
  position: fixed;
@@ -733,13 +814,19 @@ var LionRapidEditor = (() => {
733
814
  var SKIP_TAGS = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "CODE", "PRE", "TEXTAREA", "NOSCRIPT", "SVG"]);
734
815
  var BLOCK_SELECTOR2 = "p,h1,h2,h3,h4,h5,h6,li,td,th,label,button,figcaption,dt,dd,summary,caption";
735
816
  var Inspector = class {
736
- constructor(shadowRoot, onSelect) {
817
+ constructor(shadowRoot, onSelect, rootElement, snapshotKeyMap) {
737
818
  __publicField(this, "overlay");
738
819
  __publicField(this, "currentTarget", null);
739
820
  __publicField(this, "onSelect");
740
821
  __publicField(this, "rafId", null);
741
822
  __publicField(this, "active", true);
823
+ __publicField(this, "rootElement");
824
+ /** When set, clicks resolve keys from this map (snapshot viewer mode)
825
+ * instead of re-computing from the (redacted) DOM text. */
826
+ __publicField(this, "snapshotKeyMap");
742
827
  this.onSelect = onSelect;
828
+ this.rootElement = rootElement || document.body;
829
+ this.snapshotKeyMap = snapshotKeyMap || null;
743
830
  this.overlay = document.createElement("div");
744
831
  this.overlay.className = "lr-overlay";
745
832
  shadowRoot.appendChild(this.overlay);
@@ -769,6 +856,7 @@ var LionRapidEditor = (() => {
769
856
  const target = e.target;
770
857
  if (!target || !target.closest) return;
771
858
  if (target.closest("#__lionrapid-editor")) return;
859
+ if (!this.rootElement.contains(target)) return;
772
860
  if (this.shouldSkip(target)) {
773
861
  this.clearHighlight();
774
862
  return;
@@ -787,11 +875,25 @@ var LionRapidEditor = (() => {
787
875
  const target = e.target;
788
876
  if (!target || !target.closest) return;
789
877
  if (target.closest("#__lionrapid-editor")) return;
878
+ if (!this.rootElement.contains(target)) return;
790
879
  const block = target.closest(BLOCK_SELECTOR2) || target.closest("[data-lr-block]");
791
880
  if (!block) return;
792
881
  if (this.shouldSkip(block)) return;
793
882
  e.preventDefault();
794
883
  e.stopPropagation();
884
+ if (this.snapshotKeyMap) {
885
+ const idxAttr = block.getAttribute("data-lr-snap-idx");
886
+ if (idxAttr !== null) {
887
+ const idx = parseInt(idxAttr, 10);
888
+ const key = this.snapshotKeyMap.get(idx);
889
+ if (key) {
890
+ const unit = parseBlockElement(block);
891
+ this.onSelect({ key, unit, translatedUnit: null, element: block });
892
+ return;
893
+ }
894
+ }
895
+ return;
896
+ }
795
897
  const resolved = resolveKey(block);
796
898
  if (resolved) {
797
899
  this.onSelect(resolved);
@@ -828,7 +930,7 @@ var LionRapidEditor = (() => {
828
930
 
829
931
  // src/browser/editor/toolbar.ts
830
932
  var Toolbar = class {
831
- constructor(shadowRoot, languages, currentLocale, onLanguageChange, onExit) {
933
+ constructor(shadowRoot, languages, currentLocale, onLanguageChange, onExit, onCaptureSnapshot = null, captureOptions = {}) {
832
934
  __publicField(this, "el");
833
935
  __publicField(this, "langBtnEl");
834
936
  __publicField(this, "langMenuEl");
@@ -837,13 +939,16 @@ var LionRapidEditor = (() => {
837
939
  __publicField(this, "currentLocale");
838
940
  __publicField(this, "onLanguageChange");
839
941
  __publicField(this, "onExit");
942
+ __publicField(this, "onCaptureSnapshot");
840
943
  // Drag state
841
944
  __publicField(this, "dragging", false);
842
945
  __publicField(this, "dragOffsetX", 0);
843
946
  __publicField(this, "dragOffsetY", 0);
844
947
  this.onLanguageChange = onLanguageChange;
845
948
  this.onExit = onExit;
949
+ this.onCaptureSnapshot = onCaptureSnapshot;
846
950
  this.currentLocale = currentLocale;
951
+ const captureMode = captureOptions.mode ?? "capture";
847
952
  this.el = document.createElement("div");
848
953
  this.el.className = "lr-fab";
849
954
  const fabBtn = document.createElement("button");
@@ -887,6 +992,22 @@ var LionRapidEditor = (() => {
887
992
  }
888
993
  langWrap.appendChild(this.langMenuEl);
889
994
  panel.appendChild(langWrap);
995
+ if (this.onCaptureSnapshot) {
996
+ const captureBtn = document.createElement("button");
997
+ captureBtn.className = "lr-fab-snapshot";
998
+ if (captureMode === "recapture") {
999
+ captureBtn.textContent = "Save re-capture";
1000
+ captureBtn.title = "Save this page as a new version of the existing snapshot";
1001
+ } else {
1002
+ captureBtn.textContent = "Capture";
1003
+ captureBtn.title = "Capture a redacted snapshot of this page for translators";
1004
+ }
1005
+ captureBtn.addEventListener("click", (e) => {
1006
+ e.stopPropagation();
1007
+ if (this.onCaptureSnapshot) this.onCaptureSnapshot();
1008
+ });
1009
+ panel.appendChild(captureBtn);
1010
+ }
890
1011
  this.progressEl = document.createElement("span");
891
1012
  this.progressEl.className = "lr-fab-progress";
892
1013
  panel.appendChild(this.progressEl);
@@ -1205,7 +1326,7 @@ var LionRapidEditor = (() => {
1205
1326
  // src/browser/editor/sidebar.ts
1206
1327
  var htmlSerializer2 = new HTMLSerializer();
1207
1328
  var Sidebar = class {
1208
- constructor(shadowRoot, api, locale, namespace, defaultLocale, onBlockClick) {
1329
+ constructor(shadowRoot, api, locale, namespace, defaultLocale, onBlockClick, userRole = "") {
1209
1330
  __publicField(this, "el");
1210
1331
  __publicField(this, "bodyEl");
1211
1332
  __publicField(this, "statusEl");
@@ -1221,11 +1342,13 @@ var LionRapidEditor = (() => {
1221
1342
  __publicField(this, "onProgressUpdate", null);
1222
1343
  __publicField(this, "lastFetchTime", 0);
1223
1344
  __publicField(this, "lastFlushTime", 0);
1345
+ __publicField(this, "userRole");
1224
1346
  this.api = api;
1225
1347
  this.locale = locale;
1226
1348
  this.namespace = namespace;
1227
1349
  this.defaultLocale = defaultLocale;
1228
1350
  this.onBlockClick = onBlockClick;
1351
+ this.userRole = userRole;
1229
1352
  this.blockExtractor = new BlockExtractor();
1230
1353
  this.el = document.createElement("div");
1231
1354
  this.el.className = "lr-sidebar";
@@ -1379,6 +1502,9 @@ var LionRapidEditor = (() => {
1379
1502
  }
1380
1503
  return previewEl;
1381
1504
  });
1505
+ if (!isSourceLocale) {
1506
+ this.renderWorkflowActions(key, status);
1507
+ }
1382
1508
  if (!isLocked) {
1383
1509
  textarea.addEventListener("input", () => {
1384
1510
  const text = textarea.value;
@@ -1401,6 +1527,69 @@ var LionRapidEditor = (() => {
1401
1527
  setTimeout(() => textarea.focus(), 100);
1402
1528
  }
1403
1529
  }
1530
+ /**
1531
+ * Render Confirm/Reject/Mark-Ready/Unmark-Ready buttons under the preview.
1532
+ * Visibility:
1533
+ * - Confirm: shown when row is not locked and not already ready
1534
+ * - Reject: shown when row is not locked and not already ready (server
1535
+ * returns 400 if stage_index is already 0)
1536
+ * - Unconfirm: shown when isConfirmed
1537
+ * - Mark Ready: PM/admin only, hidden when already ready
1538
+ * - Unmark Ready: PM/admin only, only when ready
1539
+ * After any successful click we re-fetch status for this key so the UI
1540
+ * reflects the new state and the unaffected buttons hide/show themselves.
1541
+ */
1542
+ renderWorkflowActions(key, status) {
1543
+ const isPM = this.userRole === "project_manager" || this.userRole === "admin";
1544
+ const isReady = !!status?.isReady;
1545
+ const isLocked = !!status?.isLocked;
1546
+ const isConfirmed = !!status?.isConfirmed;
1547
+ const composite = `${this.locale}:${this.namespace}:${key}`;
1548
+ const wrap = document.createElement("div");
1549
+ wrap.className = "lr-workflow-actions";
1550
+ const addBtn = (label, action, variant = "secondary") => {
1551
+ const btn = document.createElement("button");
1552
+ btn.className = `lr-wf-btn lr-wf-${variant}`;
1553
+ btn.textContent = label;
1554
+ btn.addEventListener("click", async () => {
1555
+ btn.disabled = true;
1556
+ const result = await this.api.transitionTarget(composite, action);
1557
+ btn.disabled = false;
1558
+ if (result) {
1559
+ await this.refreshStatusForKey(key);
1560
+ }
1561
+ });
1562
+ wrap.appendChild(btn);
1563
+ };
1564
+ if (!isLocked && !isReady) {
1565
+ addBtn("Confirm", "confirm", "primary");
1566
+ addBtn("Reject", "reject", "danger");
1567
+ }
1568
+ if (isConfirmed && !isLocked && !isReady) {
1569
+ addBtn("Unconfirm", "unconfirm");
1570
+ }
1571
+ if (isPM) {
1572
+ if (!isReady) {
1573
+ addBtn("Mark Ready", "mark-ready", "primary");
1574
+ } else {
1575
+ addBtn("Unmark Ready", "unmark-ready", "danger");
1576
+ }
1577
+ }
1578
+ if (wrap.children.length > 0) {
1579
+ this.bodyEl.appendChild(wrap);
1580
+ }
1581
+ }
1582
+ /** Re-fetch status for one key and re-render the current view. */
1583
+ async refreshStatusForKey(key) {
1584
+ const fresh = await this.api.fetchStatus([key], this.locale, this.namespace);
1585
+ for (const [k, s] of Object.entries(fresh)) {
1586
+ this.statusMap.set(k, s);
1587
+ }
1588
+ if (this.current) {
1589
+ this.renderContent(this.current);
1590
+ }
1591
+ this.emitProgress();
1592
+ }
1404
1593
  addField(label, contentFn) {
1405
1594
  const field = document.createElement("div");
1406
1595
  field.className = "lr-field";
@@ -1723,8 +1912,440 @@ var LionRapidEditor = (() => {
1723
1912
  this.onSessionExpired();
1724
1913
  }
1725
1914
  }
1915
+ // ============================================
1916
+ // Snapshot API
1917
+ // ============================================
1918
+ /**
1919
+ * Upload a captured snapshot. Returns the created snapshot's UUID so the
1920
+ * caller can build a share URL (`?__lr_snapshot={uuid}`).
1921
+ */
1922
+ async createSnapshot(snapshot) {
1923
+ try {
1924
+ const response = await fetch(`${this.apiBase}/api/editor/snapshots`, {
1925
+ method: "POST",
1926
+ headers: {
1927
+ "Content-Type": "application/json",
1928
+ "Authorization": `Bearer ${this.sessionToken}`
1929
+ },
1930
+ body: JSON.stringify({
1931
+ pageUrl: snapshot.pageUrl,
1932
+ title: snapshot.title,
1933
+ viewport: snapshot.viewport,
1934
+ textEntries: snapshot.textEntries,
1935
+ html: snapshot.html
1936
+ })
1937
+ });
1938
+ if (response.status === 401) {
1939
+ this.handleSessionExpired();
1940
+ return null;
1941
+ }
1942
+ if (!response.ok) {
1943
+ throw new Error(`HTTP ${response.status}`);
1944
+ }
1945
+ const data = await response.json();
1946
+ return { snapshotUuid: data.snapshot_uuid || data.snapshotUuid };
1947
+ } catch (err) {
1948
+ this.onStatus("error", `Snapshot failed: ${err.message}`);
1949
+ return null;
1950
+ }
1951
+ }
1952
+ /**
1953
+ * Re-capture an existing snapshot. Server appends a new version and keeps
1954
+ * the same `snapshotUuid`. Returns the new version number on success.
1955
+ */
1956
+ async recaptureSnapshot(snapshotUuid, snapshot) {
1957
+ try {
1958
+ const response = await fetch(
1959
+ `${this.apiBase}/api/editor/snapshots/${encodeURIComponent(snapshotUuid)}`,
1960
+ {
1961
+ method: "PUT",
1962
+ headers: {
1963
+ "Content-Type": "application/json",
1964
+ "Authorization": `Bearer ${this.sessionToken}`
1965
+ },
1966
+ body: JSON.stringify({
1967
+ pageUrl: snapshot.pageUrl,
1968
+ title: snapshot.title,
1969
+ viewport: snapshot.viewport,
1970
+ textEntries: snapshot.textEntries,
1971
+ html: snapshot.html
1972
+ })
1973
+ }
1974
+ );
1975
+ if (response.status === 401) {
1976
+ this.handleSessionExpired();
1977
+ return null;
1978
+ }
1979
+ if (!response.ok) {
1980
+ throw new Error(`HTTP ${response.status}`);
1981
+ }
1982
+ const data = await response.json();
1983
+ return {
1984
+ snapshotUuid: data.snapshot_uuid || data.snapshotUuid,
1985
+ version: data.version || 1
1986
+ };
1987
+ } catch (err) {
1988
+ this.onStatus("error", `Re-capture failed: ${err.message}`);
1989
+ return null;
1990
+ }
1991
+ }
1992
+ /**
1993
+ * Fetch a single snapshot by UUID. Returns null on auth failure or 404.
1994
+ */
1995
+ async getSnapshot(snapshotUuid) {
1996
+ try {
1997
+ const response = await fetch(
1998
+ `${this.apiBase}/api/editor/snapshots/${encodeURIComponent(snapshotUuid)}`,
1999
+ {
2000
+ headers: { "Authorization": `Bearer ${this.sessionToken}` }
2001
+ }
2002
+ );
2003
+ if (response.status === 401) {
2004
+ this.handleSessionExpired();
2005
+ return null;
2006
+ }
2007
+ if (!response.ok) return null;
2008
+ return await response.json();
2009
+ } catch {
2010
+ return null;
2011
+ }
2012
+ }
2013
+ /**
2014
+ * List all snapshots the current editor session can see.
2015
+ */
2016
+ async listSnapshots() {
2017
+ try {
2018
+ const response = await fetch(`${this.apiBase}/api/editor/snapshots`, {
2019
+ headers: { "Authorization": `Bearer ${this.sessionToken}` }
2020
+ });
2021
+ if (response.status === 401) {
2022
+ this.handleSessionExpired();
2023
+ return [];
2024
+ }
2025
+ if (!response.ok) return [];
2026
+ const data = await response.json();
2027
+ return data.snapshots || [];
2028
+ } catch {
2029
+ return [];
2030
+ }
2031
+ }
2032
+ /**
2033
+ * Delete (soft) a snapshot by UUID.
2034
+ */
2035
+ async deleteSnapshot(snapshotUuid) {
2036
+ try {
2037
+ const response = await fetch(
2038
+ `${this.apiBase}/api/editor/snapshots/${encodeURIComponent(snapshotUuid)}`,
2039
+ {
2040
+ method: "DELETE",
2041
+ headers: { "Authorization": `Bearer ${this.sessionToken}` }
2042
+ }
2043
+ );
2044
+ if (response.status === 401) {
2045
+ this.handleSessionExpired();
2046
+ return false;
2047
+ }
2048
+ return response.ok;
2049
+ } catch {
2050
+ return false;
2051
+ }
2052
+ }
2053
+ /**
2054
+ * Workflow transitions (Phase 3). Per-key endpoints on the integrations API
2055
+ * that mirror the dashboard's row-level transitions but are addressable by
2056
+ * composite editor key (locale:namespace:auto_xxx).
2057
+ *
2058
+ * Returns the parsed JSON on success, or null on failure (caller usually
2059
+ * shows a toast / re-fetches status).
2060
+ */
2061
+ async transitionTarget(composite, action) {
2062
+ try {
2063
+ const response = await fetch(
2064
+ `${this.apiBase}/api/integrations/targets/${encodeURIComponent(composite)}/${action}`,
2065
+ {
2066
+ method: "POST",
2067
+ headers: { "Authorization": `Bearer ${this.sessionToken}` }
2068
+ }
2069
+ );
2070
+ if (response.status === 401) {
2071
+ this.handleSessionExpired();
2072
+ return null;
2073
+ }
2074
+ if (!response.ok) {
2075
+ this.onStatus("error", `${action} failed: HTTP ${response.status}`);
2076
+ return null;
2077
+ }
2078
+ return await response.json();
2079
+ } catch (err) {
2080
+ this.onStatus("error", `${action} failed: ${err.message}`);
2081
+ return null;
2082
+ }
2083
+ }
1726
2084
  };
1727
2085
 
2086
+ // src/browser/editor/pii-redactor.ts
2087
+ var REDACTED = "[REDACTED]";
2088
+ var EMAIL_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi;
2089
+ var PHONE_RE = /(?:\+\d{1,3}[\s.-]?)?(?:\(\d{3}\)|\d{3})[\s.-]\d{3}[\s.-]\d{4}/g;
2090
+ var SSN_RE = /\b\d{3}[\s-]\d{2}[\s-]\d{4}\b/g;
2091
+ var IPV4_RE = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g;
2092
+ var URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"']+/gi;
2093
+ var CC_CANDIDATE_RE = /\b\d(?:[ -]?\d){12,18}\b/g;
2094
+ function luhnValid(digits) {
2095
+ if (digits.length < 13 || digits.length > 19) return false;
2096
+ let sum = 0;
2097
+ let alt = false;
2098
+ for (let i = digits.length - 1; i >= 0; i--) {
2099
+ let n = digits.charCodeAt(i) - 48;
2100
+ if (n < 0 || n > 9) return false;
2101
+ if (alt) {
2102
+ n *= 2;
2103
+ if (n > 9) n -= 9;
2104
+ }
2105
+ sum += n;
2106
+ alt = !alt;
2107
+ }
2108
+ return sum % 10 === 0;
2109
+ }
2110
+ function ipv4Valid(match) {
2111
+ const parts = match.split(".");
2112
+ if (parts.length !== 4) return false;
2113
+ for (const p of parts) {
2114
+ const n = Number(p);
2115
+ if (!Number.isInteger(n) || n < 0 || n > 255) return false;
2116
+ }
2117
+ return true;
2118
+ }
2119
+ function redactString(text) {
2120
+ if (!text) return text;
2121
+ let out = text;
2122
+ out = out.replace(URL_RE, REDACTED);
2123
+ out = out.replace(EMAIL_RE, REDACTED);
2124
+ out = out.replace(PHONE_RE, REDACTED);
2125
+ out = out.replace(SSN_RE, REDACTED);
2126
+ out = out.replace(IPV4_RE, (m) => ipv4Valid(m) ? REDACTED : m);
2127
+ out = out.replace(CC_CANDIDATE_RE, (m) => {
2128
+ const digits = m.replace(/[\s-]/g, "");
2129
+ return luhnValid(digits) ? REDACTED : m;
2130
+ });
2131
+ return out;
2132
+ }
2133
+ var SKIP_TAGS3 = /* @__PURE__ */ new Set(["CODE", "PRE", "STYLE", "TEXTAREA"]);
2134
+ function redactTextNodes(root) {
2135
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
2136
+ const toUpdate = [];
2137
+ let node = walker.nextNode();
2138
+ while (node) {
2139
+ if (!isInSkippedSubtree(node)) {
2140
+ const original = node.nodeValue || "";
2141
+ const replaced = redactString(original);
2142
+ if (replaced !== original) {
2143
+ toUpdate.push({ node, value: replaced });
2144
+ }
2145
+ }
2146
+ node = walker.nextNode();
2147
+ }
2148
+ for (const { node: textNode, value } of toUpdate) {
2149
+ textNode.nodeValue = value;
2150
+ }
2151
+ }
2152
+ function isInSkippedSubtree(node) {
2153
+ let cur = node.parentNode;
2154
+ while (cur && cur.nodeType === Node.ELEMENT_NODE) {
2155
+ const el = cur;
2156
+ if (SKIP_TAGS3.has(el.tagName)) return true;
2157
+ if (el.hasAttribute("data-no-redact")) return true;
2158
+ cur = el.parentNode;
2159
+ }
2160
+ return false;
2161
+ }
2162
+
2163
+ // src/browser/editor/snapshot-capture.ts
2164
+ var REDACTED2 = "[REDACTED]";
2165
+ function captureSnapshot() {
2166
+ const clone = document.documentElement.cloneNode(true);
2167
+ const textEntries = extractTextEntries(clone);
2168
+ removeDangerousElements(clone);
2169
+ clearFormValues(clone);
2170
+ redactSensitiveElements(clone);
2171
+ stripInlineEventHandlers(clone);
2172
+ redactTextNodePII(clone);
2173
+ stripSensitiveMetaAndHeaders(clone);
2174
+ const html = "<!DOCTYPE html>\n" + clone.outerHTML;
2175
+ return {
2176
+ html,
2177
+ textEntries,
2178
+ pageUrl: window.location.href,
2179
+ title: document.title,
2180
+ viewport: {
2181
+ width: window.innerWidth,
2182
+ height: window.innerHeight,
2183
+ devicePixelRatio: window.devicePixelRatio || 1
2184
+ }
2185
+ };
2186
+ }
2187
+ function removeDangerousElements(root) {
2188
+ const selectors = [
2189
+ "script",
2190
+ "noscript",
2191
+ "iframe",
2192
+ "embed",
2193
+ "object",
2194
+ "#__lionrapid-editor",
2195
+ 'link[rel="prefetch"]',
2196
+ 'link[rel="preload"]',
2197
+ 'link[rel="modulepreload"]'
2198
+ ];
2199
+ for (const selector of selectors) {
2200
+ const matches = root.querySelectorAll(selector);
2201
+ for (let i = 0; i < matches.length; i++) {
2202
+ matches[i].remove();
2203
+ }
2204
+ }
2205
+ }
2206
+ function clearFormValues(root) {
2207
+ const inputs = root.querySelectorAll("input, textarea, select");
2208
+ for (let i = 0; i < inputs.length; i++) {
2209
+ const el = inputs[i];
2210
+ if ("value" in el) el.value = "";
2211
+ if (el.hasAttribute("value")) el.setAttribute("value", "");
2212
+ if (el.tagName === "TEXTAREA") el.textContent = "";
2213
+ if (el.tagName === "SELECT") {
2214
+ const options = el.querySelectorAll("option");
2215
+ for (let j = 0; j < options.length; j++) {
2216
+ options[j].selected = false;
2217
+ options[j].removeAttribute("selected");
2218
+ }
2219
+ }
2220
+ const attrs = Array.from(el.attributes);
2221
+ for (const attr of attrs) {
2222
+ if (attr.name.startsWith("data-value") || attr.name === "data-initial") {
2223
+ el.removeAttribute(attr.name);
2224
+ }
2225
+ }
2226
+ }
2227
+ }
2228
+ function redactSensitiveElements(root) {
2229
+ const sensitive = root.querySelectorAll("[data-pii], [data-sensitive]");
2230
+ for (let i = 0; i < sensitive.length; i++) {
2231
+ const el = sensitive[i];
2232
+ el.textContent = REDACTED2;
2233
+ const attrs = Array.from(el.attributes);
2234
+ for (const attr of attrs) {
2235
+ if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
2236
+ el.removeAttribute(attr.name);
2237
+ }
2238
+ }
2239
+ }
2240
+ }
2241
+ function stripInlineEventHandlers(root) {
2242
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
2243
+ let node = walker.nextNode();
2244
+ while (node) {
2245
+ const attrs = Array.from(node.attributes);
2246
+ for (const attr of attrs) {
2247
+ if (attr.name.toLowerCase().startsWith("on")) {
2248
+ node.removeAttribute(attr.name);
2249
+ }
2250
+ }
2251
+ node = walker.nextNode();
2252
+ }
2253
+ }
2254
+ function redactTextNodePII(root) {
2255
+ redactTextNodes(root);
2256
+ }
2257
+ function stripSensitiveMetaAndHeaders(root) {
2258
+ const sensitiveMetas = root.querySelectorAll(
2259
+ 'meta[name="csrf-token"], meta[name="csp-nonce"], meta[name="csrf-param"], meta[http-equiv="set-cookie"]'
2260
+ );
2261
+ for (let i = 0; i < sensitiveMetas.length; i++) {
2262
+ sensitiveMetas[i].remove();
2263
+ }
2264
+ const TOKEN_PARAMS = ["token", "auth", "access_token", "session", "sid", "key", "api_key", "apikey"];
2265
+ const urlElements = root.querySelectorAll("link[href], a[href], img[src]");
2266
+ for (let i = 0; i < urlElements.length; i++) {
2267
+ const el = urlElements[i];
2268
+ const attr = el.tagName === "IMG" ? "src" : "href";
2269
+ const raw = el.getAttribute(attr);
2270
+ if (!raw) continue;
2271
+ try {
2272
+ const url = new URL(raw, window.location.href);
2273
+ let changed = false;
2274
+ for (const param of TOKEN_PARAMS) {
2275
+ if (url.searchParams.has(param)) {
2276
+ url.searchParams.set(param, REDACTED2);
2277
+ changed = true;
2278
+ }
2279
+ }
2280
+ if (changed) el.setAttribute(attr, url.toString());
2281
+ } catch {
2282
+ }
2283
+ }
2284
+ }
2285
+ function extractTextEntries(clone) {
2286
+ const extractor = new BlockExtractor(
2287
+ { emails: true, urls: true, numbers: true, phones: true },
2288
+ [],
2289
+ 2
2290
+ );
2291
+ const blocks = extractor.extract(clone);
2292
+ return blocks.map((block, index) => {
2293
+ block.element.setAttribute("data-lr-snap-idx", String(index));
2294
+ return {
2295
+ key: block.key,
2296
+ tagName: block.element.tagName,
2297
+ blockIndex: index
2298
+ };
2299
+ });
2300
+ }
2301
+
2302
+ // src/browser/editor/snapshot-viewer.ts
2303
+ async function activateSnapshotViewer(snapshotUuid, api) {
2304
+ const snapshot = await api.getSnapshot(snapshotUuid);
2305
+ if (!snapshot) return null;
2306
+ const parser = new DOMParser();
2307
+ const doc = parser.parseFromString(snapshot.html, "text/html");
2308
+ const snapshotStyles = doc.head.querySelectorAll('link[rel="stylesheet"], style');
2309
+ for (let i = 0; i < snapshotStyles.length; i++) {
2310
+ const clone = snapshotStyles[i].cloneNode(true);
2311
+ clone.setAttribute("data-lr-snapshot-style", "");
2312
+ document.head.appendChild(clone);
2313
+ }
2314
+ if (snapshot.title) {
2315
+ document.title = `[Snapshot] ${snapshot.title}`;
2316
+ }
2317
+ const editorHost = document.getElementById("__lionrapid-editor");
2318
+ while (document.body.firstChild) {
2319
+ if (document.body.firstChild === editorHost) {
2320
+ const next = document.body.firstChild.nextSibling;
2321
+ if (!next) break;
2322
+ document.body.removeChild(next);
2323
+ } else {
2324
+ document.body.removeChild(document.body.firstChild);
2325
+ }
2326
+ }
2327
+ const root = document.createElement("div");
2328
+ root.id = "__lr_snapshot_root";
2329
+ root.setAttribute("data-lr-snapshot-uuid", snapshotUuid);
2330
+ root.innerHTML = doc.body.innerHTML;
2331
+ if (editorHost && editorHost.parentNode === document.body) {
2332
+ document.body.insertBefore(root, editorHost);
2333
+ } else {
2334
+ document.body.appendChild(root);
2335
+ }
2336
+ const keyMap = /* @__PURE__ */ new Map();
2337
+ const entries = snapshot.textEntries || [];
2338
+ for (const entry of entries) {
2339
+ keyMap.set(entry.blockIndex, entry.key);
2340
+ }
2341
+ return {
2342
+ root,
2343
+ pageUrl: snapshot.pageUrl,
2344
+ title: snapshot.title,
2345
+ keyMap
2346
+ };
2347
+ }
2348
+
1728
2349
  // src/browser/editor/editor-init.ts
1729
2350
  var SESSION_KEY = "__lr_editor_session";
1730
2351
  function getStoredSession() {
@@ -1755,6 +2376,17 @@ var LionRapidEditor = (() => {
1755
2376
  function clearSession() {
1756
2377
  sessionStorage.removeItem(SESSION_KEY);
1757
2378
  }
2379
+ function extractSessionRole(token) {
2380
+ try {
2381
+ const raw = token.startsWith("es_") ? token.slice(3) : token;
2382
+ const parts = raw.split(".");
2383
+ if (parts.length !== 3) return "";
2384
+ const payload = JSON.parse(atob(parts[1]));
2385
+ return typeof payload.role === "string" ? payload.role : "";
2386
+ } catch {
2387
+ return "";
2388
+ }
2389
+ }
1758
2390
  function showEditorNotice(message) {
1759
2391
  const el = document.createElement("div");
1760
2392
  el.setAttribute("data-no-translate", "");
@@ -1791,12 +2423,15 @@ var LionRapidEditor = (() => {
1791
2423
  setTimeout(() => el.remove(), 8e3);
1792
2424
  }
1793
2425
  function initEditor() {
1794
- const scriptEl = document.querySelector("script[data-grant]");
2426
+ const scriptEl = document.querySelector("script[data-grant]") || document.querySelector("script[data-lr-editor]");
1795
2427
  if (!scriptEl) {
1796
- console.warn("[LionRapid Editor] No script tag with data-grant found");
1797
2428
  return;
1798
2429
  }
1799
2430
  const grant = scriptEl.dataset.grant || "";
2431
+ const hasValidSession = getStoredSession() !== null;
2432
+ if (!grant && !hasValidSession) {
2433
+ return;
2434
+ }
1800
2435
  const locale = scriptEl.dataset.locale || "";
1801
2436
  const apiBase = scriptEl.dataset.api || "https://api.lionrapid.com";
1802
2437
  const projectId = scriptEl.dataset.project || "";
@@ -1850,6 +2485,8 @@ var LionRapidEditor = (() => {
1850
2485
  showEditorNotice("Editor session expired. Please reopen the editor from the dashboard.");
1851
2486
  return;
1852
2487
  }
2488
+ const role = extractSessionRole(sessionToken);
2489
+ const canCaptureSnapshots = role === "project_manager" || role === "admin";
1853
2490
  const host = document.createElement("div");
1854
2491
  host.id = "__lionrapid-editor";
1855
2492
  host.setAttribute("data-no-translate", "");
@@ -1862,12 +2499,26 @@ var LionRapidEditor = (() => {
1862
2499
  if (switcherEl) switcherEl.style.display = "none";
1863
2500
  const api = new EditorAPI(normalizedApiBase, sessionToken, projectId);
1864
2501
  api.startAutoFlush();
2502
+ const currentUrl = new URL(window.location.href);
2503
+ const snapshotUuid = currentUrl.searchParams.get("__lr_snapshot");
2504
+ const recaptureUuid = currentUrl.searchParams.get("__lr_recapture");
2505
+ let inspectorRoot;
2506
+ let snapshotKeyMap;
2507
+ if (snapshotUuid) {
2508
+ const result = await activateSnapshotViewer(snapshotUuid, api);
2509
+ if (!result) {
2510
+ showEditorNotice("Snapshot not found or expired. Please check the dashboard.");
2511
+ return;
2512
+ }
2513
+ inspectorRoot = result.root;
2514
+ snapshotKeyMap = result.keyMap;
2515
+ }
1865
2516
  const handleBlockSelect = (resolved) => {
1866
2517
  sidebar.open(resolved);
1867
2518
  inspector.clearHighlight();
1868
2519
  };
1869
- const sidebar = new Sidebar(shadow, api, currentLocale, namespace, defaultLocale, handleBlockSelect);
1870
- const inspector = new Inspector(shadow, handleBlockSelect);
2520
+ const sidebar = new Sidebar(shadow, api, currentLocale, namespace, defaultLocale, handleBlockSelect, role);
2521
+ const inspector = new Inspector(shadow, handleBlockSelect, inspectorRoot, snapshotKeyMap);
1871
2522
  const toolbar = new Toolbar(
1872
2523
  shadow,
1873
2524
  languages,
@@ -1898,7 +2549,43 @@ var LionRapidEditor = (() => {
1898
2549
  sidebar.destroy();
1899
2550
  host.remove();
1900
2551
  clearSession();
1901
- }
2552
+ },
2553
+ // Capture Snapshot — only available in live-page mode (not viewer) AND
2554
+ // only for PM/admin roles. Translators and proofreaders cannot capture;
2555
+ // the server enforces the same check on POST /api/editor/snapshots.
2556
+ //
2557
+ // If ?__lr_recapture={uuid} is present, the same button re-captures that
2558
+ // existing snapshot (PUT) instead of creating a new one (POST).
2559
+ snapshotUuid || !canCaptureSnapshots ? null : async () => {
2560
+ try {
2561
+ const snapshot = captureSnapshot();
2562
+ if (recaptureUuid) {
2563
+ const result = await api.recaptureSnapshot(recaptureUuid, snapshot);
2564
+ if (result) {
2565
+ showEditorNotice(
2566
+ `Re-captured as v${result.version} (${snapshot.textEntries.length} blocks). You can close this tab.`
2567
+ );
2568
+ } else {
2569
+ showEditorNotice("Re-capture failed. Please try again.");
2570
+ }
2571
+ } else {
2572
+ const result = await api.createSnapshot(snapshot);
2573
+ if (result) {
2574
+ showEditorNotice(
2575
+ `Snapshot captured (${snapshot.textEntries.length} blocks). Open it from the Snapshots tab in the dashboard.`
2576
+ );
2577
+ } else {
2578
+ showEditorNotice("Snapshot upload failed. Please try again.");
2579
+ }
2580
+ }
2581
+ } catch (err) {
2582
+ console.error("[LionRapid Editor] Snapshot capture error:", err);
2583
+ showEditorNotice("Snapshot capture failed. See console for details.");
2584
+ }
2585
+ },
2586
+ // When in recapture mode, surface it to the toolbar so the button label
2587
+ // can change from "Capture" to "Save re-capture".
2588
+ recaptureUuid ? { mode: "recapture" } : void 0
1902
2589
  );
1903
2590
  sidebar.setProgressCallback((translated, total) => {
1904
2591
  toolbar.updateProgress(translated, total);
@@ -1907,8 +2594,20 @@ var LionRapidEditor = (() => {
1907
2594
  toolbar.updateProgress(sidebar.getTranslatedCount(), sidebar.getTotalCount());
1908
2595
  });
1909
2596
  const url = new URL(window.location.href);
2597
+ let dirty = false;
1910
2598
  if (url.searchParams.has("__lr_editor")) {
1911
2599
  url.searchParams.delete("__lr_editor");
2600
+ dirty = true;
2601
+ }
2602
+ if (url.searchParams.has("__lr_snapshot")) {
2603
+ url.searchParams.delete("__lr_snapshot");
2604
+ dirty = true;
2605
+ }
2606
+ if (url.searchParams.has("__lr_recapture")) {
2607
+ url.searchParams.delete("__lr_recapture");
2608
+ dirty = true;
2609
+ }
2610
+ if (dirty) {
1912
2611
  history.replaceState(null, "", url.toString());
1913
2612
  }
1914
2613
  }