@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.
@@ -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
- <Text variant="h3">Welcome to your namostack plugin {context.name}</Text>
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
- <div id="root"></div>
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>
@@ -7,6 +7,8 @@
7
7
  "typecheck": "tsc"
8
8
  },
9
9
  "dependencies": {
10
+ "@fontsource-variable/inter": "^5.3.0",
11
+ "@fontsource-variable/source-serif-4": "^5.3.1",
10
12
  "@namostack/plugin-sdk": "workspace:*"
11
13
  },
12
14
  "devDependencies": {
@@ -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="#4f46e5" />
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 { connect, type PluginTheme } from '@namostack/plugin-sdk';
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(namostack.context.theme);
42
+ applyTheme(context.theme);
13
43
  namostack.onThemeChange(applyTheme);
14
44
 
15
- // textContent, not innerHTML: the name is the developer's own text.
16
- document.getElementById('root')!.textContent =
17
- `Welcome to your namostack plugin ${namostack.context.name}`;
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
+ });