@namostack/cli 0.1.0-rc.1 → 0.1.0-rc.2
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/dist/index.mjs +362 -139
- package/package.json +4 -4
- package/templates/react/public/icon.svg +1 -1
- package/templates/react/src/App.tsx +76 -2
- package/templates/react/src/NamostackProvider.tsx +9 -0
- package/templates/react/src/components/Counter.tsx +101 -0
- package/templates/react/src/components/ExampleCard.tsx +55 -0
- package/templates/react/src/components/Notify.tsx +41 -0
- package/templates/react/src/components/styles.ts +24 -0
- package/templates/react/src/errors.ts +10 -0
- package/templates/vanilla/index.html +50 -1
- package/templates/vanilla/package.json +2 -0
- package/templates/vanilla/public/icon.svg +1 -1
- package/templates/vanilla/src/main.ts +146 -5
- package/templates/vanilla/src/style.css +330 -0
|
@@ -1,10 +1,84 @@
|
|
|
1
1
|
import { Text } from '@juliensouki/seedui';
|
|
2
|
+
import { styled } from '@juliensouki/seedui/sc';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Code, Muted } from './components/styles';
|
|
5
|
+
import { Counter } from './components/Counter';
|
|
6
|
+
import { Notify } from './components/Notify';
|
|
7
|
+
import { describeError } from './errors';
|
|
2
8
|
import { useNamostack } from './useNamostack';
|
|
3
9
|
|
|
10
|
+
const Page = styled.main((props) => ({
|
|
11
|
+
maxWidth: 560,
|
|
12
|
+
margin: '0 auto',
|
|
13
|
+
padding: '40px 24px',
|
|
14
|
+
display: 'flex',
|
|
15
|
+
flexDirection: 'column',
|
|
16
|
+
gap: 24,
|
|
17
|
+
[props.theme.breakpoints.down('sm')]: {
|
|
18
|
+
padding: '24px 16px',
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const Header = styled.header({
|
|
23
|
+
display: 'flex',
|
|
24
|
+
alignItems: 'center',
|
|
25
|
+
gap: 16,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const Icon = styled.img({
|
|
29
|
+
width: 56,
|
|
30
|
+
height: 56,
|
|
31
|
+
flexShrink: 0,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const Link = styled.a((props) => ({
|
|
35
|
+
color: props.theme.colors.primary.default,
|
|
36
|
+
textDecoration: 'none',
|
|
37
|
+
'&:hover': { textDecoration: 'underline' },
|
|
38
|
+
}));
|
|
39
|
+
|
|
4
40
|
export function App() {
|
|
5
|
-
const { context } = useNamostack();
|
|
41
|
+
const { context, user } = useNamostack();
|
|
42
|
+
const [displayName, setDisplayName] = useState<string>();
|
|
43
|
+
const [error, setError] = useState<string>();
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
user
|
|
47
|
+
.getProfile()
|
|
48
|
+
.then((profile) => setDisplayName(profile.displayName))
|
|
49
|
+
.catch((caught: unknown) =>
|
|
50
|
+
setError(describeError(caught, 'user:profile:read')),
|
|
51
|
+
);
|
|
52
|
+
}, [user]);
|
|
6
53
|
|
|
7
54
|
return (
|
|
8
|
-
<
|
|
55
|
+
<Page>
|
|
56
|
+
<Header>
|
|
57
|
+
<Icon src={`${import.meta.env.BASE_URL}icon.svg`} alt="" />
|
|
58
|
+
<div>
|
|
59
|
+
<Text variant="h3">{context.name}</Text>
|
|
60
|
+
<Muted variant="p">
|
|
61
|
+
{displayName ? `Hi ${displayName}, ` : 'Hi, '}
|
|
62
|
+
this is your new plugin.
|
|
63
|
+
</Muted>
|
|
64
|
+
{error && <Muted variant="caption">{error}</Muted>}
|
|
65
|
+
</div>
|
|
66
|
+
</Header>
|
|
67
|
+
|
|
68
|
+
<Counter />
|
|
69
|
+
<Notify />
|
|
70
|
+
|
|
71
|
+
<Muted variant="caption">
|
|
72
|
+
Edit <Code>src/App.tsx</Code> and save to see it change, or{' '}
|
|
73
|
+
<Link
|
|
74
|
+
href="https://docs.namostack.com"
|
|
75
|
+
target="_blank"
|
|
76
|
+
rel="noreferrer"
|
|
77
|
+
>
|
|
78
|
+
read the docs
|
|
79
|
+
</Link>
|
|
80
|
+
.
|
|
81
|
+
</Muted>
|
|
82
|
+
</Page>
|
|
9
83
|
);
|
|
10
84
|
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { ThemeProvider } from '@juliensouki/seedui';
|
|
2
|
+
import { createGlobalStyle } from '@juliensouki/seedui/sc';
|
|
2
3
|
import type { Namostack } from '@namostack/plugin-sdk';
|
|
3
4
|
import { useEffect, useState, type ReactNode } from 'react';
|
|
4
5
|
import { NamostackContext } from './useNamostack';
|
|
5
6
|
|
|
7
|
+
// seedui doesn't reset the page; without this, browsers add an 8px margin.
|
|
8
|
+
const GlobalStyle = createGlobalStyle`
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
}
|
|
12
|
+
`;
|
|
13
|
+
|
|
6
14
|
interface NamostackProviderProps {
|
|
7
15
|
namostack: Namostack;
|
|
8
16
|
children: ReactNode;
|
|
@@ -26,6 +34,7 @@ export function NamostackProvider({
|
|
|
26
34
|
|
|
27
35
|
return (
|
|
28
36
|
<NamostackContext.Provider value={namostack}>
|
|
37
|
+
<GlobalStyle />
|
|
29
38
|
<ThemeProvider mode={mode}>{children}</ThemeProvider>
|
|
30
39
|
</NamostackContext.Provider>
|
|
31
40
|
);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Button, Text } from '@juliensouki/seedui';
|
|
2
|
+
import type { StoredDocument } from '@namostack/plugin-sdk';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { describeError } from '../errors';
|
|
5
|
+
import { useNamostack } from '../useNamostack';
|
|
6
|
+
import { ExampleCard } from './ExampleCard';
|
|
7
|
+
import { Actions, Code } from './styles';
|
|
8
|
+
|
|
9
|
+
interface Clicks {
|
|
10
|
+
count: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Action = 'increment' | 'reset';
|
|
14
|
+
|
|
15
|
+
/** A counter saved for the current user: it survives reloads and devices. */
|
|
16
|
+
export function Counter() {
|
|
17
|
+
const { data } = useNamostack();
|
|
18
|
+
const [clicks, setClicks] = useState<StoredDocument<Clicks> | null>();
|
|
19
|
+
const [pending, setPending] = useState<Action>();
|
|
20
|
+
const [error, setError] = useState<string>();
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
data
|
|
24
|
+
.find<Clicks>('clicks', { limit: 1 })
|
|
25
|
+
.then(({ items }) => setClicks(items[0] ?? null))
|
|
26
|
+
.catch((caught: unknown) => {
|
|
27
|
+
setClicks(null);
|
|
28
|
+
setError(describeError(caught, 'storage'));
|
|
29
|
+
});
|
|
30
|
+
}, [data]);
|
|
31
|
+
|
|
32
|
+
const run = async (
|
|
33
|
+
action: Action,
|
|
34
|
+
change: () => Promise<StoredDocument<Clicks> | null>,
|
|
35
|
+
) => {
|
|
36
|
+
setPending(action);
|
|
37
|
+
setError(undefined);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
setClicks(await change());
|
|
41
|
+
} catch (caught) {
|
|
42
|
+
setError(describeError(caught, 'storage'));
|
|
43
|
+
} finally {
|
|
44
|
+
setPending(undefined);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const increment = () =>
|
|
49
|
+
run('increment', () =>
|
|
50
|
+
clicks
|
|
51
|
+
? data.update<Clicks>('clicks', clicks.id, { count: clicks.count + 1 })
|
|
52
|
+
: data.insert<Clicks>('clicks', { count: 1 }),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const reset = () =>
|
|
56
|
+
run('reset', async () => {
|
|
57
|
+
if (clicks) {
|
|
58
|
+
await data.delete('clicks', clicks.id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const loading = clicks === undefined;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ExampleCard
|
|
68
|
+
title="Save data"
|
|
69
|
+
permission="storage"
|
|
70
|
+
error={error}
|
|
71
|
+
description={
|
|
72
|
+
<>
|
|
73
|
+
Each click is saved with <Code>data.update</Code>: reload, the count
|
|
74
|
+
stays.
|
|
75
|
+
</>
|
|
76
|
+
}
|
|
77
|
+
>
|
|
78
|
+
<Text variant="h2">{clicks?.count ?? 0}</Text>
|
|
79
|
+
<Actions>
|
|
80
|
+
<Button
|
|
81
|
+
size="md"
|
|
82
|
+
isLoading={pending === 'increment'}
|
|
83
|
+
disabled={loading || pending === 'reset'}
|
|
84
|
+
onClick={increment}
|
|
85
|
+
>
|
|
86
|
+
Click me
|
|
87
|
+
</Button>
|
|
88
|
+
<Button
|
|
89
|
+
size="md"
|
|
90
|
+
color="neutral"
|
|
91
|
+
variant="transparent"
|
|
92
|
+
isLoading={pending === 'reset'}
|
|
93
|
+
disabled={!clicks || pending === 'increment'}
|
|
94
|
+
onClick={reset}
|
|
95
|
+
>
|
|
96
|
+
Reset
|
|
97
|
+
</Button>
|
|
98
|
+
</Actions>
|
|
99
|
+
</ExampleCard>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Card, Tag, Text } from '@juliensouki/seedui';
|
|
2
|
+
import { styled } from '@juliensouki/seedui/sc';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { Muted } from './styles';
|
|
5
|
+
|
|
6
|
+
const Root = styled(Card)({
|
|
7
|
+
width: '100%',
|
|
8
|
+
boxSizing: 'border-box',
|
|
9
|
+
display: 'flex',
|
|
10
|
+
flexDirection: 'column',
|
|
11
|
+
gap: 16,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const Heading = styled.div({
|
|
15
|
+
display: 'flex',
|
|
16
|
+
alignItems: 'center',
|
|
17
|
+
justifyContent: 'space-between',
|
|
18
|
+
gap: 8,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const ErrorText = styled(Text)((props) => ({
|
|
22
|
+
color: props.theme.colors.error.default,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
interface ExampleCardProps {
|
|
26
|
+
title: string;
|
|
27
|
+
description: ReactNode;
|
|
28
|
+
/** The manifest permission the example needs. */
|
|
29
|
+
permission: string;
|
|
30
|
+
error?: string;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One SDK feature: what it does, the permission it needs, and a way to try it. */
|
|
35
|
+
export function ExampleCard({
|
|
36
|
+
title,
|
|
37
|
+
description,
|
|
38
|
+
permission,
|
|
39
|
+
error,
|
|
40
|
+
children,
|
|
41
|
+
}: ExampleCardProps) {
|
|
42
|
+
return (
|
|
43
|
+
<Root variant="outlined" padding={20}>
|
|
44
|
+
<Heading>
|
|
45
|
+
<Text variant="h5">{title}</Text>
|
|
46
|
+
<Tag color="neutral" size="sm">
|
|
47
|
+
{permission}
|
|
48
|
+
</Tag>
|
|
49
|
+
</Heading>
|
|
50
|
+
<Muted variant="p">{description}</Muted>
|
|
51
|
+
{children}
|
|
52
|
+
{error && <ErrorText variant="caption">{error}</ErrorText>}
|
|
53
|
+
</Root>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Button } from '@juliensouki/seedui';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { describeError } from '../errors';
|
|
4
|
+
import { useNamostack } from '../useNamostack';
|
|
5
|
+
import { ExampleCard } from './ExampleCard';
|
|
6
|
+
import { Actions, Code } from './styles';
|
|
7
|
+
|
|
8
|
+
/** Shows a toast in the app, outside the plugin's frame. */
|
|
9
|
+
export function Notify() {
|
|
10
|
+
const { ui } = useNamostack();
|
|
11
|
+
const [error, setError] = useState<string>();
|
|
12
|
+
|
|
13
|
+
const notify = async () => {
|
|
14
|
+
setError(undefined);
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await ui.notify({ message: 'Hello from your plugin!', tone: 'success' });
|
|
18
|
+
} catch (caught) {
|
|
19
|
+
setError(describeError(caught, 'ui:notify'));
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<ExampleCard
|
|
25
|
+
title="Talk to the app"
|
|
26
|
+
permission="ui:notify"
|
|
27
|
+
error={error}
|
|
28
|
+
description={
|
|
29
|
+
<>
|
|
30
|
+
<Code>ui.notify</Code> shows a notification in the app.
|
|
31
|
+
</>
|
|
32
|
+
}
|
|
33
|
+
>
|
|
34
|
+
<Actions>
|
|
35
|
+
<Button size="md" color="neutral" onClick={notify}>
|
|
36
|
+
Say hello
|
|
37
|
+
</Button>
|
|
38
|
+
</Actions>
|
|
39
|
+
</ExampleCard>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Text } from '@juliensouki/seedui';
|
|
2
|
+
import { styled } from '@juliensouki/seedui/sc';
|
|
3
|
+
|
|
4
|
+
export const Actions = styled.div({
|
|
5
|
+
display: 'flex',
|
|
6
|
+
flexWrap: 'wrap',
|
|
7
|
+
gap: 8,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const Code = styled.code((props) => ({
|
|
11
|
+
padding: '1px 6px',
|
|
12
|
+
borderRadius: props.theme.borderRadius(1),
|
|
13
|
+
background:
|
|
14
|
+
props.theme.colors.neutral[props.theme.mode === 'light' ? 200 : 400],
|
|
15
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
16
|
+
fontSize: '0.875em',
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
export const Muted = styled(Text)((props) => ({
|
|
20
|
+
color:
|
|
21
|
+
props.theme.mode === 'light'
|
|
22
|
+
? props.theme.colors.neutral[600]
|
|
23
|
+
: props.theme.colors.neutral[900],
|
|
24
|
+
}));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { BridgeError } from '@namostack/plugin-sdk';
|
|
2
|
+
|
|
3
|
+
/** What to show when an SDK call fails; a missing permission says how to add it. */
|
|
4
|
+
export function describeError(error: unknown, permission: string): string {
|
|
5
|
+
if (error instanceof BridgeError && error.code === 'permission_denied') {
|
|
6
|
+
return `Add "${permission}" to the permissions in namostack.plugin.json.`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return error instanceof BridgeError ? error.message : String(error);
|
|
10
|
+
}
|
|
@@ -6,7 +6,56 @@
|
|
|
6
6
|
<title>{{name}}</title>
|
|
7
7
|
</head>
|
|
8
8
|
<body>
|
|
9
|
-
<
|
|
9
|
+
<main class="page" hidden>
|
|
10
|
+
<header class="header">
|
|
11
|
+
<img class="icon" src="./icon.svg" alt="" />
|
|
12
|
+
<div>
|
|
13
|
+
<h1 class="h3" id="name"></h1>
|
|
14
|
+
<p class="p muted" id="greeting">Hi, this is your new plugin.</p>
|
|
15
|
+
<p class="caption muted" id="greeting-error" hidden></p>
|
|
16
|
+
</div>
|
|
17
|
+
</header>
|
|
18
|
+
|
|
19
|
+
<section class="card">
|
|
20
|
+
<div class="card-heading">
|
|
21
|
+
<h2 class="h5">Save data</h2>
|
|
22
|
+
<span class="tag">storage</span>
|
|
23
|
+
</div>
|
|
24
|
+
<p class="p muted">
|
|
25
|
+
Each click is saved with <code>data.update</code>: reload, the count
|
|
26
|
+
stays.
|
|
27
|
+
</p>
|
|
28
|
+
<p class="h2" id="count">0</p>
|
|
29
|
+
<div class="actions">
|
|
30
|
+
<button class="button" id="increment" disabled>Click me</button>
|
|
31
|
+
<button class="button button-transparent" id="reset" disabled>
|
|
32
|
+
Reset
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
<p class="caption error" id="count-error" hidden></p>
|
|
36
|
+
</section>
|
|
37
|
+
|
|
38
|
+
<section class="card">
|
|
39
|
+
<div class="card-heading">
|
|
40
|
+
<h2 class="h5">Talk to the app</h2>
|
|
41
|
+
<span class="tag">ui:notify</span>
|
|
42
|
+
</div>
|
|
43
|
+
<p class="p muted">
|
|
44
|
+
<code>ui.notify</code> shows a notification in the app.
|
|
45
|
+
</p>
|
|
46
|
+
<div class="actions">
|
|
47
|
+
<button class="button button-neutral" id="notify">Say hello</button>
|
|
48
|
+
</div>
|
|
49
|
+
<p class="caption error" id="notify-error" hidden></p>
|
|
50
|
+
</section>
|
|
51
|
+
|
|
52
|
+
<p class="caption muted">
|
|
53
|
+
Edit <code>src/main.ts</code> and save to see it change, or
|
|
54
|
+
<a href="https://docs.namostack.com" target="_blank" rel="noreferrer"
|
|
55
|
+
>read the docs</a
|
|
56
|
+
>.
|
|
57
|
+
</p>
|
|
58
|
+
</main>
|
|
10
59
|
<script type="module" src="/src/main.ts"></script>
|
|
11
60
|
</body>
|
|
12
61
|
</html>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
-
<rect width="64" height="64" rx="14" fill="#
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#6344e5" />
|
|
3
3
|
<path d="M22 16h13a11 11 0 0 1 0 22h-6v10h-7zm7 6v10h6a5 5 0 0 0 0-10z" fill="#fff" fill-rule="evenodd" />
|
|
4
4
|
</svg>
|
|
@@ -1,7 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
BridgeError,
|
|
3
|
+
connect,
|
|
4
|
+
type PluginTheme,
|
|
5
|
+
type StoredDocument,
|
|
6
|
+
} from '@namostack/plugin-sdk';
|
|
7
|
+
import './style.css';
|
|
8
|
+
|
|
9
|
+
// seedui's fonts, as in the namostack app.
|
|
10
|
+
import '@fontsource-variable/inter';
|
|
11
|
+
import '@fontsource-variable/source-serif-4';
|
|
12
|
+
|
|
13
|
+
interface Clicks {
|
|
14
|
+
count: number;
|
|
15
|
+
}
|
|
2
16
|
|
|
3
17
|
// Plugins only run inside the namostack app: anywhere else, this throws.
|
|
4
18
|
const namostack = await connect();
|
|
19
|
+
const { context, data, ui, user } = namostack;
|
|
20
|
+
|
|
21
|
+
function element<T extends HTMLElement = HTMLElement>(id: string): T {
|
|
22
|
+
return document.getElementById(id) as T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Shows a failed SDK call; a missing permission says how to add it. */
|
|
26
|
+
function showError(target: HTMLElement, error: unknown, permission: string) {
|
|
27
|
+
target.hidden = false;
|
|
28
|
+
target.textContent =
|
|
29
|
+
error instanceof BridgeError && error.code === 'permission_denied'
|
|
30
|
+
? `Add "${permission}" to the permissions in namostack.plugin.json.`
|
|
31
|
+
: error instanceof BridgeError
|
|
32
|
+
? error.message
|
|
33
|
+
: String(error);
|
|
34
|
+
}
|
|
5
35
|
|
|
6
36
|
/** Follows the app's light or dark mode; style against `[data-mode="dark"]`. */
|
|
7
37
|
function applyTheme({ mode }: PluginTheme) {
|
|
@@ -9,9 +39,120 @@ function applyTheme({ mode }: PluginTheme) {
|
|
|
9
39
|
document.documentElement.style.colorScheme = mode;
|
|
10
40
|
}
|
|
11
41
|
|
|
12
|
-
applyTheme(
|
|
42
|
+
applyTheme(context.theme);
|
|
13
43
|
namostack.onThemeChange(applyTheme);
|
|
14
44
|
|
|
15
|
-
// textContent, not innerHTML:
|
|
16
|
-
|
|
17
|
-
|
|
45
|
+
// textContent, not innerHTML: these are the developer's and user's own text.
|
|
46
|
+
element('name').textContent = context.name;
|
|
47
|
+
document.querySelector('main')!.hidden = false;
|
|
48
|
+
|
|
49
|
+
user
|
|
50
|
+
.getProfile()
|
|
51
|
+
.then(({ displayName }) => {
|
|
52
|
+
element('greeting').textContent =
|
|
53
|
+
`Hi ${displayName}, this is your new plugin.`;
|
|
54
|
+
})
|
|
55
|
+
.catch((error: unknown) =>
|
|
56
|
+
showError(element('greeting-error'), error, 'user:profile:read'),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// A counter saved for the current user: it survives reloads and devices.
|
|
60
|
+
const count = element('count');
|
|
61
|
+
const increment = element<HTMLButtonElement>('increment');
|
|
62
|
+
const reset = element<HTMLButtonElement>('reset');
|
|
63
|
+
const countError = element('count-error');
|
|
64
|
+
let clicks: StoredDocument<Clicks> | null = null;
|
|
65
|
+
|
|
66
|
+
function render() {
|
|
67
|
+
count.textContent = String(clicks?.count ?? 0);
|
|
68
|
+
reset.disabled = !clicks;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Like seedui's `isLoading`: keeps the button's size and swaps its label for a spinner. */
|
|
72
|
+
function setLoading(button: HTMLButtonElement, loading: boolean) {
|
|
73
|
+
if (loading) {
|
|
74
|
+
const { width, height } = button.getBoundingClientRect();
|
|
75
|
+
|
|
76
|
+
button.dataset.label = button.textContent ?? '';
|
|
77
|
+
button.style.width = `${width}px`;
|
|
78
|
+
button.style.height = `${height}px`;
|
|
79
|
+
button.replaceChildren(
|
|
80
|
+
Object.assign(document.createElement('div'), { className: 'spinner' }),
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
button.textContent = button.dataset.label ?? '';
|
|
84
|
+
button.style.width = '';
|
|
85
|
+
button.style.height = '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
button.ariaBusy = String(loading);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function run(
|
|
92
|
+
button: HTMLButtonElement,
|
|
93
|
+
change: () => Promise<StoredDocument<Clicks> | null>,
|
|
94
|
+
) {
|
|
95
|
+
const other = button === increment ? reset : increment;
|
|
96
|
+
|
|
97
|
+
setLoading(button, true);
|
|
98
|
+
other.disabled = true;
|
|
99
|
+
countError.hidden = true;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
clicks = await change();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
showError(countError, error, 'storage');
|
|
105
|
+
} finally {
|
|
106
|
+
setLoading(button, false);
|
|
107
|
+
increment.disabled = false;
|
|
108
|
+
render();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
increment.addEventListener('click', () => {
|
|
113
|
+
if (increment.ariaBusy === 'true') {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
void run(increment, () =>
|
|
118
|
+
clicks
|
|
119
|
+
? data.update<Clicks>('clicks', clicks.id, { count: clicks.count + 1 })
|
|
120
|
+
: data.insert<Clicks>('clicks', { count: 1 }),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
reset.addEventListener('click', () => {
|
|
125
|
+
if (reset.ariaBusy === 'true') {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
void run(reset, async () => {
|
|
130
|
+
if (clicks) {
|
|
131
|
+
await data.delete('clicks', clicks.id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
data
|
|
139
|
+
.find<Clicks>('clicks', { limit: 1 })
|
|
140
|
+
.then(({ items }) => {
|
|
141
|
+
clicks = items[0] ?? null;
|
|
142
|
+
})
|
|
143
|
+
.catch((error: unknown) => showError(countError, error, 'storage'))
|
|
144
|
+
.finally(() => {
|
|
145
|
+
increment.disabled = false;
|
|
146
|
+
render();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Shows a toast in the app, outside the plugin's frame.
|
|
150
|
+
element('notify').addEventListener('click', async () => {
|
|
151
|
+
element('notify-error').hidden = true;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await ui.notify({ message: 'Hello from your plugin!', tone: 'success' });
|
|
155
|
+
} catch (error) {
|
|
156
|
+
showError(element('notify-error'), error, 'ui:notify');
|
|
157
|
+
}
|
|
158
|
+
});
|