@bridgeline-digital/hawk-app-cli 0.0.0-stage → 0.1.0-dev.20261020205204
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 +7 -0
- package/README.md +769 -2
- package/agents/AGENTS.md +192 -0
- package/agents/project/custom.md +21 -0
- package/agents/project/next-mui.md +21 -0
- package/agents/project/next.md +24 -0
- package/agents/project/vanilla.md +22 -0
- package/agents/project/vite.md +19 -0
- package/bin/hawk-app.mjs +10 -0
- package/docs/bridge/README.md +1315 -0
- package/docs/showcase/README.md +47 -0
- package/docs/showcase/functions/echo.ts +12 -0
- package/docs/showcase/functions/long-task.ts +31 -0
- package/docs/showcase/functions/showcase.ts +186 -0
- package/docs/showcase/hawk-app.json +73 -0
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -0
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -0
- package/docs/showcase/queries/searches-by-day.json +6 -0
- package/docs/showcase/queries/searches-by-day.sql +7 -0
- package/docs/showcase/src/App.tsx +1436 -0
- package/docs/showcase/src/main.tsx +10 -0
- package/docs/showcase/src/styles.css +71 -0
- package/docs/showcase/src/useHawkApp.ts +44 -0
- package/docs/showcase/src/vite-env.d.ts +1 -0
- package/docs/ui/README.md +166 -0
- package/package.json +50 -4
- package/schema/hawk-app.schema.json +611 -0
- package/src/accessibility.mjs +105 -0
- package/src/agents.mjs +145 -0
- package/src/aliases.mjs +30 -0
- package/src/app-icon.mjs +82 -0
- package/src/channel.mjs +116 -0
- package/src/cli.mjs +825 -0
- package/src/clients.mjs +221 -0
- package/src/config.mjs +148 -0
- package/src/create.mjs +186 -0
- package/src/credentials.mjs +56 -0
- package/src/data.mjs +169 -0
- package/src/deploy.mjs +349 -0
- package/src/dev.mjs +459 -0
- package/src/doctor.mjs +176 -0
- package/src/env.mjs +140 -0
- package/src/functions.mjs +313 -0
- package/src/login.mjs +166 -0
- package/src/logs.mjs +64 -0
- package/src/manifest.mjs +425 -0
- package/src/mcp-docs.mjs +168 -0
- package/src/mcp-hawksearch.mjs +342 -0
- package/src/mcp-reports.mjs +130 -0
- package/src/mcp-rules.mjs +109 -0
- package/src/mcp-ui.mjs +73 -0
- package/src/mcp.mjs +539 -0
- package/src/platform.mjs +116 -0
- package/src/project-checks.mjs +153 -0
- package/src/queries.mjs +289 -0
- package/src/scope-check.mjs +91 -0
- package/src/secrets.mjs +86 -0
- package/src/slug-guard.mjs +80 -0
- package/src/sso.mjs +182 -0
- package/src/store-listing.mjs +106 -0
- package/src/telemetry.mjs +50 -0
- package/src/template-history.json +34 -0
- package/src/upgrade.mjs +406 -0
- package/src/versions.mjs +102 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/package.json +26 -0
- package/templates/next/src/app/globals.css +5 -0
- package/templates/next/src/app/layout.tsx +14 -0
- package/templates/next/src/app/page.tsx +86 -0
- package/templates/next/src/hawk-elements.d.ts +10 -0
- package/templates/next/src/lib/useHawkApp.ts +46 -0
- package/templates/next/tsconfig.json +21 -0
- package/templates/next-mui/next.config.ts +13 -0
- package/templates/next-mui/package.json +31 -0
- package/templates/next-mui/src/app/layout.tsx +20 -0
- package/templates/next-mui/src/app/page.tsx +79 -0
- package/templates/next-mui/src/app/providers.tsx +11 -0
- package/templates/next-mui/tsconfig.json +21 -0
- package/templates/preact/index.html +12 -0
- package/templates/preact/package.json +26 -0
- package/templates/preact/src/App.tsx +83 -0
- package/templates/preact/src/hawk-elements.d.ts +10 -0
- package/templates/preact/src/main.tsx +8 -0
- package/templates/preact/src/styles.css +5 -0
- package/templates/preact/src/useHawkApp.ts +44 -0
- package/templates/preact/src/vite-env.d.ts +1 -0
- package/templates/preact/tsconfig.json +17 -0
- package/templates/preact/vite.config.ts +11 -0
- package/templates/react/index.html +12 -0
- package/templates/react/package.json +29 -0
- package/templates/react/src/App.tsx +83 -0
- package/templates/react/src/hawk-elements.d.ts +10 -0
- package/templates/react/src/main.tsx +13 -0
- package/templates/react/src/styles.css +5 -0
- package/templates/react/src/useHawkApp.ts +44 -0
- package/templates/react/src/vite-env.d.ts +1 -0
- package/templates/react/tsconfig.json +16 -0
- package/templates/react/vite.config.ts +11 -0
- package/templates/vanilla/package.json +15 -0
- package/templates/vanilla/public/app.js +59 -0
- package/templates/vanilla/public/index.html +37 -0
- package/templates/vanilla/public/styles.css +5 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { HawkApiError, type AnalyticsReportRow } from '@bridgeline-digital/hawk-app-bridge/client';
|
|
4
|
+
// The UI kit's elements (<hawk-kpi>, <hawk-column-chart>, …): defined in the browser, safe to import on the server.
|
|
5
|
+
import { createFormat } from '@bridgeline-digital/hawk-app-ui/elements';
|
|
6
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
7
|
+
import { useHawkApp } from '@/lib/useHawkApp';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Starter screen: the context from the host and one real data call, shown with the UI kit (Spark's look: the kit's
|
|
11
|
+
* stylesheet and its elements, <hawk-kpi> and <hawk-column-chart>). Replace it with your app. Data comes only through
|
|
12
|
+
* the bridge: no API keys in the app.
|
|
13
|
+
*/
|
|
14
|
+
export default function Page() {
|
|
15
|
+
const hawk = useHawkApp();
|
|
16
|
+
const [rows, setRows] = useState<AnalyticsReportRow<'searches'>[] | null>(null);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
const app = hawk.status === 'ready' ? hawk.app : undefined;
|
|
20
|
+
const context = hawk.status === 'ready' ? hawk.context : undefined;
|
|
21
|
+
const format = useMemo(() => createFormat(context?.locale), [context?.locale]);
|
|
22
|
+
|
|
23
|
+
// Reload whenever the user changes the date range or engine in the host.
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!app || !context) return;
|
|
26
|
+
let current = true;
|
|
27
|
+
setRows(null);
|
|
28
|
+
setError(null);
|
|
29
|
+
const filters = context.engine ? [{ member: 'engine_id' as const, operator: 'equals', values: [context.engine.id] }] : undefined;
|
|
30
|
+
app.analytics
|
|
31
|
+
.report('searches', { filters, limit: 1000 }) // dates default to the host's date range
|
|
32
|
+
.then((report) => current && setRows(report.data))
|
|
33
|
+
.catch((e: unknown) => current && setError(e instanceof HawkApiError ? `${e.message}${e.code ? ` (${e.code})` : ''}` : String(e)));
|
|
34
|
+
return () => {
|
|
35
|
+
current = false;
|
|
36
|
+
};
|
|
37
|
+
}, [app, context?.dateRange?.from, context?.dateRange?.to, context?.engine?.id]);
|
|
38
|
+
|
|
39
|
+
// One column per day (a day can have a row per engine).
|
|
40
|
+
const days = useMemo(() => {
|
|
41
|
+
const byDay = new Map<string, number>();
|
|
42
|
+
for (const row of rows ?? []) byDay.set(row.search_date, (byDay.get(row.search_date) ?? 0) + row.search_count);
|
|
43
|
+
return [...byDay].sort(([a], [b]) => a.localeCompare(b)).map(([date, value]) => ({ label: format.date(date, { year: false }), value }));
|
|
44
|
+
}, [rows, format]);
|
|
45
|
+
|
|
46
|
+
if (hawk.status === 'connecting') return <span className="hawk-spinner" aria-label="Connecting" />;
|
|
47
|
+
if (hawk.status === 'error') return <div className="hawk-notice is-error">{hawk.message}</div>;
|
|
48
|
+
|
|
49
|
+
const total = days.reduce((sum, d) => sum + d.value, 0);
|
|
50
|
+
const { tenant, engine, dateRange } = hawk.context;
|
|
51
|
+
return (
|
|
52
|
+
<main className="hawk-stack">
|
|
53
|
+
<div>
|
|
54
|
+
<h1>__NAME__</h1>
|
|
55
|
+
<p className="hawk-muted">
|
|
56
|
+
Client {tenant.clientId}
|
|
57
|
+
{engine ? ` · ${engine.name ?? `Engine ${engine.id}`}` : ''}
|
|
58
|
+
{dateRange ? ` · ${format.date(dateRange.from)} – ${format.date(dateRange.to)}` : ''}
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{error && <div className="hawk-notice is-error">{error}</div>}
|
|
63
|
+
|
|
64
|
+
<div className="hawk-kpi-row">
|
|
65
|
+
<hawk-kpi label="Searches" value={rows ? format.integer(total) : ''} loading={rows ? undefined : ''} highlight="" />
|
|
66
|
+
<hawk-kpi label="Searches per day" value={rows ? format.integer(days.length ? total / days.length : 0) : ''} loading={rows ? undefined : ''} hint={rows ? `over ${days.length} days` : undefined} />
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<section className="hawk-section">
|
|
70
|
+
<div className="hawk-section-header">
|
|
71
|
+
<div>
|
|
72
|
+
<h2 className="hawk-section-title">Searches per day</h2>
|
|
73
|
+
<p className="hawk-section-subtitle">In the host's date range. Hover a column for its number.</p>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
{rows ? <hawk-column-chart label="Searches per day" value-format="integer" columns={days} /> : <span className="hawk-spinner" />}
|
|
77
|
+
</section>
|
|
78
|
+
|
|
79
|
+
<div>
|
|
80
|
+
<button className="hawk-button" onClick={() => hawk.app.toast('Hello from __NAME__!', 'success')}>
|
|
81
|
+
Say hello
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</main>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The UI kit's custom elements in JSX (<hawk-kpi>, <hawk-data-table>, <hawk-bar-list>, <hawk-column-chart>).
|
|
2
|
+
import type { HawkElementTags } from '@bridgeline-digital/hawk-app-ui/elements';
|
|
3
|
+
|
|
4
|
+
declare module 'react' {
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
6
|
+
namespace JSX {
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
8
|
+
interface IntrinsicElements extends HawkElementTags {}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createApp, type HawkApp, type HawkAppContext } from '@bridgeline-digital/hawk-app-bridge/client';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
/** Shown when the app is opened directly instead of inside a host. */
|
|
7
|
+
function notEmbeddedMessage(): string {
|
|
8
|
+
return `Open this app from the Apps page in HawkSearch. Developing locally? Add ?devUrl=${window.location.href} to the app's address in HawkSearch.`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type HawkAppState =
|
|
12
|
+
| { status: 'connecting' }
|
|
13
|
+
| { status: 'ready'; app: HawkApp; context: HawkAppContext }
|
|
14
|
+
| { status: 'error'; message: string };
|
|
15
|
+
|
|
16
|
+
/** Connects to the host (the HawkSearch dashboard / Spark) once and keeps the context (client, engine, dates, theme) current. */
|
|
17
|
+
export function useHawkApp(): HawkAppState {
|
|
18
|
+
const [state, setState] = useState<HawkAppState>({ status: 'connecting' });
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let app: HawkApp | undefined;
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
|
|
24
|
+
createApp({ autoResize: true })
|
|
25
|
+
.then((connected) => {
|
|
26
|
+
if (cancelled) return connected.destroy();
|
|
27
|
+
app = connected;
|
|
28
|
+
setState({ status: 'ready', app: connected, context: connected.context });
|
|
29
|
+
connected.onContextChange((context) => setState({ status: 'ready', app: connected, context }));
|
|
30
|
+
})
|
|
31
|
+
.catch((error: { code?: string; message?: string }) => {
|
|
32
|
+
if (cancelled) return;
|
|
33
|
+
setState({
|
|
34
|
+
status: 'error',
|
|
35
|
+
message: error.code === 'not_embedded' ? notEmbeddedMessage() : `Could not connect to the host: ${error.message}`,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return () => {
|
|
40
|
+
cancelled = true;
|
|
41
|
+
app?.destroy();
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": false,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": { "@/*": ["./src/*"] }
|
|
18
|
+
},
|
|
19
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
20
|
+
"exclude": ["node_modules"]
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NextConfig } from 'next';
|
|
2
|
+
|
|
3
|
+
// Hawk Apps are static files on the apps CDN, one folder per deploy (/<slug>/v<n>/): static export, no server
|
|
4
|
+
// features (no SSR, API routes or middleware). All data comes through the bridge.
|
|
5
|
+
// `hawk-app deploy` builds with HAWK_APP_BASE_PATH=/<slug>/v<n>; local dev uses /<slug>.
|
|
6
|
+
const nextConfig: NextConfig = {
|
|
7
|
+
output: 'export',
|
|
8
|
+
basePath: process.env.HAWK_APP_BASE_PATH ?? '/__SLUG__',
|
|
9
|
+
trailingSlash: true,
|
|
10
|
+
images: { unoptimized: true },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default nextConfig;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__SLUG__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev -p 3100",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"app:dev": "hawk-app dev",
|
|
9
|
+
"app:deploy": "hawk-app deploy",
|
|
10
|
+
"app:audit": "npm audit --omit=dev --audit-level=high"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@bridgeline-digital/hawk-app-bridge": "^0.1.0",
|
|
14
|
+
"@bridgeline-digital/hawk-app-ui": "^0.1.0",
|
|
15
|
+
"@emotion/react": "^11.14.0",
|
|
16
|
+
"@emotion/styled": "^11.14.1",
|
|
17
|
+
"@mui/icons-material": "^9.5.0",
|
|
18
|
+
"@mui/material": "^9.5.0",
|
|
19
|
+
"@mui/material-nextjs": "^9.5.0",
|
|
20
|
+
"next": "^16.4.0",
|
|
21
|
+
"react": "^19.1.0",
|
|
22
|
+
"react-dom": "^19.1.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@bridgeline-digital/hawk-app-cli": "^__CLI_VERSION__",
|
|
26
|
+
"@types/node": "^20",
|
|
27
|
+
"@types/react": "^19.1.0",
|
|
28
|
+
"@types/react-dom": "^19.1.0",
|
|
29
|
+
"typescript": "^5.6.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Montserrat, Spark's font, bundled with the app (no request to Google Fonts at build or run time).
|
|
2
|
+
import '@bridgeline-digital/hawk-app-ui/fonts';
|
|
3
|
+
import { AppRouterCacheProvider } from '@mui/material-nextjs/v16-appRouter';
|
|
4
|
+
import type { Metadata } from 'next';
|
|
5
|
+
import Providers from './providers';
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = { title: '__NAME__' };
|
|
8
|
+
|
|
9
|
+
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
10
|
+
return (
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<body>
|
|
13
|
+
{/* Puts MUI's styles into the exported HTML, so the page doesn't flash unstyled. */}
|
|
14
|
+
<AppRouterCacheProvider>
|
|
15
|
+
<Providers>{children}</Providers>
|
|
16
|
+
</AppRouterCacheProvider>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { HawkApiError } from '@bridgeline-digital/hawk-app-bridge/client';
|
|
4
|
+
import { KpiRow, KpiTile, LoadingSection, Notice, Section, useFormat, useHawk, useToast } from '@bridgeline-digital/hawk-app-ui';
|
|
5
|
+
import { ColumnChart } from '@bridgeline-digital/hawk-app-ui/charts';
|
|
6
|
+
import Button from '@mui/material/Button';
|
|
7
|
+
import Stack from '@mui/material/Stack';
|
|
8
|
+
import Typography from '@mui/material/Typography';
|
|
9
|
+
import { useEffect, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
interface SearchesRow {
|
|
12
|
+
search_date: string;
|
|
13
|
+
search_count: number | string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Starter screen: the context from the host and one real data call, with the UI kit's components.
|
|
18
|
+
* Replace it with your app. Data comes only through the bridge: no API keys in the app.
|
|
19
|
+
*/
|
|
20
|
+
export default function Page() {
|
|
21
|
+
const { app, context } = useHawk();
|
|
22
|
+
const format = useFormat();
|
|
23
|
+
const toast = useToast();
|
|
24
|
+
const [rows, setRows] = useState<SearchesRow[] | null>(null);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
const [attempt, setAttempt] = useState(0);
|
|
27
|
+
|
|
28
|
+
// Reload whenever the user changes the date range or engine in the host.
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let current = true;
|
|
31
|
+
setRows(null);
|
|
32
|
+
setError(null);
|
|
33
|
+
const filters = context.engine ? [{ member: 'engine_id', operator: 'equals', values: [context.engine.id] }] : undefined;
|
|
34
|
+
app.analytics
|
|
35
|
+
.report<SearchesRow>('searches', { filters, limit: 1000 }) // dates default to the host's date range
|
|
36
|
+
.then((report) => current && setRows(report.data))
|
|
37
|
+
.catch((e: unknown) => current && setError(e instanceof HawkApiError ? `${e.message}${e.code ? ` (${e.code})` : ''}` : String(e)));
|
|
38
|
+
return () => {
|
|
39
|
+
current = false;
|
|
40
|
+
};
|
|
41
|
+
}, [app, context.dateRange?.from, context.dateRange?.to, context.engine?.id, attempt]);
|
|
42
|
+
|
|
43
|
+
const { tenant, engine, dateRange } = context;
|
|
44
|
+
const days = (rows ?? []).map((r) => ({ label: format.date(r.search_date.slice(0, 10), { year: false }), value: Number(r.search_count) || 0 }));
|
|
45
|
+
const total = days.reduce((sum, d) => sum + d.value, 0);
|
|
46
|
+
return (
|
|
47
|
+
<Stack component="main" spacing={2} sx={{ p: 2 }}>
|
|
48
|
+
{/* No page heading: the host already shows the app's name above the frame. */}
|
|
49
|
+
<Typography variant="body2" color="text.secondary">
|
|
50
|
+
Client {tenant.clientId}
|
|
51
|
+
{engine ? ` · ${engine.name ?? `Engine ${engine.id}`}` : ''}
|
|
52
|
+
{dateRange ? ` · ${format.date(dateRange.from.slice(0, 10))} – ${format.date(dateRange.to.slice(0, 10))}` : ''}
|
|
53
|
+
</Typography>
|
|
54
|
+
|
|
55
|
+
{error ? (
|
|
56
|
+
<Notice severity="error" onRetry={() => setAttempt((n) => n + 1)}>
|
|
57
|
+
{error}
|
|
58
|
+
</Notice>
|
|
59
|
+
) : !rows ? (
|
|
60
|
+
<LoadingSection title="Searches" height={160} />
|
|
61
|
+
) : (
|
|
62
|
+
<>
|
|
63
|
+
<KpiRow columns={3}>
|
|
64
|
+
<KpiTile highlight label="Searches" value={format.integer(total)} />
|
|
65
|
+
<KpiTile label="Days" value={format.integer(days.length)} />
|
|
66
|
+
<KpiTile label="Per day" value={format.integer(days.length ? total / days.length : 0)} />
|
|
67
|
+
</KpiRow>
|
|
68
|
+
<Section title="Searches per day">
|
|
69
|
+
<ColumnChart label="Searches per day" columns={days} format={format.integer} />
|
|
70
|
+
</Section>
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
<Button variant="contained" onClick={() => toast('Hello from __NAME__!', 'success')}>
|
|
75
|
+
Say hello
|
|
76
|
+
</Button>
|
|
77
|
+
</Stack>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { HawkAppProvider } from '@bridgeline-digital/hawk-app-ui';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Connects to the host (HawkSearch or Spark) and shows the app in Spark's theme, in the host's colors and mode. Until it
|
|
7
|
+
* has connected it shows a spinner, and outside a host it says how to open the app. Pages read the app with useHawk().
|
|
8
|
+
*/
|
|
9
|
+
export default function Providers({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
10
|
+
return <HawkAppProvider>{children}</HawkAppProvider>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": false,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": { "@/*": ["./src/*"] }
|
|
18
|
+
},
|
|
19
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
20
|
+
"exclude": ["node_modules"]
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>__NAME__</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__SLUG__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc --noEmit && vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"app:dev": "hawk-app dev",
|
|
11
|
+
"app:deploy": "hawk-app deploy",
|
|
12
|
+
"app:audit": "npm audit --omit=dev --audit-level=high"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@bridgeline-digital/hawk-app-bridge": "^0.1.0",
|
|
16
|
+
"@bridgeline-digital/hawk-app-ui": "^0.1.0",
|
|
17
|
+
"preact": "^11.0.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@bridgeline-digital/hawk-app-cli": "^__CLI_VERSION__",
|
|
21
|
+
"@types/node": "^20",
|
|
22
|
+
"@preact/preset-vite": "^2.10.6",
|
|
23
|
+
"typescript": "^5.6.0",
|
|
24
|
+
"vite": "^8.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { HawkApiError, type AnalyticsReportRow } from '@bridgeline-digital/hawk-app-bridge/client';
|
|
2
|
+
import { createFormat } from '@bridgeline-digital/hawk-app-ui/elements';
|
|
3
|
+
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
4
|
+
import { useHawkApp } from './useHawkApp';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Starter screen: the context from the host and one real data call, shown with the UI kit (Spark's look: the kit's
|
|
8
|
+
* stylesheet and its elements, <hawk-kpi> and <hawk-column-chart>). Replace it with your app. Data comes only through
|
|
9
|
+
* the bridge: no API keys in the app.
|
|
10
|
+
*/
|
|
11
|
+
export function App() {
|
|
12
|
+
const hawk = useHawkApp();
|
|
13
|
+
const [rows, setRows] = useState<AnalyticsReportRow<'searches'>[] | null>(null);
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
const app = hawk.status === 'ready' ? hawk.app : undefined;
|
|
17
|
+
const context = hawk.status === 'ready' ? hawk.context : undefined;
|
|
18
|
+
const format = useMemo(() => createFormat(context?.locale), [context?.locale]);
|
|
19
|
+
|
|
20
|
+
// Reload whenever the user changes the date range or engine in the host.
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!app || !context) return;
|
|
23
|
+
let current = true;
|
|
24
|
+
setRows(null);
|
|
25
|
+
setError(null);
|
|
26
|
+
const filters = context.engine ? [{ member: 'engine_id' as const, operator: 'equals', values: [context.engine.id] }] : undefined;
|
|
27
|
+
app.analytics
|
|
28
|
+
.report('searches', { filters, limit: 1000 }) // dates default to the host's date range
|
|
29
|
+
.then((report) => current && setRows(report.data))
|
|
30
|
+
.catch((e: unknown) => current && setError(e instanceof HawkApiError ? `${e.message}${e.code ? ` (${e.code})` : ''}` : String(e)));
|
|
31
|
+
return () => {
|
|
32
|
+
current = false;
|
|
33
|
+
};
|
|
34
|
+
}, [app, context?.dateRange?.from, context?.dateRange?.to, context?.engine?.id]);
|
|
35
|
+
|
|
36
|
+
// One column per day (a day can have a row per engine).
|
|
37
|
+
const days = useMemo(() => {
|
|
38
|
+
const byDay = new Map<string, number>();
|
|
39
|
+
for (const row of rows ?? []) byDay.set(row.search_date, (byDay.get(row.search_date) ?? 0) + row.search_count);
|
|
40
|
+
return [...byDay].sort(([a], [b]) => a.localeCompare(b)).map(([date, value]) => ({ label: format.date(date, { year: false }), value }));
|
|
41
|
+
}, [rows, format]);
|
|
42
|
+
|
|
43
|
+
if (hawk.status === 'connecting') return <span className="hawk-spinner" aria-label="Connecting" />;
|
|
44
|
+
if (hawk.status === 'error') return <div className="hawk-notice is-error">{hawk.message}</div>;
|
|
45
|
+
|
|
46
|
+
const total = days.reduce((sum, d) => sum + d.value, 0);
|
|
47
|
+
const { tenant, engine, dateRange } = hawk.context;
|
|
48
|
+
return (
|
|
49
|
+
<main className="hawk-stack">
|
|
50
|
+
<div>
|
|
51
|
+
<h1>__NAME__</h1>
|
|
52
|
+
<p className="hawk-muted">
|
|
53
|
+
Client {tenant.clientId}
|
|
54
|
+
{engine ? ` · ${engine.name ?? `Engine ${engine.id}`}` : ''}
|
|
55
|
+
{dateRange ? ` · ${format.date(dateRange.from)} – ${format.date(dateRange.to)}` : ''}
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{error && <div className="hawk-notice is-error">{error}</div>}
|
|
60
|
+
|
|
61
|
+
<div className="hawk-kpi-row">
|
|
62
|
+
<hawk-kpi label="Searches" value={rows ? format.integer(total) : ''} loading={rows ? undefined : ''} highlight="" />
|
|
63
|
+
<hawk-kpi label="Searches per day" value={rows ? format.integer(days.length ? total / days.length : 0) : ''} loading={rows ? undefined : ''} hint={rows ? `over ${days.length} days` : undefined} />
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<section className="hawk-section">
|
|
67
|
+
<div className="hawk-section-header">
|
|
68
|
+
<div>
|
|
69
|
+
<h2 className="hawk-section-title">Searches per day</h2>
|
|
70
|
+
<p className="hawk-section-subtitle">In the host's date range. Hover a column for its number.</p>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
{rows ? <hawk-column-chart label="Searches per day" value-format="integer" columns={days} /> : <span className="hawk-spinner" />}
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<div>
|
|
77
|
+
<button className="hawk-button" onClick={() => hawk.app.toast('Hello from __NAME__!', 'success')}>
|
|
78
|
+
Say hello
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</main>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The UI kit's custom elements in JSX (<hawk-kpi>, <hawk-data-table>, <hawk-bar-list>, <hawk-column-chart>).
|
|
2
|
+
import type { HawkElementTags } from '@bridgeline-digital/hawk-app-ui/elements';
|
|
3
|
+
|
|
4
|
+
declare module 'preact' {
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
6
|
+
namespace JSX {
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
8
|
+
interface IntrinsicElements extends HawkElementTags {}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { render } from 'preact';
|
|
2
|
+
// The UI kit: Spark's look (stylesheet, with Montserrat) and its elements (<hawk-kpi>, <hawk-data-table>, …).
|
|
3
|
+
import '@bridgeline-digital/hawk-app-ui/css';
|
|
4
|
+
import '@bridgeline-digital/hawk-app-ui/elements';
|
|
5
|
+
import { App } from './App';
|
|
6
|
+
import './styles.css';
|
|
7
|
+
|
|
8
|
+
render(<App />, document.getElementById('root')!);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Your app's own styles. Spark's look comes from the UI kit's stylesheet (imported in main.tsx): its hawk-* classes
|
|
3
|
+
* (hawk-section, hawk-button, hawk-notice, hawk-table, …) and --hawk-ui-* variables, which follow the host's colors
|
|
4
|
+
* and light or dark mode. Use those before adding your own.
|
|
5
|
+
*/
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createApp, type HawkApp, type HawkAppContext } from '@bridgeline-digital/hawk-app-bridge/client';
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
3
|
+
|
|
4
|
+
/** Shown when the app is opened directly instead of inside a host. */
|
|
5
|
+
function notEmbeddedMessage(): string {
|
|
6
|
+
return `Open this app from the Apps page in HawkSearch. Developing locally? Add ?devUrl=${window.location.href} to the app's address in HawkSearch.`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type HawkAppState =
|
|
10
|
+
| { status: 'connecting' }
|
|
11
|
+
| { status: 'ready'; app: HawkApp; context: HawkAppContext }
|
|
12
|
+
| { status: 'error'; message: string };
|
|
13
|
+
|
|
14
|
+
/** Connects to the host (the HawkSearch dashboard / Spark) once and keeps the context (client, engine, dates, theme) current. */
|
|
15
|
+
export function useHawkApp(): HawkAppState {
|
|
16
|
+
const [state, setState] = useState<HawkAppState>({ status: 'connecting' });
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let app: HawkApp | undefined;
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
|
|
22
|
+
createApp({ autoResize: true })
|
|
23
|
+
.then((connected) => {
|
|
24
|
+
if (cancelled) return connected.destroy();
|
|
25
|
+
app = connected;
|
|
26
|
+
setState({ status: 'ready', app: connected, context: connected.context });
|
|
27
|
+
connected.onContextChange((context) => setState({ status: 'ready', app: connected, context }));
|
|
28
|
+
})
|
|
29
|
+
.catch((error: { code?: string; message?: string }) => {
|
|
30
|
+
if (cancelled) return;
|
|
31
|
+
setState({
|
|
32
|
+
status: 'error',
|
|
33
|
+
message: error.code === 'not_embedded' ? notEmbeddedMessage() : `Could not connect to the host: ${error.message}`,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
cancelled = true;
|
|
39
|
+
app?.destroy();
|
|
40
|
+
};
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"jsxImportSource": "preact",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noUnusedLocals": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"types": ["vite/client", "node"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src", "vite.config.ts"]
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import preact from '@preact/preset-vite';
|
|
2
|
+
import { defineConfig } from 'vite';
|
|
3
|
+
|
|
4
|
+
// Each deploy is served from the apps CDN at /<slug>/v<n>/, so every asset URL needs that prefix.
|
|
5
|
+
// `hawk-app deploy` builds with HAWK_APP_BASE_PATH=/<slug>/v<n>; local dev uses /<slug>/.
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
base: `${process.env.HAWK_APP_BASE_PATH ?? '/__SLUG__'}/`,
|
|
8
|
+
plugins: [preact()],
|
|
9
|
+
// Port 3100: the dev platform accepts data calls from localhost:3100-3103 (HawkSearch dev mode, ?devUrl=).
|
|
10
|
+
server: { port: 3100, strictPort: true },
|
|
11
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>__NAME__</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__SLUG__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc --noEmit && vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"app:dev": "hawk-app dev",
|
|
11
|
+
"app:deploy": "hawk-app deploy",
|
|
12
|
+
"app:audit": "npm audit --omit=dev --audit-level=high"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@bridgeline-digital/hawk-app-bridge": "^0.1.0",
|
|
16
|
+
"@bridgeline-digital/hawk-app-ui": "^0.1.0",
|
|
17
|
+
"react": "^19.1.0",
|
|
18
|
+
"react-dom": "^19.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@bridgeline-digital/hawk-app-cli": "^__CLI_VERSION__",
|
|
22
|
+
"@types/node": "^20",
|
|
23
|
+
"@types/react": "^19.1.0",
|
|
24
|
+
"@types/react-dom": "^19.1.0",
|
|
25
|
+
"@vitejs/plugin-react": "^6.0.0",
|
|
26
|
+
"typescript": "^5.6.0",
|
|
27
|
+
"vite": "^8.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|