create-grist-widget 0.2.10 → 0.2.12
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react"
|
|
2
2
|
import { GristHandshakeProvider, useGristHandshakeContext } from "grist-widget-sdk/advanced"
|
|
3
3
|
|
|
4
|
-
import { deriveDisplayStatus } from "@/lib/grist-handshake-status"
|
|
4
|
+
import { deriveDisplayStatus, type HandshakeDisplayStatus } from "@/lib/grist-handshake-status"
|
|
5
5
|
import { cn } from "@/lib/utils"
|
|
6
6
|
|
|
7
7
|
// Fixed, approximate countdown shown while retrying -- not a mirror of the
|
|
@@ -49,12 +49,8 @@ function StatusDot({ pulsing, className }: { pulsing: boolean; className: string
|
|
|
49
49
|
)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const display = deriveDisplayStatus(snapshot.lifecycle)
|
|
55
|
-
const retryAttempts = display.kind === "retrying" ? display.attempts : 0
|
|
56
|
-
const secondsLeft = useRetryCountdown(retryAttempts, display.kind === "retrying")
|
|
57
|
-
|
|
52
|
+
/** Pure render of a display status -- shared by the real (context-driven) and demo (cycling) chips. */
|
|
53
|
+
function ChipVisual({ display, secondsLeft }: { display: HandshakeDisplayStatus; secondsLeft: number }) {
|
|
58
54
|
switch (display.kind) {
|
|
59
55
|
case "connecting":
|
|
60
56
|
return (
|
|
@@ -87,19 +83,89 @@ function ChipContents() {
|
|
|
87
83
|
}
|
|
88
84
|
}
|
|
89
85
|
|
|
86
|
+
function RealChipContents() {
|
|
87
|
+
const { snapshot } = useGristHandshakeContext()
|
|
88
|
+
const display = deriveDisplayStatus(snapshot.lifecycle)
|
|
89
|
+
const retryAttempts = display.kind === "retrying" ? display.attempts : 0
|
|
90
|
+
const secondsLeft = useRetryCountdown(retryAttempts, display.kind === "retrying")
|
|
91
|
+
return <ChipVisual display={display} secondsLeft={secondsLeft} />
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A fixed, looping sequence for the showcase hub -- there's no real Grist to
|
|
95
|
+
// connect to on that page (it's shown precisely because a real widget isn't
|
|
96
|
+
// embedded), so this is a cosmetic demo of the same visual states a real
|
|
97
|
+
// widget's chip goes through, not a real handshake attempt.
|
|
98
|
+
const DEMO_SEQUENCE: readonly { display: HandshakeDisplayStatus; ms: number }[] = [
|
|
99
|
+
{ display: { kind: "connecting" }, ms: 1500 },
|
|
100
|
+
{ display: { kind: "retrying", attempts: 1 }, ms: 3000 },
|
|
101
|
+
{ display: { kind: "connected" }, ms: 3500 },
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
function DemoChipContents() {
|
|
105
|
+
const [step, setStep] = useState(0)
|
|
106
|
+
const [countdownState, setCountdownState] = useState({ step, secondsLeft: 3 })
|
|
107
|
+
const current = DEMO_SEQUENCE[step]
|
|
108
|
+
|
|
109
|
+
// Reset the mini countdown the moment the demo cycle enters "retrying" --
|
|
110
|
+
// an "adjust state during render" pattern, not a setState-in-effect.
|
|
111
|
+
if (current.display.kind === "retrying" && countdownState.step !== step) {
|
|
112
|
+
setCountdownState({ step, secondsLeft: 3 })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const advance = setTimeout(() => {
|
|
117
|
+
setStep((s) => (s + 1) % DEMO_SEQUENCE.length)
|
|
118
|
+
}, current.ms)
|
|
119
|
+
return () => clearTimeout(advance)
|
|
120
|
+
}, [step, current.ms])
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (current.display.kind !== "retrying") return
|
|
124
|
+
const id = setInterval(() => {
|
|
125
|
+
setCountdownState((s) => ({ ...s, secondsLeft: s.secondsLeft > 0 ? s.secondsLeft - 1 : 0 }))
|
|
126
|
+
}, 1000)
|
|
127
|
+
return () => clearInterval(id)
|
|
128
|
+
}, [step, current.display.kind])
|
|
129
|
+
|
|
130
|
+
return <ChipVisual display={current.display} secondsLeft={countdownState.secondsLeft} />
|
|
131
|
+
}
|
|
132
|
+
|
|
90
133
|
/**
|
|
91
|
-
* Small pill showing
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
134
|
+
* Small pill showing Grist handshake status. Two modes:
|
|
135
|
+
*
|
|
136
|
+
* - Default: live status while actually embedded in Grist. Mounts its own
|
|
137
|
+
* `<GristHandshakeProvider>` alongside `<GristWidgetProvider>` --
|
|
138
|
+
* documented (grist-widget-sdk/advanced) as safe to coexist, since both
|
|
139
|
+
* share the page's `ensureGristReady()` singleton. Purely observational:
|
|
140
|
+
* it never drives the real widget's connection, just reflects it.
|
|
141
|
+
* - `demo`: a cosmetic, looping cycle through the same visual states, used
|
|
142
|
+
* on the showcase hub page where no real Grist connection is ever
|
|
143
|
+
* attempted -- a reference to the tech behind the template, not a status
|
|
144
|
+
* readout.
|
|
96
145
|
*/
|
|
97
|
-
export function GristStatusChip(
|
|
146
|
+
export function GristStatusChip({
|
|
147
|
+
demo = false,
|
|
148
|
+
className,
|
|
149
|
+
}: {
|
|
150
|
+
demo?: boolean
|
|
151
|
+
className?: string
|
|
152
|
+
}) {
|
|
153
|
+
const pill = (
|
|
154
|
+
<div
|
|
155
|
+
className={cn(
|
|
156
|
+
"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",
|
|
157
|
+
className,
|
|
158
|
+
)}
|
|
159
|
+
>
|
|
160
|
+
{demo ? <DemoChipContents /> : <RealChipContents />}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if (demo) return pill
|
|
165
|
+
|
|
98
166
|
return (
|
|
99
167
|
<GristHandshakeProvider>
|
|
100
|
-
<div className="pointer-events-none fixed top-2 right-2 z-50
|
|
101
|
-
<ChipContents />
|
|
102
|
-
</div>
|
|
168
|
+
<div className="pointer-events-none fixed top-2 right-2 z-50">{pill}</div>
|
|
103
169
|
</GristHandshakeProvider>
|
|
104
170
|
)
|
|
105
171
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
|
|
3
|
+
import { GristStatusChip } from "@/components/grist-status-chip"
|
|
1
4
|
import { Button } from "@/components/ui/button"
|
|
5
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
2
6
|
import { DEV_URL, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
|
|
3
7
|
|
|
4
8
|
// The showcase "hub" page: rendered whenever this deploy's URL has no
|
|
@@ -11,12 +15,64 @@ import { DEV_URL, formatDate, useVersions, versionUrl } from "@/lib/showcase-ver
|
|
|
11
15
|
// for this repo's own showcase as for a real scaffolded widget that happens
|
|
12
16
|
// to have something deployed at its own bare root; the only per-deploy
|
|
13
17
|
// difference is which /v<version>/ dirs actually exist upstream.
|
|
18
|
+
|
|
19
|
+
// A ready-to-paste instruction for an AI coding agent, not a human. Written
|
|
20
|
+
// to work first-shot: it explains *why* to scaffold into a subdirectory
|
|
21
|
+
// (create-grist-widget's CLI refuses a non-empty target -- and a repo
|
|
22
|
+
// Claude Code is already connected to always has a .git folder) rather than
|
|
23
|
+
// just saying "scaffold and push", which left room for the agent to try
|
|
24
|
+
// (and fail) `npm create grist-widget .` in place.
|
|
25
|
+
const CLAUDE_CODE_PROMPT = `Scaffold a new Grist custom widget here using \`npm create grist-widget\` (https://www.npmjs.com/package/create-grist-widget). This repo is an empty git clone already connected to GitHub, so scaffold into a new subdirectory first -- the CLI refuses a non-empty target directory, and this one already has a .git folder. Then push the scaffold's \`main\` and \`dev\` branches to this repository's own \`origin\` remote, so the bundled GitHub Pages deploy workflow runs. Report back the /latest/ and /v<version>/ GitHub Pages URLs once it's live.`
|
|
26
|
+
|
|
27
|
+
function CopyableBlock({ text }: { text: string }) {
|
|
28
|
+
const [copied, setCopied] = useState(false)
|
|
29
|
+
|
|
30
|
+
function copy() {
|
|
31
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
32
|
+
setCopied(true)
|
|
33
|
+
setTimeout(() => setCopied(false), 1500)
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="relative">
|
|
39
|
+
<pre className="overflow-x-auto rounded-lg bg-muted p-4 pr-16 text-xs whitespace-pre-wrap">
|
|
40
|
+
<code>{text}</code>
|
|
41
|
+
</pre>
|
|
42
|
+
<Button
|
|
43
|
+
type="button"
|
|
44
|
+
variant="secondary"
|
|
45
|
+
size="sm"
|
|
46
|
+
className="absolute top-2 right-2"
|
|
47
|
+
onClick={copy}
|
|
48
|
+
>
|
|
49
|
+
{copied ? "Copied" : "Copy"}
|
|
50
|
+
</Button>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function CreateRepoButton() {
|
|
56
|
+
return (
|
|
57
|
+
<div className="mt-2 ml-5">
|
|
58
|
+
<Button asChild>
|
|
59
|
+
<a href="https://github.com/new" target="_blank" rel="noreferrer">
|
|
60
|
+
Create a new repository
|
|
61
|
+
</a>
|
|
62
|
+
</Button>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
14
67
|
export function TemplateLanding() {
|
|
15
68
|
const { versions, error } = useVersions()
|
|
16
69
|
|
|
17
70
|
return (
|
|
18
71
|
<div className="mx-auto max-w-2xl px-6 py-16">
|
|
19
72
|
<header className="mb-10 text-center">
|
|
73
|
+
<div className="mb-4 flex justify-center">
|
|
74
|
+
<GristStatusChip demo />
|
|
75
|
+
</div>
|
|
20
76
|
<p className="mb-2 text-sm font-medium text-muted-foreground">
|
|
21
77
|
grist-widget-template
|
|
22
78
|
</p>
|
|
@@ -32,38 +88,85 @@ export function TemplateLanding() {
|
|
|
32
88
|
|
|
33
89
|
<div className="mb-10 rounded-lg border p-4">
|
|
34
90
|
<h2 className="mb-3 font-heading text-base font-semibold">
|
|
35
|
-
|
|
91
|
+
Get started
|
|
36
92
|
</h2>
|
|
37
|
-
<
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
93
|
+
<Tabs defaultValue="claude-code">
|
|
94
|
+
<TabsList className="mb-4">
|
|
95
|
+
<TabsTrigger value="claude-code">Using Claude Code?</TabsTrigger>
|
|
96
|
+
<TabsTrigger value="manual">Manual setup</TabsTrigger>
|
|
97
|
+
</TabsList>
|
|
98
|
+
|
|
99
|
+
<TabsContent value="claude-code">
|
|
100
|
+
<ol className="list-inside list-decimal space-y-3 text-sm text-muted-foreground">
|
|
101
|
+
<li>
|
|
102
|
+
Create a new, empty GitHub repo for the widget.
|
|
103
|
+
<CreateRepoButton />
|
|
104
|
+
</li>
|
|
105
|
+
<li>
|
|
106
|
+
Install the{" "}
|
|
107
|
+
<a
|
|
108
|
+
href="https://github.com/apps/claude"
|
|
109
|
+
target="_blank"
|
|
110
|
+
rel="noreferrer"
|
|
111
|
+
className="underline underline-offset-4 hover:text-foreground"
|
|
112
|
+
>
|
|
113
|
+
Claude GitHub App
|
|
114
|
+
</a>{" "}
|
|
115
|
+
on that repo.
|
|
116
|
+
</li>
|
|
117
|
+
<li>
|
|
118
|
+
Start a Claude Code session connected to it and paste this
|
|
119
|
+
prompt:
|
|
120
|
+
<div className="mt-2 ml-5">
|
|
121
|
+
<CopyableBlock text={CLAUDE_CODE_PROMPT} />
|
|
122
|
+
</div>
|
|
123
|
+
</li>
|
|
124
|
+
</ol>
|
|
125
|
+
</TabsContent>
|
|
57
126
|
|
|
58
|
-
|
|
127
|
+
<TabsContent value="manual">
|
|
128
|
+
<ol className="list-inside list-decimal space-y-3 text-sm text-muted-foreground">
|
|
129
|
+
<li>
|
|
130
|
+
Scaffold it and try it locally:
|
|
131
|
+
<div className="mt-2 ml-5">
|
|
132
|
+
<pre className="overflow-x-auto rounded-lg bg-muted p-4 text-xs">
|
|
133
|
+
<code>{`npm create grist-widget my-widget
|
|
134
|
+
cd my-widget
|
|
135
|
+
pnpm install
|
|
136
|
+
pnpm dev`}</code>
|
|
137
|
+
</pre>
|
|
138
|
+
</div>
|
|
139
|
+
</li>
|
|
140
|
+
<li>
|
|
141
|
+
Create a new, empty GitHub repo for the widget.
|
|
142
|
+
<CreateRepoButton />
|
|
143
|
+
</li>
|
|
144
|
+
<li>
|
|
145
|
+
Push it:
|
|
146
|
+
<div className="mt-2 ml-5">
|
|
147
|
+
<pre className="overflow-x-auto rounded-lg bg-muted p-4 text-xs">
|
|
148
|
+
<code>{`git remote add origin https://github.com/<owner>/<repo>.git
|
|
59
149
|
git push -u origin main
|
|
60
150
|
git push origin dev`}</code>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
151
|
+
</pre>
|
|
152
|
+
</div>
|
|
153
|
+
</li>
|
|
154
|
+
<li>
|
|
155
|
+
Once that first push's workflow run completes (it creates a{" "}
|
|
156
|
+
<code className="text-foreground">gh-pages</code> branch),
|
|
157
|
+
enable Pages: Settings → Pages → Source → "Deploy from a
|
|
158
|
+
branch" → branch <code className="text-foreground">gh-pages</code>{" "}
|
|
159
|
+
→ <code className="text-foreground">/ (root)</code>.
|
|
160
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
161
|
+
If that first run fails with a permissions error pushing to{" "}
|
|
162
|
+
<code className="text-foreground">gh-pages</code>, Settings
|
|
163
|
+
→ Actions → General → Workflow permissions → "Read and
|
|
164
|
+
write permissions" fixes it — most repos don't need this.
|
|
165
|
+
</p>
|
|
166
|
+
</li>
|
|
167
|
+
</ol>
|
|
168
|
+
</TabsContent>
|
|
169
|
+
</Tabs>
|
|
67
170
|
</div>
|
|
68
171
|
|
|
69
172
|
<section>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { Tabs as TabsPrimitive } from "radix-ui"
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
function Tabs({
|
|
8
|
+
className,
|
|
9
|
+
orientation = "horizontal",
|
|
10
|
+
...props
|
|
11
|
+
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
|
12
|
+
return (
|
|
13
|
+
<TabsPrimitive.Root
|
|
14
|
+
data-slot="tabs"
|
|
15
|
+
data-orientation={orientation}
|
|
16
|
+
className={cn(
|
|
17
|
+
"group/tabs flex gap-2 data-horizontal:flex-col",
|
|
18
|
+
className
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const tabsListVariants = cva(
|
|
26
|
+
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
|
27
|
+
{
|
|
28
|
+
variants: {
|
|
29
|
+
variant: {
|
|
30
|
+
default: "bg-muted",
|
|
31
|
+
line: "gap-1 bg-transparent",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
defaultVariants: {
|
|
35
|
+
variant: "default",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
function TabsList({
|
|
41
|
+
className,
|
|
42
|
+
variant = "default",
|
|
43
|
+
...props
|
|
44
|
+
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
|
45
|
+
VariantProps<typeof tabsListVariants>) {
|
|
46
|
+
return (
|
|
47
|
+
<TabsPrimitive.List
|
|
48
|
+
data-slot="tabs-list"
|
|
49
|
+
data-variant={variant}
|
|
50
|
+
className={cn(tabsListVariants({ variant }), className)}
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function TabsTrigger({
|
|
57
|
+
className,
|
|
58
|
+
...props
|
|
59
|
+
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
|
60
|
+
return (
|
|
61
|
+
<TabsPrimitive.Trigger
|
|
62
|
+
data-slot="tabs-trigger"
|
|
63
|
+
className={cn(
|
|
64
|
+
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
65
|
+
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
|
66
|
+
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
|
67
|
+
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
|
68
|
+
className
|
|
69
|
+
)}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function TabsContent({
|
|
76
|
+
className,
|
|
77
|
+
...props
|
|
78
|
+
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
|
79
|
+
return (
|
|
80
|
+
<TabsPrimitive.Content
|
|
81
|
+
data-slot="tabs-content"
|
|
82
|
+
className={cn("flex-1 text-sm outline-none", className)}
|
|
83
|
+
{...props}
|
|
84
|
+
/>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|