@raketa-cloud/admin 1.8.17 → 1.8.19

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 (59) hide show
  1. package/dist/application.css +288 -11
  2. package/package.json +8 -6
  3. package/src/handlers/resources/ReportsAPIHandler.js +27 -0
  4. package/src/handlers/resources/ResourcesHandler.js +16 -0
  5. package/src/helpers/content_tasks_helper.js +1 -1
  6. package/src/helpers/reports/cwv.js +217 -0
  7. package/src/helpers/reports/googleAuth.js +149 -0
  8. package/src/helpers/reports/quick-insights.js +294 -0
  9. package/src/helpers/reports/reportsUtils.js +181 -0
  10. package/src/index.js +8 -0
  11. package/src/localCache.js +112 -0
  12. package/src/report/analytics.js +107 -0
  13. package/src/report/reports.js +390 -0
  14. package/src/routeAssets.js +74 -0
  15. package/src/routeResourceModule.js +24 -0
  16. package/src/routeStandard.js +7 -1
  17. package/src/styles/application.css +1 -1
  18. package/src/tests/handlers/RedirectsHandler.test/notes.test.js +56 -0
  19. package/src/tests/handlers/_shared/SettingsHandler.test/global_variables.test.js +54 -0
  20. package/src/tests/handlers/categories/update_defaults.test.js +35 -0
  21. package/src/tests/handlers/content_tasks/ContentTasksHandler.test/editors.test.js +29 -0
  22. package/src/tests/handlers/resource_revisions/PublishesHandler.test/schedule.test.js +137 -0
  23. package/src/tests/handlers/resource_revisions/TagHandler.test/default_locale_tags.test.js +162 -0
  24. package/src/tests/handlers/resource_revisions/WidgetHandler.test/defaults.test.js +61 -0
  25. package/src/tests/handlers/resources/DeletesHandler.test/no_redirect.test.js +55 -0
  26. package/src/tests/handlers/resources/ReportsAPIHandler.test/show.test.js +156 -0
  27. package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +8 -0
  28. package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +94 -0
  29. package/src/tests/handlers/resources/TranslationsHandler.test/edge_cases.test.js +211 -0
  30. package/src/tests/helpers/CSVExportHelper.branches.test.js +57 -0
  31. package/src/tests/helpers/FormBuilder.branches.test.js +151 -0
  32. package/src/tests/helpers/HTMLBuilder.test.js +51 -0
  33. package/src/tests/helpers/content_tasks_helper.test.js +1 -1
  34. package/src/tests/helpers/historyUtils.branches.test.js +36 -0
  35. package/src/tests/helpers/reports/cwv.test.js +239 -0
  36. package/src/tests/helpers/reports/quick-insights.test.js +593 -0
  37. package/src/tests/helpers/reports/reportsUtils.test.js +425 -0
  38. package/src/tests/helpers/utils.test.js +37 -0
  39. package/src/tests/helpers/view_helpers.test.js +87 -0
  40. package/src/tests/index.test.js +74 -0
  41. package/src/tests/localCache.test.js +236 -0
  42. package/src/tests/report/analytics.test.js +230 -0
  43. package/src/tests/report/reports.test.js +831 -0
  44. package/src/tests/routeAssets.test.js +120 -0
  45. package/src/tests/routeResourceModule.test.js +99 -2
  46. package/src/tests/routeStandard.test.js +3 -3
  47. package/src/tests/support/fakeDom.js +164 -0
  48. package/src/views/content_tasks/index.ejs +2 -2
  49. package/src/views/resources/_shared/_core_web_vitals.ejs +27 -0
  50. package/src/views/resources/_shared/_quick_insights.ejs +64 -0
  51. package/src/views/resources/_shared/_scheduled_revisions.ejs +88 -0
  52. package/src/views/resources/_shared/_title.ejs +16 -12
  53. package/src/views/resources/_shared/_toolbar.ejs +11 -21
  54. package/src/views/resources/_shared/_traffic_chart.ejs +27 -0
  55. package/src/views/resources/_shared/_translations_strip.ejs +13 -25
  56. package/src/views/resources/_shared/_vital_meter.ejs +54 -0
  57. package/src/views/resources/index.ejs +1 -1
  58. package/src/views/resources/show.ejs +88 -98
  59. package/src/views/resources/translations/create_form.ejs +14 -5
