@conexus-x/next-view 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitignore +7 -0
- package/README.md +107 -0
- package/app/globals.css +92 -0
- package/app/layout.tsx +28 -0
- package/app/page.tsx +143 -0
- package/components/RecordRow.tsx +81 -0
- package/components/ThemeSync.tsx +19 -0
- package/conexus.manifest.json +42 -0
- package/next.config.ts +53 -0
- package/package.json +39 -0
- package/postcss.config.mjs +14 -0
- package/tsconfig.json +41 -0
package/.gitignore
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Conexus View
|
|
2
|
+
|
|
3
|
+
A starter **custom view** for Conexus X: Next.js 16 + React 19, wired to `@conexus-x/sdk`.
|
|
4
|
+
|
|
5
|
+
Copy this folder, rename it, and you have an app. It lists the records in whichever collection the person is looking at, completes them in place, and stays live while colleagues edit the same board.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev # http://localhost:5173
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Running it directly in a browser tab shows **"Not connected to Conexus X"** — that is correct. A view is a page that expects to be framed by the board, which is where its context and its data come from.
|
|
13
|
+
|
|
14
|
+
### Working against an unpublished SDK
|
|
15
|
+
|
|
16
|
+
`npm install` resolves `@conexus-x/sdk` from the registry. If you are developing
|
|
17
|
+
the SDK at the same time, link it instead of adding a path to `package.json` —
|
|
18
|
+
a `file:` dependency committed here would break for everyone else:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cd ../conexus-x-sdk && npm run build && npm link
|
|
22
|
+
cd - && npm link @conexus-x/sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`npm unlink @conexus-x/sdk && npm install` puts it back on the published version.
|
|
26
|
+
Nothing in this repo records the link, which is the point.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Tailwind is already set up
|
|
30
|
+
|
|
31
|
+
Tailwind v4 is installed and configured. There is no `tailwind.config.js` to
|
|
32
|
+
create, no CLI to run, no PostCSS file to write — v4 does not use one. Add
|
|
33
|
+
classes to your markup and they work.
|
|
34
|
+
|
|
35
|
+
The theme lives in `app/globals.css`, as a small set of semantic colours:
|
|
36
|
+
|
|
37
|
+
| Utility | Meaning |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `bg-bg` / `bg-card` | page and surface |
|
|
40
|
+
| `text-fg` / `text-muted` | body and secondary text |
|
|
41
|
+
| `border-line` | dividers and borders |
|
|
42
|
+
| `text-accent` / `bg-accent-soft` | the interactive colour |
|
|
43
|
+
| `text-danger` / `bg-danger-soft` | errors |
|
|
44
|
+
|
|
45
|
+
**These flip with the board's theme on their own**, so you almost never write
|
|
46
|
+
`dark:`. The values swap under `[data-theme="dark"]`, which `ThemeSync` sets
|
|
47
|
+
from the host — `bg-card text-fg` is already correct in both themes.
|
|
48
|
+
|
|
49
|
+
A `dark:` variant is wired up for the cases where a whole utility has to change
|
|
50
|
+
rather than a colour. It reads the host's `data-theme`, **not**
|
|
51
|
+
`prefers-color-scheme`: someone may run Conexus X in dark while their OS is in
|
|
52
|
+
light, and a view that follows the OS looks like a hole cut in the page.
|
|
53
|
+
|
|
54
|
+
To change a colour, edit the two blocks at the top of `app/globals.css`. To add one, add it
|
|
55
|
+
in both blocks and give it a name in `@theme inline`.
|
|
56
|
+
|
|
57
|
+
## What is where
|
|
58
|
+
|
|
59
|
+
| File | What it shows you |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| [app/page.tsx](app/page.tsx) | The whole view: connection state, context, a scoped read, host chrome |
|
|
62
|
+
| [components/RecordRow.tsx](components/RecordRow.tsx) | A scoped write, and why it does not update local state on success |
|
|
63
|
+
| [components/ThemeSync.tsx](components/ThemeSync.tsx) | Following the board theme without a flash of the wrong one |
|
|
64
|
+
| [conexus.manifest.json](conexus.manifest.json) | What the app declares — views, settings, scopes, and why it wants them |
|
|
65
|
+
| [next.config.ts](next.config.ts) | `frame-ancestors`, the header that decides whether this can be embedded at all |
|
|
66
|
+
|
|
67
|
+
## The whole SDK surface, in one screen
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
const { status, error } = useConnection(); // connecting | ready | error
|
|
71
|
+
const context = useViewContext(); // board, collection, user, theme, environment
|
|
72
|
+
const settings = useSettings<ViewSettings>(); // what the person configured for this placement
|
|
73
|
+
const canWrite = useScope("records:write"); // feature-detect, never assume
|
|
74
|
+
const commands = useCommands(); // notice, openRecord, confirm, navigate, copy
|
|
75
|
+
|
|
76
|
+
const { data, loading, error, refresh } = useRecords(context?.collectionId);
|
|
77
|
+
|
|
78
|
+
useAutoResize(); // grow the frame to fit
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`useRecords` refetches on its own when the board changes — a colleague editing a cell, an automation moving a row, your own write coming back. There is no socket in this app and no polling; the host forwards what belongs to this board and the hook redraws.
|
|
82
|
+
|
|
83
|
+
For anything the hooks do not cover:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
const cx = useConexus();
|
|
87
|
+
await cx.api.values.forRecord(recordId);
|
|
88
|
+
await cx.request({ method: "GET", path: "/columns/" + context.moduleId });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Notice what is missing
|
|
92
|
+
|
|
93
|
+
No `fetch` of the CRM API. No base URL. No token. No socket. The host owns all four and proxies every call with the signed-in user's own credentials, gated by the scopes your manifest asked for and an admin approved. That is why this app is a few hundred lines.
|
|
94
|
+
|
|
95
|
+
## Deploying
|
|
96
|
+
|
|
97
|
+
Any static or Node host works. On Vercel:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
vercel --prod
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then set `entry` in `conexus.manifest.json` to the deployed URL, and set `CONEXUS_ORIGINS` on the deployment to the Conexus X origins allowed to frame you (comma-separated). Getting `frame-ancestors` wrong is the most common reason a working view renders as a blank rectangle.
|
|
104
|
+
|
|
105
|
+
## Settings
|
|
106
|
+
|
|
107
|
+
Fields declared in the manifest are rendered by the host, stored per placement, and delivered to `useSettings()`. Give every field a default in your code as well — a view mounted before anyone opened its settings pane receives an empty object.
|
package/app/globals.css
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TAILWIND IS ALREADY SET UP. There is nothing for you to configure.
|
|
5
|
+
*
|
|
6
|
+
* Tailwind v4 has no `tailwind.config.js` — the theme lives here, in CSS. The
|
|
7
|
+
* only other piece is postcss.config.mjs, which is three lines and already
|
|
8
|
+
* written. Add classes to your markup and they work.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DARK MODE FOLLOWS THE BOARD, NOT THE OPERATING SYSTEM.
|
|
13
|
+
*
|
|
14
|
+
* Tailwind's stock `dark:` variant reads `prefers-color-scheme`, which is the
|
|
15
|
+
* wrong signal here: the person may run Conexus X in dark while their OS is in
|
|
16
|
+
* light, and a view that follows the OS looks like a hole cut in the page. The
|
|
17
|
+
* host tells us its theme and ThemeSync writes it to `<html data-theme>`, so
|
|
18
|
+
* the variant reads that instead.
|
|
19
|
+
*
|
|
20
|
+
* You rarely need `dark:` at all — the semantic colours below already flip.
|
|
21
|
+
* It is here for the cases where a whole utility has to change, not a colour.
|
|
22
|
+
*/
|
|
23
|
+
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The palette, as two sets of the same names.
|
|
27
|
+
*
|
|
28
|
+
* Swapping the VALUES under `[data-theme]` rather than writing `dark:` on every
|
|
29
|
+
* element is what keeps the markup readable: `bg-card text-fg` is correct in
|
|
30
|
+
* both themes and there is one place to change a colour.
|
|
31
|
+
*/
|
|
32
|
+
@layer base {
|
|
33
|
+
:root {
|
|
34
|
+
--cx-bg: #ffffff;
|
|
35
|
+
--cx-card: #ffffff;
|
|
36
|
+
--cx-fg: #18181b;
|
|
37
|
+
--cx-muted: #71717a;
|
|
38
|
+
--cx-line: #e4e4e7;
|
|
39
|
+
--cx-accent: #2563eb;
|
|
40
|
+
--cx-accent-soft: #eff6ff;
|
|
41
|
+
--cx-danger: #b91c1c;
|
|
42
|
+
--cx-danger-soft: #fef2f2;
|
|
43
|
+
|
|
44
|
+
color-scheme: light;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
[data-theme="dark"] {
|
|
48
|
+
--cx-bg: #18181b;
|
|
49
|
+
--cx-card: #1f1f23;
|
|
50
|
+
--cx-fg: #fafafa;
|
|
51
|
+
--cx-muted: #a1a1aa;
|
|
52
|
+
--cx-line: #3f3f46;
|
|
53
|
+
--cx-accent: #60a5fa;
|
|
54
|
+
--cx-accent-soft: #1e293b;
|
|
55
|
+
--cx-danger: #fca5a5;
|
|
56
|
+
--cx-danger-soft: #450a0a;
|
|
57
|
+
|
|
58
|
+
color-scheme: dark;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
body {
|
|
62
|
+
background-color: var(--cx-bg);
|
|
63
|
+
color: var(--cx-fg);
|
|
64
|
+
font-family: var(--font-sans);
|
|
65
|
+
-webkit-font-smoothing: antialiased;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `inline` matters.
|
|
71
|
+
*
|
|
72
|
+
* It makes each utility emit `var(--cx-…)` directly instead of copying the
|
|
73
|
+
* value into a second variable. That is what lets the theme flip at runtime:
|
|
74
|
+
* the utility resolves the variable at paint time, under whatever `data-theme`
|
|
75
|
+
* is on the element.
|
|
76
|
+
*
|
|
77
|
+
* Every name here becomes a full set of utilities — `bg-card`, `text-muted`,
|
|
78
|
+
* `border-line`, `ring-accent`, and so on.
|
|
79
|
+
*/
|
|
80
|
+
@theme inline {
|
|
81
|
+
--color-bg: var(--cx-bg);
|
|
82
|
+
--color-card: var(--cx-card);
|
|
83
|
+
--color-fg: var(--cx-fg);
|
|
84
|
+
--color-muted: var(--cx-muted);
|
|
85
|
+
--color-line: var(--cx-line);
|
|
86
|
+
--color-accent: var(--cx-accent);
|
|
87
|
+
--color-accent-soft: var(--cx-accent-soft);
|
|
88
|
+
--color-danger: var(--cx-danger);
|
|
89
|
+
--color-danger-soft: var(--cx-danger-soft);
|
|
90
|
+
|
|
91
|
+
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
92
|
+
}
|
package/app/layout.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import "./globals.css";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: "Conexus View",
|
|
8
|
+
description: "A custom view running inside a Conexus X board."
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* No fonts, no analytics, no chrome.
|
|
13
|
+
*
|
|
14
|
+
* This document is drawn INSIDE someone board, a few hundred pixels tall,
|
|
15
|
+
* behind whatever the customer is actually working on. Every kilobyte here is
|
|
16
|
+
* paid for by a person who did not ask for it, and anything that looks like
|
|
17
|
+
* page furniture reads as a bug in the host.
|
|
18
|
+
*/
|
|
19
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
20
|
+
return (
|
|
21
|
+
<html lang="en" suppressHydrationWarning>
|
|
22
|
+
{/* The theme is applied client-side from the host context — the
|
|
23
|
+
server cannot know it, and guessing produces a flash of the
|
|
24
|
+
wrong one on every mount. */}
|
|
25
|
+
<body>{children}</body>
|
|
26
|
+
</html>
|
|
27
|
+
);
|
|
28
|
+
}
|
package/app/page.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The starter view.
|
|
5
|
+
*
|
|
6
|
+
* Lists the records in whichever collection the person is looking at, lets them
|
|
7
|
+
* tick one complete, and stays live while colleagues edit the same board. It is
|
|
8
|
+
* the smallest thing that exercises every part of the SDK worth knowing:
|
|
9
|
+
* context, a scoped read, a scoped write, host chrome, and realtime.
|
|
10
|
+
*
|
|
11
|
+
* Note what is NOT here: no fetch, no API base URL, no token, no socket. The
|
|
12
|
+
* host owns all four, which is why this file is short.
|
|
13
|
+
*
|
|
14
|
+
* Styling is Tailwind, already configured — see app/globals.css. The colour
|
|
15
|
+
* names (`bg-card`, `text-muted`, `border-line`) flip with the board's theme on
|
|
16
|
+
* their own, so almost nothing here needs a `dark:` variant.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
useAutoResize,
|
|
21
|
+
useCommands,
|
|
22
|
+
useConnection,
|
|
23
|
+
useRecords,
|
|
24
|
+
useScope,
|
|
25
|
+
useSettings,
|
|
26
|
+
useViewContext
|
|
27
|
+
} from "@conexus-x/sdk/react";
|
|
28
|
+
|
|
29
|
+
import { RecordRow } from "@/components/RecordRow";
|
|
30
|
+
import { ThemeSync } from "@/components/ThemeSync";
|
|
31
|
+
|
|
32
|
+
interface ViewSettings {
|
|
33
|
+
title?: string;
|
|
34
|
+
showCompleted?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function View() {
|
|
38
|
+
const { status, error } = useConnection();
|
|
39
|
+
const context = useViewContext();
|
|
40
|
+
const commands = useCommands();
|
|
41
|
+
const canWrite = useScope("records:write");
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What the person configured for THIS placement of the view.
|
|
45
|
+
*
|
|
46
|
+
* The manifest declares the fields, the host renders the pane and stores
|
|
47
|
+
* the answers, and this type is the contract between the two — so a default
|
|
48
|
+
* belongs here as well, because a view mounted before anyone opened its
|
|
49
|
+
* settings receives an empty object.
|
|
50
|
+
*/
|
|
51
|
+
const settings = useSettings<ViewSettings>();
|
|
52
|
+
const title = settings.title ?? "Records";
|
|
53
|
+
const showCompleted = settings.showCompleted ?? true;
|
|
54
|
+
|
|
55
|
+
const {
|
|
56
|
+
data: records,
|
|
57
|
+
loading,
|
|
58
|
+
error: recordsError,
|
|
59
|
+
refresh
|
|
60
|
+
} = useRecords(context?.collectionId);
|
|
61
|
+
|
|
62
|
+
// Grow the frame to fit the content. The host resizes the slot it gave us.
|
|
63
|
+
useAutoResize();
|
|
64
|
+
|
|
65
|
+
if (status === "connecting") {
|
|
66
|
+
return (
|
|
67
|
+
<main className="p-4">
|
|
68
|
+
<div className="mb-3 h-3.5 w-2/5 animate-pulse rounded bg-line" />
|
|
69
|
+
<div className="mb-2 h-3.5 w-full animate-pulse rounded bg-line" />
|
|
70
|
+
<div className="h-3.5 w-4/5 animate-pulse rounded bg-line" />
|
|
71
|
+
</main>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (status === "error") {
|
|
76
|
+
return (
|
|
77
|
+
<main className="p-4">
|
|
78
|
+
<div className="rounded-lg border border-danger bg-danger-soft px-3.5 py-3 text-danger">
|
|
79
|
+
<strong className="font-semibold">Not connected to Conexus X.</strong>
|
|
80
|
+
<p className="mt-1.5">
|
|
81
|
+
{error?.message ??
|
|
82
|
+
"This page expects to run inside a Conexus X board."}
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
</main>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<main className="p-4">
|
|
91
|
+
<ThemeSync theme={context?.theme ?? "light"} />
|
|
92
|
+
|
|
93
|
+
<header className="mb-3 flex items-baseline justify-between gap-3">
|
|
94
|
+
<h1 className="text-[15px] font-semibold">{title}</h1>
|
|
95
|
+
<span className="text-[13px] text-muted">
|
|
96
|
+
{context?.environment === "test" ? "Test environment" : null}
|
|
97
|
+
</span>
|
|
98
|
+
</header>
|
|
99
|
+
|
|
100
|
+
{!context?.collectionId ? (
|
|
101
|
+
<p className="text-[13px] text-muted">Open a collection to see its records.</p>
|
|
102
|
+
) : recordsError ? (
|
|
103
|
+
<div className="rounded-lg border border-danger bg-danger-soft px-3.5 py-3 text-danger">
|
|
104
|
+
{recordsError.code === "scope_denied"
|
|
105
|
+
? "This app was not granted permission to read records."
|
|
106
|
+
: recordsError.message}
|
|
107
|
+
</div>
|
|
108
|
+
) : loading && !records ? (
|
|
109
|
+
<div className="h-3.5 w-full animate-pulse rounded bg-line" />
|
|
110
|
+
) : (
|
|
111
|
+
<ul>
|
|
112
|
+
{(records ?? [])
|
|
113
|
+
.filter((record) => showCompleted || !record.isCompleted)
|
|
114
|
+
.map((record) => (
|
|
115
|
+
<RecordRow
|
|
116
|
+
key={record._id}
|
|
117
|
+
record={record}
|
|
118
|
+
canWrite={canWrite}
|
|
119
|
+
onOpen={() =>
|
|
120
|
+
commands
|
|
121
|
+
.openRecord(record._id)
|
|
122
|
+
.catch(() =>
|
|
123
|
+
commands.notice(
|
|
124
|
+
"This board cannot open records from a view.",
|
|
125
|
+
"info"
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
onError={(message) => {
|
|
130
|
+
void commands.notice(message, "error");
|
|
131
|
+
refresh();
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
))}
|
|
135
|
+
</ul>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{records?.length === 0 && context?.collectionId ? (
|
|
139
|
+
<p className="text-[13px] text-muted">No records in this collection yet.</p>
|
|
140
|
+
) : null}
|
|
141
|
+
</main>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { useConexus, type CxRecord } from "@conexus-x/sdk/react";
|
|
6
|
+
|
|
7
|
+
interface RecordRowProps {
|
|
8
|
+
record: CxRecord;
|
|
9
|
+
canWrite: boolean;
|
|
10
|
+
onOpen: () => void;
|
|
11
|
+
onError: (message: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Shared button look. Extracted because two buttons is where copies start. */
|
|
15
|
+
const button =
|
|
16
|
+
"rounded-md border border-line px-2.5 py-[3px] text-[13px] text-accent " +
|
|
17
|
+
"transition-colors hover:bg-accent-soft disabled:cursor-not-allowed " +
|
|
18
|
+
"disabled:opacity-50 disabled:hover:bg-transparent";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One row.
|
|
22
|
+
*
|
|
23
|
+
* The complete/reopen button does NOT update local state on success. The write
|
|
24
|
+
* comes back as a realtime change, `useRecords` refetches, and the row redraws
|
|
25
|
+
* from the server — which is the same path a colleague edit takes. Keeping one
|
|
26
|
+
* path means the view cannot end up showing a local guess that the board never
|
|
27
|
+
* accepted.
|
|
28
|
+
*/
|
|
29
|
+
export function RecordRow({ record, canWrite, onOpen, onError }: RecordRowProps) {
|
|
30
|
+
const cx = useConexus();
|
|
31
|
+
const [saving, setSaving] = useState(false);
|
|
32
|
+
|
|
33
|
+
const toggle = async () => {
|
|
34
|
+
setSaving(true);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await cx.api.records.update(record._id, {
|
|
38
|
+
isCompleted: !record.isCompleted
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
onError(
|
|
42
|
+
error instanceof Error
|
|
43
|
+
? error.message
|
|
44
|
+
: "Could not update that record."
|
|
45
|
+
);
|
|
46
|
+
} finally {
|
|
47
|
+
setSaving(false);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<li className="flex items-center gap-2.5 border-b border-line py-2 last:border-b-0">
|
|
53
|
+
<span
|
|
54
|
+
className={
|
|
55
|
+
record.isCompleted
|
|
56
|
+
? "min-w-0 flex-1 truncate text-muted line-through"
|
|
57
|
+
: "min-w-0 flex-1 truncate"
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
{record.name}
|
|
61
|
+
</span>
|
|
62
|
+
|
|
63
|
+
<button type="button" onClick={onOpen} className={button}>
|
|
64
|
+
Open
|
|
65
|
+
</button>
|
|
66
|
+
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={toggle}
|
|
70
|
+
// Disabled rather than hidden when the scope is missing: a
|
|
71
|
+
// button that vanishes reads as a bug, one that is greyed out
|
|
72
|
+
// reads as a permission.
|
|
73
|
+
disabled={!canWrite || saving}
|
|
74
|
+
title={canWrite ? undefined : "This app cannot change records."}
|
|
75
|
+
className={button}
|
|
76
|
+
>
|
|
77
|
+
{saving ? "Saving…" : record.isCompleted ? "Reopen" : "Complete"}
|
|
78
|
+
</button>
|
|
79
|
+
</li>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mirror the host theme onto <html data-theme>, where globals.css reads it.
|
|
7
|
+
*
|
|
8
|
+
* Written to the DOM in an effect rather than rendered as an attribute because
|
|
9
|
+
* the server has no idea which theme the board is in — rendering a guess and
|
|
10
|
+
* correcting it on hydration is a visible flash of the wrong colours on every
|
|
11
|
+
* single mount, and this view remounts whenever the person opens the tab.
|
|
12
|
+
*/
|
|
13
|
+
export function ThemeSync({ theme }: { theme: "light" | "dark" }) {
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
document.documentElement.dataset.theme = theme;
|
|
16
|
+
}, [theme]);
|
|
17
|
+
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "conexus-next-view",
|
|
3
|
+
"name": "Conexus Next View",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Starter custom view (Next.js) — lists the records in the current collection and completes them in place.",
|
|
6
|
+
"publisher": {
|
|
7
|
+
"name": "Conexus X",
|
|
8
|
+
"email": "apps@conexus-x.example",
|
|
9
|
+
"website": "https://conexus-x.example"
|
|
10
|
+
},
|
|
11
|
+
"entry": "https://conexus-next-view.vercel.app",
|
|
12
|
+
"views": [
|
|
13
|
+
{
|
|
14
|
+
"id": "records",
|
|
15
|
+
"name": "Records",
|
|
16
|
+
"surface": "module",
|
|
17
|
+
"defaultHeight": 480,
|
|
18
|
+
"settings": [
|
|
19
|
+
{
|
|
20
|
+
"key": "title",
|
|
21
|
+
"label": "Heading",
|
|
22
|
+
"type": "text",
|
|
23
|
+
"default": "Records"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"key": "showCompleted",
|
|
27
|
+
"label": "Include completed records",
|
|
28
|
+
"type": "boolean",
|
|
29
|
+
"default": true
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"scopes": [
|
|
35
|
+
"records:read",
|
|
36
|
+
"records:write"
|
|
37
|
+
],
|
|
38
|
+
"permissionsRationale": {
|
|
39
|
+
"records:read": "To list the records in the collection you are looking at.",
|
|
40
|
+
"records:write": "To tick a record complete from inside the view."
|
|
41
|
+
}
|
|
42
|
+
}
|
package/next.config.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A view is meant to be framed. Say so explicitly.
|
|
5
|
+
*
|
|
6
|
+
* Most hosting defaults (and every security checklist) push you toward
|
|
7
|
+
* `X-Frame-Options: DENY`, which renders a custom view as a blank rectangle
|
|
8
|
+
* with an error only in the console — the single most common way an embedded
|
|
9
|
+
* app appears broken for a reason nobody can see. `frame-ancestors` is the
|
|
10
|
+
* modern control and it is an ALLOWLIST, not an opt-out: only the Conexus X
|
|
11
|
+
* origins named here may embed this app, so this is stricter than DENY plus a
|
|
12
|
+
* hole, not weaker.
|
|
13
|
+
*
|
|
14
|
+
* CONEXUS_ORIGINS is comma-separated so a deploy can add a preview domain
|
|
15
|
+
* without a code change.
|
|
16
|
+
*/
|
|
17
|
+
const conexusOrigins =
|
|
18
|
+
process.env.CONEXUS_ORIGINS ??
|
|
19
|
+
"http://localhost:3000 https://conexus-x.vercel.app";
|
|
20
|
+
|
|
21
|
+
const nextConfig: NextConfig = {
|
|
22
|
+
/**
|
|
23
|
+
* NOT pinning `turbopack.root` here, deliberately.
|
|
24
|
+
*
|
|
25
|
+
* Turbopack warns when it infers a workspace root from a lockfile further
|
|
26
|
+
* up the tree, and the obvious fix is `turbopack: { root: import.meta.dirname }`.
|
|
27
|
+
* Do not add it while you are developing against a locally linked SDK
|
|
28
|
+
* (`npm link @conexus-x/sdk`): the link resolves to a folder OUTSIDE this
|
|
29
|
+
* one, and a pinned root refuses to look there, so the import stops
|
|
30
|
+
* resolving with an error that says nothing about linking.
|
|
31
|
+
*
|
|
32
|
+
* With `@conexus-x/sdk` installed normally from the registry, pinning the
|
|
33
|
+
* root is safe and silences the warning.
|
|
34
|
+
*/
|
|
35
|
+
async headers() {
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
source: "/:path*",
|
|
39
|
+
headers: [
|
|
40
|
+
{
|
|
41
|
+
key: "Content-Security-Policy",
|
|
42
|
+
value: `frame-ancestors 'self' ${conexusOrigins.split(",").join(" ")};`
|
|
43
|
+
},
|
|
44
|
+
// The host passes context in the query string; nothing here
|
|
45
|
+
// should be cached across two different mounts of the view.
|
|
46
|
+
{ key: "Cache-Control", value: "no-store" }
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default nextConfig;
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@conexus-x/next-view",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Custom view for Conexus X — Next.js starter, wired to @conexus-x/sdk.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev --port 5173",
|
|
8
|
+
"build": "next build",
|
|
9
|
+
"start": "next start --port 5173",
|
|
10
|
+
"lint": "eslint"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@conexus-x/sdk": "^0.1.0",
|
|
14
|
+
"next": "16.2.10",
|
|
15
|
+
"react": "19.2.4",
|
|
16
|
+
"react-dom": "19.2.4"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
20
|
+
"@types/node": "^20",
|
|
21
|
+
"@types/react": "^19",
|
|
22
|
+
"@types/react-dom": "^19",
|
|
23
|
+
"tailwindcss": "^4.3.3",
|
|
24
|
+
"typescript": "^5"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "restricted"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"app",
|
|
31
|
+
"components",
|
|
32
|
+
"next.config.ts",
|
|
33
|
+
"postcss.config.mjs",
|
|
34
|
+
"tsconfig.json",
|
|
35
|
+
"conexus.manifest.json",
|
|
36
|
+
"README.md",
|
|
37
|
+
".gitignore"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailwind v4 needs exactly this and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* No `tailwind.config.js`, no `autoprefixer`, no `postcss-import` — v4 handles
|
|
5
|
+
* all three itself. If you have set Tailwind up before and this file looks too
|
|
6
|
+
* short, it is not missing anything.
|
|
7
|
+
*/
|
|
8
|
+
const config = {
|
|
9
|
+
plugins: {
|
|
10
|
+
"@tailwindcss/postcss": {}
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default config;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"lib": [
|
|
5
|
+
"dom",
|
|
6
|
+
"dom.iterable",
|
|
7
|
+
"esnext"
|
|
8
|
+
],
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"module": "esnext",
|
|
15
|
+
"moduleResolution": "bundler",
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"jsx": "react-jsx",
|
|
19
|
+
"incremental": true,
|
|
20
|
+
"plugins": [
|
|
21
|
+
{
|
|
22
|
+
"name": "next"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"paths": {
|
|
26
|
+
"@/*": [
|
|
27
|
+
"./*"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"include": [
|
|
32
|
+
"next-env.d.ts",
|
|
33
|
+
"**/*.ts",
|
|
34
|
+
"**/*.tsx",
|
|
35
|
+
".next/types/**/*.ts",
|
|
36
|
+
".next/dev/types/**/*.ts"
|
|
37
|
+
],
|
|
38
|
+
"exclude": [
|
|
39
|
+
"node_modules"
|
|
40
|
+
]
|
|
41
|
+
}
|