create-grist-widget 0.2.8 → 0.2.10
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/package.json +1 -1
- package/template/README.md +4 -0
- package/template/src/components/channel-notice.tsx +122 -0
- package/template/src/components/grist-status-chip.tsx +105 -0
- package/template/src/components/template-landing.tsx +27 -56
- package/template/src/components/ui/card.tsx +103 -0
- package/template/src/components/ui/input.tsx +19 -0
- package/template/src/lib/grist-handshake-status.ts +26 -0
- package/template/src/lib/showcase-routing.ts +47 -0
- package/template/src/lib/showcase-versions.ts +49 -0
- package/template/src/main.tsx +35 -18
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -4,6 +4,10 @@ Base template to build a Grist widget with `grist-widget-sdk`.
|
|
|
4
4
|
|
|
5
5
|
`src/main.tsx` wraps the app with `GristWidgetProvider`, `GristBoundary`, and `GristSdkAlerts`. The latter maps `getGristSdkAlertDescriptors()` from the SDK to shadcn `Alert` (`src/components/grist-sdk-alerts.tsx` + `src/components/ui/alert.tsx`); keep them in sync with the playground when you change alert styling.
|
|
6
6
|
|
|
7
|
+
Opened outside a Grist iframe, `main.tsx` picks between two components purely by URL shape (`src/lib/showcase-routing.ts`, no router needed): a bare path with no recognized channel suffix renders `TemplateLanding` (its own hero, onboarding, and released-version index); `/latest/`, `/dev/`, or `/v<version>/` renders `ChannelNotice` instead — a distinct hero explaining which build this is, chips to jump to any other version/channel (`src/lib/showcase-versions.ts`), and a copy-this-URL helper for pasting into Grist's custom widget field.
|
|
8
|
+
|
|
9
|
+
When actually embedded, `GristStatusChip` (`src/components/grist-status-chip.tsx`) shows a small pill with live handshake status — connecting, retrying with a countdown, connected, or unavailable — using `useGristHandshakeContext()` from `grist-widget-sdk/advanced` in its own `<GristHandshakeProvider>`. Safe to mount alongside `GristWidgetProvider`: both share the page's `ensureGristReady()` singleton (see `apps/docs/api/handshake.md`), so this is purely observational and never duplicates the real handshake.
|
|
10
|
+
|
|
7
11
|
`src/App.tsx` uses `useGrist<TaskRow, TaskMapped>()` for the selected row (`w.record`, `w.mode`) and remounts the UI with `key={rowKey}` when the row changes — same pattern as `widgets/create-email-draft`. See `src/grist-types.example.ts` for typing patterns.
|
|
8
12
|
|
|
9
13
|
- **ESLint** blocks direct `grist` global usage in `src/` — use the SDK only.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
|
|
3
|
+
import { Button } from "@/components/ui/button"
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardDescription,
|
|
8
|
+
CardHeader,
|
|
9
|
+
CardTitle,
|
|
10
|
+
} from "@/components/ui/card"
|
|
11
|
+
import { Input } from "@/components/ui/input"
|
|
12
|
+
import { channelLabel, type Channel } from "@/lib/showcase-routing"
|
|
13
|
+
import { DEV_URL, useVersions, versionUrl } from "@/lib/showcase-versions"
|
|
14
|
+
import { cn } from "@/lib/utils"
|
|
15
|
+
|
|
16
|
+
// Shown instead of the widget tree on a specific channel build
|
|
17
|
+
// (/latest/, /dev/, /v<version>/) when it's opened directly in a browser tab
|
|
18
|
+
// -- not the rich showcase hub (that's `TemplateLanding`, shown when the URL
|
|
19
|
+
// has no recognized channel suffix). Deliberately minimal, with its own hero
|
|
20
|
+
// distinct from the hub's: this page's job is just to explain what you're
|
|
21
|
+
// looking at, help you jump to a different build, and help you paste this
|
|
22
|
+
// exact URL into Grist.
|
|
23
|
+
function VersionChips({ current }: { current: Channel }) {
|
|
24
|
+
const { versions } = useVersions()
|
|
25
|
+
|
|
26
|
+
function chipClass(active: boolean) {
|
|
27
|
+
return cn(
|
|
28
|
+
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
|
29
|
+
active
|
|
30
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
31
|
+
: "border-border text-muted-foreground hover:text-foreground",
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="mb-8 flex flex-wrap justify-center gap-2">
|
|
37
|
+
{versions !== null && versions.length > 0 && (
|
|
38
|
+
<a
|
|
39
|
+
href={versionUrl(versions[0].version)}
|
|
40
|
+
className={chipClass(
|
|
41
|
+
current.kind === "version" && current.version === versions[0].version,
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
latest
|
|
45
|
+
</a>
|
|
46
|
+
)}
|
|
47
|
+
<a href={DEV_URL} className={chipClass(current.kind === "dev")}>
|
|
48
|
+
dev
|
|
49
|
+
</a>
|
|
50
|
+
{versions?.map((v) => (
|
|
51
|
+
<a
|
|
52
|
+
key={v.version}
|
|
53
|
+
href={versionUrl(v.version)}
|
|
54
|
+
className={chipClass(
|
|
55
|
+
current.kind === "version" && current.version === v.version,
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
v{v.version}
|
|
59
|
+
</a>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function ChannelNotice({
|
|
66
|
+
channel,
|
|
67
|
+
hubPath,
|
|
68
|
+
}: {
|
|
69
|
+
channel: Channel
|
|
70
|
+
hubPath: string
|
|
71
|
+
}) {
|
|
72
|
+
const [copied, setCopied] = useState(false)
|
|
73
|
+
const url = window.location.href
|
|
74
|
+
|
|
75
|
+
function copyUrl() {
|
|
76
|
+
navigator.clipboard.writeText(url).then(() => {
|
|
77
|
+
setCopied(true)
|
|
78
|
+
setTimeout(() => setCopied(false), 1500)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="mx-auto flex min-h-full max-w-md flex-col justify-center px-6 py-16">
|
|
84
|
+
<p className="mb-2 text-center text-sm font-medium text-muted-foreground">
|
|
85
|
+
grist-widget-template · {channelLabel(channel)}
|
|
86
|
+
</p>
|
|
87
|
+
<h1 className="text-center font-heading text-2xl font-semibold tracking-tight">
|
|
88
|
+
Grist isn't loaded here
|
|
89
|
+
</h1>
|
|
90
|
+
<p className="mx-auto mt-3 mb-8 max-w-sm text-center text-sm text-muted-foreground">
|
|
91
|
+
You're viewing the <span className="font-mono">{channelLabel(channel)}</span>{" "}
|
|
92
|
+
build directly, outside of Grist.
|
|
93
|
+
</p>
|
|
94
|
+
|
|
95
|
+
<VersionChips current={channel} />
|
|
96
|
+
|
|
97
|
+
<Card className="mb-4">
|
|
98
|
+
<CardHeader>
|
|
99
|
+
<CardTitle>Use this as a Grist custom widget</CardTitle>
|
|
100
|
+
<CardDescription>
|
|
101
|
+
Paste this URL into Grist's custom widget URL field.
|
|
102
|
+
</CardDescription>
|
|
103
|
+
</CardHeader>
|
|
104
|
+
<CardContent>
|
|
105
|
+
<div className="flex gap-2">
|
|
106
|
+
<Input readOnly value={url} onFocus={(e) => e.target.select()} />
|
|
107
|
+
<Button type="button" onClick={copyUrl}>
|
|
108
|
+
{copied ? "Copied" : "Copy"}
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
</CardContent>
|
|
112
|
+
</Card>
|
|
113
|
+
|
|
114
|
+
<a
|
|
115
|
+
href={hubPath}
|
|
116
|
+
className="text-center text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
|
117
|
+
>
|
|
118
|
+
← Back to the template home
|
|
119
|
+
</a>
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { GristHandshakeProvider, useGristHandshakeContext } from "grist-widget-sdk/advanced"
|
|
3
|
+
|
|
4
|
+
import { deriveDisplayStatus } from "@/lib/grist-handshake-status"
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
// Fixed, approximate countdown shown while retrying -- not a mirror of the
|
|
8
|
+
// SDK's actual internal poll backoff (an implementation detail, not part of
|
|
9
|
+
// the public API surface, which ramps up from ~200ms before settling into a
|
|
10
|
+
// steady cadence). Most production widgets connect in under a second per the
|
|
11
|
+
// SDK's own docs, so a visibly "retrying" chip is already in that settled
|
|
12
|
+
// long tail -- this just picks the same round number as a display countdown.
|
|
13
|
+
const RETRY_COUNTDOWN_SECONDS = 5
|
|
14
|
+
|
|
15
|
+
function useRetryCountdown(attempts: number, active: boolean) {
|
|
16
|
+
const [state, setState] = useState({ attempts, secondsLeft: RETRY_COUNTDOWN_SECONDS })
|
|
17
|
+
|
|
18
|
+
// Reset the countdown the moment a new retry attempt begins -- an
|
|
19
|
+
// "adjust state during render" pattern, not a setState-in-effect (which
|
|
20
|
+
// the linter flags): https://react.dev/learn/you-might-not-need-an-effect
|
|
21
|
+
if (active && state.attempts !== attempts) {
|
|
22
|
+
setState({ attempts, secondsLeft: RETRY_COUNTDOWN_SECONDS })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!active) return
|
|
27
|
+
const id = setInterval(() => {
|
|
28
|
+
setState((s) => ({ ...s, secondsLeft: s.secondsLeft > 0 ? s.secondsLeft - 1 : 0 }))
|
|
29
|
+
}, 1000)
|
|
30
|
+
return () => clearInterval(id)
|
|
31
|
+
}, [attempts, active])
|
|
32
|
+
|
|
33
|
+
return state.secondsLeft
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function StatusDot({ pulsing, className }: { pulsing: boolean; className: string }) {
|
|
37
|
+
return (
|
|
38
|
+
<span className="relative flex size-2">
|
|
39
|
+
{pulsing && (
|
|
40
|
+
<span
|
|
41
|
+
className={cn(
|
|
42
|
+
"absolute inline-flex size-full animate-ping rounded-full opacity-75",
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
/>
|
|
46
|
+
)}
|
|
47
|
+
<span className={cn("relative inline-flex size-2 rounded-full", className)} />
|
|
48
|
+
</span>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ChipContents() {
|
|
53
|
+
const { snapshot } = useGristHandshakeContext()
|
|
54
|
+
const display = deriveDisplayStatus(snapshot.lifecycle)
|
|
55
|
+
const retryAttempts = display.kind === "retrying" ? display.attempts : 0
|
|
56
|
+
const secondsLeft = useRetryCountdown(retryAttempts, display.kind === "retrying")
|
|
57
|
+
|
|
58
|
+
switch (display.kind) {
|
|
59
|
+
case "connecting":
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<StatusDot pulsing className="bg-amber-500" />
|
|
63
|
+
Connecting to Grist
|
|
64
|
+
</>
|
|
65
|
+
)
|
|
66
|
+
case "retrying":
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<StatusDot pulsing className="bg-amber-500" />
|
|
70
|
+
Retry Grist connection in {secondsLeft}s
|
|
71
|
+
</>
|
|
72
|
+
)
|
|
73
|
+
case "connected":
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<StatusDot pulsing={false} className="bg-emerald-500" />
|
|
77
|
+
Grist connected
|
|
78
|
+
</>
|
|
79
|
+
)
|
|
80
|
+
case "unavailable":
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<StatusDot pulsing={false} className="bg-destructive" />
|
|
84
|
+
Grist unavailable
|
|
85
|
+
</>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Small pill showing live Grist handshake status. Mounts its own
|
|
92
|
+
* `<GristHandshakeProvider>` alongside `<GristWidgetProvider>` -- documented
|
|
93
|
+
* (grist-widget-sdk/advanced) as safe to coexist, since both share the
|
|
94
|
+
* page's `ensureGristReady()` singleton. This chip is purely observational:
|
|
95
|
+
* it never drives the real widget's connection, just reflects it.
|
|
96
|
+
*/
|
|
97
|
+
export function GristStatusChip() {
|
|
98
|
+
return (
|
|
99
|
+
<GristHandshakeProvider>
|
|
100
|
+
<div className="pointer-events-none fixed top-2 right-2 z-50 flex items-center gap-1.5 rounded-full border bg-background/90 px-2.5 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur">
|
|
101
|
+
<ChipContents />
|
|
102
|
+
</div>
|
|
103
|
+
</GristHandshakeProvider>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -1,53 +1,16 @@
|
|
|
1
|
-
import { useEffect, useState } from "react"
|
|
2
|
-
|
|
3
1
|
import { Button } from "@/components/ui/button"
|
|
2
|
+
import { DEV_URL, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
|
|
4
3
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// which is
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
type ReleasedVersion = {
|
|
17
|
-
version: string
|
|
18
|
-
publishedAt: string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function useVersions() {
|
|
22
|
-
const [versions, setVersions] = useState<ReleasedVersion[] | null>(null)
|
|
23
|
-
const [error, setError] = useState(false)
|
|
24
|
-
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
let cancelled = false
|
|
27
|
-
fetch(VERSIONS_URL, { cache: "no-store" })
|
|
28
|
-
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
|
|
29
|
-
.then((data: ReleasedVersion[]) => {
|
|
30
|
-
if (!cancelled) setVersions(data)
|
|
31
|
-
})
|
|
32
|
-
.catch(() => {
|
|
33
|
-
if (!cancelled) setError(true)
|
|
34
|
-
})
|
|
35
|
-
return () => {
|
|
36
|
-
cancelled = true
|
|
37
|
-
}
|
|
38
|
-
}, [])
|
|
39
|
-
|
|
40
|
-
return { versions, error }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function formatDate(iso: string) {
|
|
44
|
-
return new Date(iso).toLocaleDateString(undefined, {
|
|
45
|
-
year: "numeric",
|
|
46
|
-
month: "short",
|
|
47
|
-
day: "numeric",
|
|
48
|
-
})
|
|
49
|
-
}
|
|
50
|
-
|
|
4
|
+
// The showcase "hub" page: rendered whenever this deploy's URL has no
|
|
5
|
+
// recognized channel suffix (see src/lib/showcase-routing.ts) -- this repo's
|
|
6
|
+
// own bare /template/, or a local `pnpm dev` server. Individual channel
|
|
7
|
+
// builds (/latest/, /dev/, /v<version>/) render `ChannelNotice` instead when
|
|
8
|
+
// not embedded in Grist, with a link back here. Its hero introduces the
|
|
9
|
+
// template project itself -- deliberately different from ChannelNotice's
|
|
10
|
+
// hero, which is about the specific build you're looking at. Same component
|
|
11
|
+
// for this repo's own showcase as for a real scaffolded widget that happens
|
|
12
|
+
// to have something deployed at its own bare root; the only per-deploy
|
|
13
|
+
// difference is which /v<version>/ dirs actually exist upstream.
|
|
51
14
|
export function TemplateLanding() {
|
|
52
15
|
const { versions, error } = useVersions()
|
|
53
16
|
|
|
@@ -58,12 +21,12 @@ export function TemplateLanding() {
|
|
|
58
21
|
grist-widget-template
|
|
59
22
|
</p>
|
|
60
23
|
<h1 className="font-heading text-3xl font-semibold tracking-tight">
|
|
61
|
-
Grist
|
|
24
|
+
Build a Grist custom widget in minutes
|
|
62
25
|
</h1>
|
|
63
26
|
<p className="mx-auto mt-3 max-w-md text-sm text-muted-foreground">
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
your own copy.
|
|
27
|
+
A React + TypeScript + Tailwind starting point for{" "}
|
|
28
|
+
<code className="text-foreground">grist-widget-sdk</code>. Scaffold
|
|
29
|
+
your own copy, or browse what's already been released below.
|
|
67
30
|
</p>
|
|
68
31
|
</header>
|
|
69
32
|
|
|
@@ -104,9 +67,17 @@ git push origin dev`}</code>
|
|
|
104
67
|
</div>
|
|
105
68
|
|
|
106
69
|
<section>
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
70
|
+
<div className="mb-3 flex items-center justify-between">
|
|
71
|
+
<h2 className="font-heading text-base font-semibold">
|
|
72
|
+
Released template versions
|
|
73
|
+
</h2>
|
|
74
|
+
<a
|
|
75
|
+
href={DEV_URL}
|
|
76
|
+
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
|
77
|
+
>
|
|
78
|
+
Live dev preview →
|
|
79
|
+
</a>
|
|
80
|
+
</div>
|
|
110
81
|
{error ? (
|
|
111
82
|
<p className="text-sm text-muted-foreground">
|
|
112
83
|
Couldn't load the version list.
|
|
@@ -136,7 +107,7 @@ git push origin dev`}</code>
|
|
|
136
107
|
</span>
|
|
137
108
|
</div>
|
|
138
109
|
<a
|
|
139
|
-
href={
|
|
110
|
+
href={versionUrl(v.version)}
|
|
140
111
|
className="text-sm underline underline-offset-4 hover:text-foreground"
|
|
141
112
|
>
|
|
142
113
|
Preview
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
function Card({
|
|
6
|
+
className,
|
|
7
|
+
size = "default",
|
|
8
|
+
...props
|
|
9
|
+
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
data-slot="card"
|
|
13
|
+
data-size={size}
|
|
14
|
+
className={cn(
|
|
15
|
+
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
data-slot="card-header"
|
|
27
|
+
className={cn(
|
|
28
|
+
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
data-slot="card-title"
|
|
40
|
+
className={cn(
|
|
41
|
+
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-slot="card-description"
|
|
53
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
data-slot="card-action"
|
|
63
|
+
className={cn(
|
|
64
|
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
data-slot="card-content"
|
|
76
|
+
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
data-slot="card-footer"
|
|
86
|
+
className={cn(
|
|
87
|
+
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
|
88
|
+
className
|
|
89
|
+
)}
|
|
90
|
+
{...props}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export {
|
|
96
|
+
Card,
|
|
97
|
+
CardHeader,
|
|
98
|
+
CardFooter,
|
|
99
|
+
CardTitle,
|
|
100
|
+
CardAction,
|
|
101
|
+
CardDescription,
|
|
102
|
+
CardContent,
|
|
103
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
6
|
+
return (
|
|
7
|
+
<input
|
|
8
|
+
type={type}
|
|
9
|
+
data-slot="input"
|
|
10
|
+
className={cn(
|
|
11
|
+
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
12
|
+
className
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { Input }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { GristLifecycle } from "grist-widget-sdk/advanced"
|
|
2
|
+
|
|
3
|
+
// Projects the SDK's full GristLifecycle (advanced API, grist-widget-sdk/advanced)
|
|
4
|
+
// down to the handful of states a small status chip needs to show.
|
|
5
|
+
export type HandshakeDisplayStatus =
|
|
6
|
+
| { kind: "connecting" }
|
|
7
|
+
| { kind: "retrying"; attempts: number }
|
|
8
|
+
| { kind: "connected" }
|
|
9
|
+
| { kind: "unavailable" }
|
|
10
|
+
|
|
11
|
+
export function deriveDisplayStatus(lifecycle: GristLifecycle): HandshakeDisplayStatus {
|
|
12
|
+
switch (lifecycle.phase) {
|
|
13
|
+
case "idle":
|
|
14
|
+
return { kind: "connecting" }
|
|
15
|
+
case "detecting":
|
|
16
|
+
return lifecycle.attempts > 0
|
|
17
|
+
? { kind: "retrying", attempts: lifecycle.attempts }
|
|
18
|
+
: { kind: "connecting" }
|
|
19
|
+
case "negotiating":
|
|
20
|
+
return { kind: "connecting" }
|
|
21
|
+
case "online":
|
|
22
|
+
return { kind: "connected" }
|
|
23
|
+
case "terminated":
|
|
24
|
+
return { kind: "unavailable" }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Parses the deployed URL shape every channel of this template (or a real
|
|
2
|
+
// scaffolded widget) uses -- /latest/, /dev/, /v<version>/ -- so main.tsx can
|
|
3
|
+
// tell a "hub" page (no recognized channel suffix, e.g. this repo's own
|
|
4
|
+
// /template/) from a "channel" page (a specific release/dev build) using
|
|
5
|
+
// nothing but window.location.pathname. No router needed for two states.
|
|
6
|
+
export type Channel =
|
|
7
|
+
| { kind: "latest" }
|
|
8
|
+
| { kind: "dev" }
|
|
9
|
+
| { kind: "version"; version: string }
|
|
10
|
+
|
|
11
|
+
export function channelLabel(channel: Channel): string {
|
|
12
|
+
switch (channel.kind) {
|
|
13
|
+
case "latest":
|
|
14
|
+
return "latest"
|
|
15
|
+
case "dev":
|
|
16
|
+
return "dev"
|
|
17
|
+
case "version":
|
|
18
|
+
return `v${channel.version}`
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `hubPath` is the current path with the trailing channel segment stripped,
|
|
24
|
+
* e.g. "/grist-widget-sdk/template/latest/" -> "/grist-widget-sdk/template/".
|
|
25
|
+
* `channel` is null when the path itself already looks like a hub (no
|
|
26
|
+
* recognized suffix) -- e.g. this repo's own bare /template/, or a local
|
|
27
|
+
* `pnpm dev` server at "/".
|
|
28
|
+
*/
|
|
29
|
+
export function parseShowcasePath(pathname: string): {
|
|
30
|
+
channel: Channel | null
|
|
31
|
+
hubPath: string
|
|
32
|
+
} {
|
|
33
|
+
const trimmed = pathname.replace(/\/+$/, "")
|
|
34
|
+
const segments = trimmed.split("/")
|
|
35
|
+
const last = segments[segments.length - 1] ?? ""
|
|
36
|
+
|
|
37
|
+
let channel: Channel | null = null
|
|
38
|
+
if (last === "latest") channel = { kind: "latest" }
|
|
39
|
+
else if (last === "dev") channel = { kind: "dev" }
|
|
40
|
+
else {
|
|
41
|
+
const match = /^v(\d+\.\d+\.\d+(?:-[\w.]+)?)$/.exec(last)
|
|
42
|
+
if (match) channel = { kind: "version", version: match[1] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const hubPath = channel ? segments.slice(0, -1).join("/") + "/" : pathname
|
|
46
|
+
return { channel, hubPath }
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
|
|
3
|
+
// Shared by the showcase hub (TemplateLanding) and the per-channel notice
|
|
4
|
+
// (ChannelNotice) -- both need the same released-version list, fetched from
|
|
5
|
+
// a fixed absolute URL rather than a path relative to wherever this happens
|
|
6
|
+
// to be deployed (see src/components/template-landing.tsx for why).
|
|
7
|
+
export const VERSIONS_URL =
|
|
8
|
+
"https://arthurblanchon.github.io/grist-widget-sdk/template/versions.json"
|
|
9
|
+
export const DEV_URL = "https://arthurblanchon.github.io/grist-widget-sdk/template/dev/"
|
|
10
|
+
export const HUB_URL = "https://arthurblanchon.github.io/grist-widget-sdk/template/"
|
|
11
|
+
|
|
12
|
+
export type ReleasedVersion = {
|
|
13
|
+
version: string
|
|
14
|
+
publishedAt: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function versionUrl(version: string): string {
|
|
18
|
+
return `https://arthurblanchon.github.io/grist-widget-sdk/template/v${version}/`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useVersions() {
|
|
22
|
+
const [versions, setVersions] = useState<ReleasedVersion[] | null>(null)
|
|
23
|
+
const [error, setError] = useState(false)
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
let cancelled = false
|
|
27
|
+
fetch(VERSIONS_URL, { cache: "no-store" })
|
|
28
|
+
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
|
|
29
|
+
.then((data: ReleasedVersion[]) => {
|
|
30
|
+
if (!cancelled) setVersions(data)
|
|
31
|
+
})
|
|
32
|
+
.catch(() => {
|
|
33
|
+
if (!cancelled) setError(true)
|
|
34
|
+
})
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true
|
|
37
|
+
}
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
return { versions, error }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatDate(iso: string) {
|
|
44
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
45
|
+
year: "numeric",
|
|
46
|
+
month: "short",
|
|
47
|
+
day: "numeric",
|
|
48
|
+
})
|
|
49
|
+
}
|
package/template/src/main.tsx
CHANGED
|
@@ -2,40 +2,57 @@ import { StrictMode } from "react"
|
|
|
2
2
|
import { createRoot } from "react-dom/client"
|
|
3
3
|
import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
|
|
4
4
|
|
|
5
|
+
import { ChannelNotice } from "@/components/channel-notice"
|
|
5
6
|
import { GristSdkAlerts } from "@/components/grist-sdk-alerts"
|
|
7
|
+
import { GristStatusChip } from "@/components/grist-status-chip"
|
|
6
8
|
import { TemplateLanding } from "@/components/template-landing"
|
|
7
9
|
import { ThemeProvider } from "@/components/theme-provider.tsx"
|
|
10
|
+
import { parseShowcasePath } from "@/lib/showcase-routing"
|
|
8
11
|
import "./index.css"
|
|
9
12
|
import App, { GRIST_OPTIONS } from "./App.tsx"
|
|
10
13
|
|
|
11
14
|
// A Grist custom widget only ever runs embedded in Grist's own iframe.
|
|
12
15
|
// Opened directly in a browser tab (window.self === window.top), there's no
|
|
13
|
-
// Grist to connect to
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
16
|
+
// Grist to connect to. Two different "not embedded" experiences, told apart
|
|
17
|
+
// purely by URL shape (see src/lib/showcase-routing.ts):
|
|
18
|
+
// - no recognized channel suffix (this repo's own bare /template/, or a
|
|
19
|
+
// local `pnpm dev` server) -> the rich showcase hub (TemplateLanding):
|
|
20
|
+
// onboarding + a link to every released version and the dev channel.
|
|
21
|
+
// - a recognized channel suffix (/latest/, /dev/, /v<version>/) -> a
|
|
22
|
+
// minimal notice: which build this is, a link back to the hub, and a
|
|
23
|
+
// copy-this-URL helper for pasting into Grist's custom widget field.
|
|
24
|
+
// The hub always wins over embedding -- /template/ is never meant to
|
|
25
|
+
// function as an actual widget, even if someone pastes it into Grist by
|
|
26
|
+
// mistake.
|
|
18
27
|
const isEmbedded = window.self !== window.top
|
|
28
|
+
const { channel, hubPath } = parseShowcasePath(window.location.pathname)
|
|
19
29
|
|
|
20
30
|
createRoot(document.getElementById("root")!).render(
|
|
21
31
|
<StrictMode>
|
|
22
32
|
<ThemeProvider>
|
|
23
|
-
{
|
|
24
|
-
<GristWidgetProvider options={GRIST_OPTIONS}>
|
|
25
|
-
<GristBoundary
|
|
26
|
-
gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
|
|
27
|
-
>
|
|
28
|
-
<div className="min-h-full w-full bg-background text-foreground">
|
|
29
|
-
<GristSdkAlerts>
|
|
30
|
-
<App />
|
|
31
|
-
</GristSdkAlerts>
|
|
32
|
-
</div>
|
|
33
|
-
</GristBoundary>
|
|
34
|
-
</GristWidgetProvider>
|
|
35
|
-
) : (
|
|
33
|
+
{!channel ? (
|
|
36
34
|
<div className="min-h-full w-full bg-background text-foreground">
|
|
37
35
|
<TemplateLanding />
|
|
38
36
|
</div>
|
|
37
|
+
) : isEmbedded ? (
|
|
38
|
+
<>
|
|
39
|
+
<GristStatusChip />
|
|
40
|
+
<GristWidgetProvider options={GRIST_OPTIONS}>
|
|
41
|
+
<GristBoundary
|
|
42
|
+
gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
|
|
43
|
+
>
|
|
44
|
+
<div className="min-h-full w-full bg-background text-foreground">
|
|
45
|
+
<GristSdkAlerts>
|
|
46
|
+
<App />
|
|
47
|
+
</GristSdkAlerts>
|
|
48
|
+
</div>
|
|
49
|
+
</GristBoundary>
|
|
50
|
+
</GristWidgetProvider>
|
|
51
|
+
</>
|
|
52
|
+
) : (
|
|
53
|
+
<div className="min-h-full w-full bg-background text-foreground">
|
|
54
|
+
<ChannelNotice channel={channel} hubPath={hubPath} />
|
|
55
|
+
</div>
|
|
39
56
|
)}
|
|
40
57
|
</ThemeProvider>
|
|
41
58
|
</StrictMode>
|