@@ -0,0 +1,120 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import currentPath from "../currentPath.cjs";
5
+ import routeAssets from "../routeAssets";
6
+
7
+ function makeAdapter() {
8
+ const routes = {};
9
+
10
+ return {
11
+ routes,
12
+ addRoute: jest.fn((verb, path, handler) => {
13
+ routes[`${verb} ${path}`] = handler;
14
+ }),
15
+ };
16
+ }
17
+
18
+ // Minimal stand-in for Hono's context: records what the handler returns.
19
+ function makeCtx() {
20
+ return {
21
+ body: jest.fn((source, status, headers) => ({ source, status, headers })),
22
+ };
23
+ }
24
+
25
+ describe("routeAssets", () => {
26
+ test("registers the package's client scripts under /report", () => {
27
+ const adapter = makeAdapter();
28
+
29
+ routeAssets(adapter);
30
+
31
+ expect(Object.keys(adapter.routes)).toEqual([
32
+ "GET /report/analytics.js",
33
+ "GET /report/reports.js",
34
+ "GET /report/chart.js",
35
+ ]);
36
+ });
37
+
38
+ // A synchronous read per request blocks the event loop for a file that only
39
+ // changes while someone is editing it.
40
+ describe("reading the scripts", () => {
41
+ const fs = require("node:fs");
42
+ const env = process.env.RAKETA_ENV;
43
+
44
+ afterEach(() => {
45
+ jest.restoreAllMocks();
46
+ if (env === undefined) delete process.env.RAKETA_ENV;
47
+ else process.env.RAKETA_ENV = env;
48
+ });
49
+
50
+ const get = async (adapter, file) =>
51
+ adapter.routes[`GET /report/${file}`](makeCtx());
52
+
53
+ test("reads each script once outside development", async () => {
54
+ const read = jest.spyOn(fs, "readFileSync");
55
+ const adapter = makeAdapter();
56
+
57
+ routeAssets(adapter);
58
+ await get(adapter, "analytics.js");
59
+ await get(adapter, "analytics.js");
60
+ await get(adapter, "reports.js");
61
+
62
+ expect(read).toHaveBeenCalledTimes(2);
63
+ });
64
+
65
+ test("re-reads on every request in development", async () => {
66
+ process.env.RAKETA_ENV = "development";
67
+ const read = jest.spyOn(fs, "readFileSync");
68
+ const adapter = makeAdapter();
69
+
70
+ routeAssets(adapter);
71
+ await get(adapter, "analytics.js");
72
+ await get(adapter, "analytics.js");
73
+
74
+ expect(read).toHaveBeenCalledTimes(2);
75
+ });
76
+ });
77
+
78
+ // Served from the dependency rather than a CDN so a strict script-src CSP
79
+ // can't leave the traffic chart blank.
80
+ test("serves the chart.js UMD bundle from the package's dependency", async () => {
81
+ const adapter = makeAdapter();
82
+
83
+ routeAssets(adapter);
84
+
85
+ const ctx = makeCtx();
86
+ const result = await adapter.routes["GET /report/chart.js"](ctx);
87
+
88
+ expect(result.status).toBe(200);
89
+ expect(result.source).toContain("window.Chart");
90
+ expect(result.headers["content-type"]).toBe(
91
+ "application/javascript; charset=utf-8",
92
+ );
93
+ expect(result.headers["cache-control"]).toBe("public, max-age=3600");
94
+
95
+ // Held in memory after the first read — 200KB per request otherwise.
96
+ const again = await adapter.routes["GET /report/chart.js"](makeCtx());
97
+ expect(again.source).toBe(result.source);
98
+ });
99
+
100
+ test("serves each script from the package, not the host app", async () => {
101
+ const adapter = makeAdapter();
102
+
103
+ routeAssets(adapter);
104
+
105
+ for (const file of ["analytics.js", "reports.js"]) {
106
+ const ctx = makeCtx();
107
+ const result = await adapter.routes[`GET /report/${file}`](ctx);
108
+
109
+ const source = readFileSync(join(currentPath, "report", file), "utf8");
110
+
111
+ expect(result.source).toBe(source);
112
+ expect(result.status).toBe(200);
113
+ expect(result.headers["content-type"]).toBe(
114
+ "application/javascript; charset=utf-8",
115
+ );
116
+ // Read per request so `npm run dev` picks up edits without a restart.
117
+ expect(result.headers["cache-control"]).toBe("no-cache");
118
+ }
119
+ });
120
+ });
@@ -1,16 +1,19 @@
1
1
  import routeResourceModule from "../routeResourceModule";
2
2
 
3
- function makeAdapter() {
3
+ function makeAdapter(globalConfig = {}) {
4
4
  return {
5
5
  routeCollection: jest.fn(),
6
6
  routeNestedCollection: jest.fn(),
7
7
  routeNestedMember: jest.fn(),
8
+ globalConfig,
8
9
  };
9
10
  }
10
11
 
11
12
  describe("routeResourceModule", () => {
12
13
  test("registers exports collection with explicit mapping", () => {
13
- const adapter = makeAdapter();
14
+ const adapter = makeAdapter({
15
+ features: { reports: { article: true } },
16
+ });
14
17
 
15
18
  routeResourceModule(adapter, "article", "articles", {
16
19
  dateFields: ["publish_at"],
@@ -32,7 +35,101 @@ describe("routeResourceModule", () => {
32
35
  resourceType: "article",
33
36
  resourceTypePlural: "articles",
34
37
  dateFields: ["publish_at"],
38
+ reports: true,
39
+ });
40
+ });
41
+
42
+ test("skips report routes for modules with reports disabled", () => {
43
+ const adapter = makeAdapter();
44
+
45
+ routeResourceModule(adapter, "banner", "banners");
46
+
47
+ const paths = adapter.routeCollection.mock.calls.flatMap(([mapping]) =>
48
+ mapping.map(([, path]) => path),
49
+ );
50
+
51
+ expect(paths).not.toContain("/banners/:id/insights");
52
+ expect(paths).not.toContain("/banners/:id/performance");
53
+ });
54
+
55
+ test("no longer registers page routes for the reports", () => {
56
+ const adapter = makeAdapter({
57
+ features: { reports: { page: true } },
58
+ });
59
+
60
+ routeResourceModule(adapter, "page", "pages");
61
+
62
+ const paths = adapter.routeCollection.mock.calls.flatMap(([mapping]) =>
63
+ mapping.map(([, path]) => path),
64
+ );
65
+
66
+ // The Settings page reads the JSON feed instead; the standalone Insights
67
+ // and Performance pages are gone.
68
+ expect(paths).not.toContain("/pages/:id/insights");
69
+ expect(paths).not.toContain("/pages/:id/performance");
70
+ });
71
+
72
+ test("registers the reports JSON feed when enabled via the project-level features config", () => {
73
+ const adapter = makeAdapter({
74
+ features: { reports: { page: true } },
75
+ });
76
+
77
+ routeResourceModule(adapter, "page", "pages");
78
+
79
+ const feedCall = adapter.routeCollection.mock.calls.find(([mapping]) =>
80
+ mapping.some(([, path]) => path === "/report/pages/:id"),
81
+ );
82
+
83
+ expect(feedCall[0]).toEqual([
84
+ [
85
+ "GET",
86
+ "/report/pages/:id",
87
+ "report/pages",
88
+ "pages",
89
+ "reports",
90
+ expect.any(Function),
91
+ ],
92
+ ]);
93
+ });
94
+
95
+ test("skips the reports JSON feed when disabled via the project-level features config", () => {
96
+ const adapter = makeAdapter({
97
+ features: { reports: { banner: false } },
98
+ });
99
+
100
+ routeResourceModule(adapter, "banner", "banners");
101
+
102
+ const paths = adapter.routeCollection.mock.calls.flatMap(([mapping]) =>
103
+ mapping.map(([, path]) => path),
104
+ );
105
+
106
+ expect(paths).not.toContain("/report/banners/:id");
107
+ });
108
+
109
+ test("defaults reports to disabled when the resource is not listed in the features config", () => {
110
+ const adapter = makeAdapter({
111
+ features: { reports: { page: true } },
35
112
  });
113
+
114
+ routeResourceModule(adapter, "release-note", "release-notes");
115
+
116
+ const paths = adapter.routeCollection.mock.calls.flatMap(([mapping]) =>
117
+ mapping.map(([, path]) => path),
118
+ );
119
+
120
+ expect(paths).not.toContain("/report/release-notes/:id");
121
+ });
122
+
123
+ test("defaults reports to disabled when no features config is present at all", () => {
124
+ const adapter = makeAdapter();
125
+
126
+ routeResourceModule(adapter, "page", "pages");
127
+
128
+ const paths = adapter.routeCollection.mock.calls.flatMap(([mapping]) =>
129
+ mapping.map(([, path]) => path),
130
+ );
131
+
132
+ expect(paths).not.toContain("/report/pages/:id");
36
133
  });
37
134
 
38
135
  test("propagates resourceConfig with dateFields to all helpers", () => {
@@ -20,7 +20,7 @@ describe("routeStandard", () => {
20
20
  });
21
21
 
22
22
  test("registers dashboard mapping via adapter.routeCollection", () => {
23
- const adapter = { routeCollection: jest.fn() };
23
+ const adapter = { routeCollection: jest.fn(), addRoute: jest.fn() };
24
24
 
25
25
  routeStandard(adapter);
26
26
 
@@ -42,7 +42,7 @@ describe("routeStandard", () => {
42
42
  });
43
43
 
44
44
  test("registers redirects, tags, api/tags via routeCollection helper", () => {
45
- const adapter = { routeCollection: jest.fn() };
45
+ const adapter = { routeCollection: jest.fn(), addRoute: jest.fn() };
46
46
 
47
47
  routeStandard(adapter);
48
48
 
@@ -60,7 +60,7 @@ describe("routeStandard", () => {
60
60
  });
61
61
 
62
62
  test("delegates to routeSettings and registers page/article/event resources", () => {
63
- const adapter = { routeCollection: jest.fn() };
63
+ const adapter = { routeCollection: jest.fn(), addRoute: jest.fn() };
64
64
 
65
65
  routeStandard(adapter);
66
66
 
@@ -0,0 +1,164 @@
1
+ // Minimal DOM stand-in for the browser scripts in src/static.
2
+ //
3
+ // The package runs jest in the "node" environment (jsdom is not a dependency)
4
+ // and the static scripts only touch a small slice of the DOM: attribute
5
+ // selectors, classList, dataset, style.left, textContent and remove(). This
6
+ // implements exactly that slice.
7
+
8
+ const camelize = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
9
+
10
+ // Supports the selector forms used by the static scripts: a bare tag name and
11
+ // any number of [attr] / [attr="value"] tests, optionally combined.
12
+ function compileSelector(selector) {
13
+ const tests = [];
14
+
15
+ const tagName = selector
16
+ .replace(/\[([a-zA-Z-]+)(?:="([^"]*)")?\]/g, (_, name, value) => {
17
+ tests.push((el) =>
18
+ value === undefined
19
+ ? el.hasAttribute(name)
20
+ : el.getAttribute(name) === value,
21
+ );
22
+ return "";
23
+ })
24
+ .trim();
25
+
26
+ if (tagName) {
27
+ tests.push((el) => el.tagName === tagName.toLowerCase());
28
+ }
29
+
30
+ return (el) => tests.every((test) => test(el));
31
+ }
32
+
33
+ class FakeElement {
34
+ constructor(tagName, attributes = {}, children = []) {
35
+ this.tagName = tagName.toLowerCase();
36
+ this.attributes = { ...attributes };
37
+ this.children = [];
38
+ this.parentNode = null;
39
+ this.textContent = "";
40
+ this.style = {};
41
+ this.removed = false;
42
+ this._classes = new Set(
43
+ (attributes.class || "").split(/\s+/).filter(Boolean),
44
+ );
45
+
46
+ this.classList = {
47
+ add: (...names) => names.forEach((name) => this._classes.add(name)),
48
+ remove: (...names) => names.forEach((name) => this._classes.delete(name)),
49
+ contains: (name) => this._classes.has(name),
50
+ toString: () => this.className,
51
+ };
52
+
53
+ children.forEach((child) => this.append(child));
54
+ }
55
+
56
+ get className() {
57
+ return [...this._classes].join(" ");
58
+ }
59
+
60
+ set className(value) {
61
+ this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
62
+ }
63
+
64
+ get dataset() {
65
+ return Object.keys(this.attributes).reduce((acc, name) => {
66
+ if (name.startsWith("data-")) {
67
+ acc[camelize(name.slice(5))] = this.attributes[name];
68
+ }
69
+ return acc;
70
+ }, {});
71
+ }
72
+
73
+ append(child) {
74
+ child.parentNode = this;
75
+ this.children.push(child);
76
+ return child;
77
+ }
78
+
79
+ hasAttribute(name) {
80
+ return this.attributes[name] !== undefined;
81
+ }
82
+
83
+ getAttribute(name) {
84
+ return this.attributes[name] ?? null;
85
+ }
86
+
87
+ setAttribute(name, value) {
88
+ this.attributes[name] = String(value);
89
+ }
90
+
91
+ removeAttribute(name) {
92
+ delete this.attributes[name];
93
+ }
94
+
95
+ remove() {
96
+ this.removed = true;
97
+ if (!this.parentNode) return;
98
+ this.parentNode.children = this.parentNode.children.filter(
99
+ (child) => child !== this,
100
+ );
101
+ this.parentNode = null;
102
+ }
103
+
104
+ descendants() {
105
+ return this.children.flatMap((child) => [child, ...child.descendants()]);
106
+ }
107
+
108
+ querySelector(selector) {
109
+ return this.querySelectorAll(selector)[0] ?? null;
110
+ }
111
+
112
+ querySelectorAll(selector) {
113
+ const matches = compileSelector(selector);
114
+ return this.descendants().filter(matches);
115
+ }
116
+ }
117
+
118
+ export function el(tagName, attributes = {}, children = []) {
119
+ return new FakeElement(tagName, attributes, children);
120
+ }
121
+
122
+ // Root element plus the document/window pair the static scripts expect. The
123
+ // caller installs them on globalThis before requiring the script under test.
124
+ export function createDom(children = [], { readyState = "complete" } = {}) {
125
+ const root = el("body", {}, children);
126
+ const listeners = {};
127
+
128
+ const document = {
129
+ readyState,
130
+ body: root,
131
+ addEventListener: (type, callback) => {
132
+ listeners[type] = [...(listeners[type] || []), callback];
133
+ },
134
+ dispatchEvent: (type) => (listeners[type] || []).forEach((cb) => cb()),
135
+ getElementById: (id) => root.querySelector(`[id="${id}"]`),
136
+ querySelector: (selector) => root.querySelector(selector),
137
+ querySelectorAll: (selector) => root.querySelectorAll(selector),
138
+ };
139
+
140
+ const window = {};
141
+
142
+ return { root, document, window, listeners };
143
+ }
144
+
145
+ // The static scripts run their work on import, so each test needs a fresh
146
+ // module registry with the globals already in place.
147
+ export function installDom(dom) {
148
+ globalThis.document = dom.document;
149
+ globalThis.window = dom.window;
150
+ }
151
+
152
+ export function uninstallDom() {
153
+ delete globalThis.document;
154
+ delete globalThis.window;
155
+ }
156
+
157
+ // The static scripts start their work on import and never hand back a promise,
158
+ // so tests drain the queue instead of awaiting. Several ticks, because a single
159
+ // one is not enough for a fetch → json → render chain under load.
160
+ export const flushPromises = async (ticks = 10) => {
161
+ for (let i = 0; i < ticks; i++) {
162
+ await new Promise((resolve) => setImmediate(resolve));
163
+ }
164
+ };
@@ -1,6 +1,6 @@
1
1
  <div class="page-wrapper">
2
2
  <div class="page-title">
3
- <h1 class="heading-1">Content Tasks</h1>
3
+ <h1 class="heading-1">Translation Tasks</h1>
4
4
 
5
5
  <div class="actions-toolbar">
6
6
  <button type="button" data-toggle-sidebar="1" class="p-2.5 rounded hover:bg-neutral-200 transition-all duration-300 ease-in-out dark:hover:bg-neutral-700">
@@ -18,7 +18,7 @@
18
18
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-12">
19
19
  <path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />
20
20
  </svg>
21
- <h3 class="heading-3 mt-4">No content tasks</h3>
21
+ <h3 class="heading-3 mt-4">No translation tasks</h3>
22
22
  <p class="body-2 text-raketa-foreground/70 mt-2">
23
23
  <% if (Object.keys(_context.query).some(k => k.startsWith('f:'))) { %>
24
24
  No tasks match the selected filters. Try adjusting your filters.
@@ -0,0 +1,27 @@
1
+ <%
2
+ const cwvMetrics = locals.vitalsMeta || [];
3
+ %>
4
+
5
+ <div class="pb-8">
6
+ <h4 class="flex items-center gap-2 pb-4 heading-3">
7
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="invisible size-5 text-raketa-foreground/50" role="img" aria-label="Core Web Vitals Assessment" data-report="cwv-assessment">
8
+ <title>Core Web Vitals Assessment</title>
9
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" />
10
+ </svg>
11
+
12
+ Core Web Vitals
13
+
14
+ <span class="font-normal body-3 text-raketa-foreground/50" data-report="cwv-period">
15
+ <span data-loader class="inline-block border-2 rounded-full size-3 border-raketa-primary/20 border-t-raketa-primary animate-spin"></span>
16
+ </span>
17
+ </h4>
18
+
19
+ <div class="grid grid-cols-2 gap-6 transition-all md:grid-cols-3 lg:grid-cols-5 lg:gap-0 lg:divide-x lg:divide-neutral-200 dark:lg:divide-neutral-700" data-report="vitals-grid">
20
+ <% cwvMetrics.forEach((m) => { %>
21
+ <%- render_partial('./_shared/_vital_meter.ejs', {
22
+ metric: m,
23
+ render_partial,
24
+ }) %>
25
+ <% }); %>
26
+ </div>
27
+ </div>
@@ -0,0 +1,64 @@
1
+ <%
2
+ // Google Analytics KPI cards, rendered empty. Labels, icons and frames come
3
+ // from here; every value is written in by /report/reports.js once it fetches
4
+ // the JSON feed — see the data-report / data-metric hooks below.
5
+ const SPINNER =
6
+ '<span data-loader class="inline-block border-2 rounded-full size-3 border-raketa-primary/20 border-t-raketa-primary animate-spin"></span>';
7
+
8
+ const ICONS = {
9
+ eye: '<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>',
10
+ users: '<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/>',
11
+ clock: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>',
12
+ cursor: '<path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672Zm-7.518-.267A8.25 8.25 0 1 1 20.25 10.5M8.288 14.212A5.25 5.25 0 1 1 17.25 10.5"/>',
13
+ };
14
+
15
+ // `metric` keys match the summary keys reports.js reads from the feed.
16
+ const CARDS = [
17
+ { metric: 'views', label: 'Page Views', icon: ICONS.eye },
18
+ { metric: 'users', label: 'Unique Users', icon: ICONS.users },
19
+ { metric: 'avgDuration', label: 'Avg. Duration', icon: ICONS.clock },
20
+ { metric: 'engagement', label: 'Engagement', icon: ICONS.cursor },
21
+ ];
22
+ %>
23
+
24
+ <div class="pb-8">
25
+ <h4 class="flex items-center gap-2 pb-4 heading-3">
26
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4 text-raketa-primary">
27
+ <path fill-rule="evenodd" d="M14.615 1.595a.75.75 0 0 1 .359.852L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262l-10.5 11.25a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262l10.5-11.25a.75.75 0 0 1 .913-.143Z" clip-rule="evenodd" />
28
+ </svg>
29
+
30
+ Insights
31
+
32
+ <span class="font-normal body-3 text-raketa-foreground/50" data-report="range">
33
+ <%- SPINNER %>
34
+ </span>
35
+ </h4>
36
+
37
+ <div class="grid grid-cols-1 gap-4 transition-all md:grid-cols-2 lg:grid-cols-4" data-report="insights-grid">
38
+ <% CARDS.forEach((c) => { %>
39
+ <div class="p-5 border rounded-xl border-neutral-200 dark:border-neutral-700">
40
+ <div class="flex items-start justify-between">
41
+ <div>
42
+ <p class="text-[10px] font-bold uppercase text-raketa-foreground/50"><%= c.label %></p>
43
+
44
+ <h3 class="mt-1 text-xl font-semibold text-raketa-foreground" data-report="value" data-metric="<%= c.metric %>">
45
+ <span data-loader class="inline-block border-2 rounded-full size-5 border-raketa-primary/20 border-t-raketa-primary animate-spin"></span>
46
+ </h3>
47
+ </div>
48
+
49
+ <div class="text-raketa-primary">
50
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><%- c.icon %></svg>
51
+ </div>
52
+ </div>
53
+
54
+ <div class="flex items-center mt-3">
55
+ <span class="text-[11px] font-bold text-raketa-foreground/50" data-report="trend" data-metric="<%= c.metric %>">
56
+ <%- SPINNER %>
57
+ </span>
58
+
59
+ <span class="ml-1.5 text-[10px] text-raketa-foreground/50">vs prev. period</span>
60
+ </div>
61
+ </div>
62
+ <% }); %>
63
+ </div>
64
+ </div>
@@ -0,0 +1,88 @@
1
+ <%
2
+ // Revisions queued for publication. Expects `scheduledRevisions` (the
3
+ // revisions carrying a `schedule`) plus render_partial for the nested
4
+ // operator partial. Renders nothing when the queue is empty.
5
+ const queued = locals.scheduledRevisions || [];
6
+ %>
7
+
8
+ <% if (queued.length) { %>
9
+ <div class="pb-8">
10
+ <h4 class="flex items-center gap-2 pb-4 heading-3">
11
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4 text-raketa-primary">
12
+ <path fill-rule="evenodd" d="M5.625 1.5c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5H5.625ZM7.5 15a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 7.5 15Zm.75 2.25a.75.75 0 0 0 0 1.5H12a.75.75 0 0 0 0-1.5H8.25Z" clip-rule="evenodd" />
13
+ <path d="M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 1 3.434 1.279 9.768 9.768 0 0 0-6.963-6.963Z" />
14
+ </svg>
15
+
16
+ Scheduled for publication
17
+ </h4>
18
+
19
+ <table class="data-grid alt">
20
+ <thead>
21
+ <tr>
22
+ <th>Title</th>
23
+ <th>Created by</th>
24
+ <th>Scheduled by</th>
25
+ <th>Publish on</th>
26
+ <th>&nbsp;</th>
27
+ </tr>
28
+ </thead>
29
+
30
+ <tbody>
31
+ <% queued.forEach((revision) => { %>
32
+ <tr class="border-b border-mono-25">
33
+ <td class="w-1/2 py-4 pl-2">
34
+ <div class="flex items-center gap-1">
35
+ <%= revision.title %>
36
+ </div>
37
+ </td>
38
+
39
+ <td class="whitespace-nowrap">
40
+ <%- render_partial('./_shared/_revision_operator.ejs', { revision }) %>
41
+ </td>
42
+
43
+ <td class="whitespace-nowrap">
44
+ <div class="body-3 text-raketa-foreground/50">
45
+ <div class="flex items-center gap-1" title="<%= revision.schedule.operatorMeta.name %>">
46
+ <% if (revision.schedule.operatorMeta.avatar) { %>
47
+ <img src="<%= revision.schedule.operatorMeta.avatar %>" class="rounded-full size-4">
48
+ <% } %>
49
+ <%= revision.schedule.operatorMeta.name %>
50
+ </div>
51
+ </div>
52
+ </td>
53
+
54
+ <td class="whitespace-nowrap">
55
+ <div class="body-3 text-raketa-foreground/50">
56
+ <div class="flex items-center gap-1">
57
+ <svg xmlns="http://www.w3.org/2000/svg" class="size-4 text-raketa-foreground" viewBox="0 0 512 512">
58
+ <path d="M112.91 128A191.85 191.85 0 0064 254c-1.18 106.35 85.65 193.8 192 194 106.2.2 192-85.83 192-192 0-104.54-83.55-189.61-187.5-192a4.36 4.36 0 00-4.5 4.37V152" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" />
59
+ <path d="M233.38 278.63l-79-113a8.13 8.13 0 0111.32-11.32l113 79a32.5 32.5 0 01-37.25 53.26 33.21 33.21 0 01-8.07-7.94z" />
60
+ </svg>
61
+
62
+ <time data-pretty-expiration-time datetime="<%= revision.schedule.mergeAt %>"><%= revision.schedule.mergeAt %></time>
63
+ </div>
64
+ </div>
65
+ </td>
66
+
67
+ <td class="whitespace-nowrap">
68
+ <div class="justify-end actions-toolbar">
69
+ <form action="<%= `/${_context.locale}/${_context.resourceConfig.resourceType}_revisions/${revision.id}/cancel_schedules` %>" method="post" class="inline-flex items-center gap-2">
70
+ <%- f.submit('Remove schedule', { class: 'btn-text' }) %>
71
+ </form>
72
+
73
+ <a href="/remote/login?redirect_to=/<%= _context.locale %>/admin/<%= _context.resourceConfig.resourceType %>-revisions/<%= revision.id %>/preview" target="_blank" class="btn-text has-icon">
74
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
75
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
76
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
77
+ </svg>
78
+
79
+ Preview
80
+ </a>
81
+ </div>
82
+ </td>
83
+ </tr>
84
+ <% }); %>
85
+ </tbody>
86
+ </table>
87
+ </div>
88
+ <% } %>
@@ -1,14 +1,18 @@
1
- <div class="flex flex-col pb-4">
2
- <h1 class="heading-1 flex items-center gap-2">
3
- <%= resource.title %>
1
+ <div class="flex justify-between w-full pb-8">
2
+ <div class="flex flex-col">
3
+ <h1 class="flex items-center gap-2 heading-1">
4
+ <%= resource.title %>
5
+ </h1>
6
+ <a href="<%= _context.remoteConfig.resourceUrl(resource, _context.resourceConfig.resourceType, _context.locale) %>" target="_blank" class="btn-text"><%= _context.remoteConfig.resourceUrl(resource, _context.resourceConfig.resourceType, _context.locale) %></a>
7
+ </div>
4
8
 
5
- <% if (resource.visibility !== "private") { %>
6
- <a href="<%= _context.remoteConfig.resourceUrl(resource, _context.resourceConfig.resourceType, _context.locale) %>" target="_blank">
7
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 text-raketa-primary">
8
- <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
9
- </svg>
10
- </a>
11
- <% } %>
12
- </h1>
13
- <a href="<%= _context.remoteConfig.resourceUrl(resource, _context.resourceConfig.resourceType, _context.locale) %>" target="_blank" class="btn-text"><%= _context.remoteConfig.resourceUrl(resource, _context.resourceConfig.resourceType, _context.locale) %></a>
9
+ <%
10
+ const withTranslations = locals.withTranslations && locals.render_partial;
11
+ %>
12
+
13
+ <% if (withTranslations) { %>
14
+ <div class="pt-2">
15
+ <%- render_partial('./_shared/_translations_strip.ejs') %>
16
+ </div>
17
+ <% } %>
14
18
  </div>