andesine 0.5.0 → 0.6.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/CHANGELOG.md +13 -0
- package/dist/cli.js +39 -80
- package/dist/cli.js.map +1 -1
- package/dist/templates/extension/README.md +11 -5
- package/dist/templates/extension/andesine.config.ts +21 -17
- package/dist/templates/extension/package.template.json +4 -4
- package/dist/templates/extension/src/backend/server.ts +0 -5
- package/dist/templates/extension/src/frontend/count-words-action.tsx +46 -0
- package/dist/templates/extension/src/frontend/hello-panel.tsx +35 -3
- package/dist/templates/extension/src/frontend/index.tsx +2 -2
- package/dist/templates/extension/src/frontend/note-view.tsx +1 -4
- package/dist/templates/extension/src/frontend/shout-action.tsx +18 -42
- package/dist/templates/extension/src/frontend/wrap-in-note-action.tsx +52 -0
- package/dist/templates/pages/package.template.json +3 -3
- package/dist/templates/pages/src/components/content/copy-button.tsx +1 -1
- package/dist/templates/pages/src/components/layout/call-to-action.tsx +2 -6
- package/dist/templates/pages/src/components/layout/footer.astro +1 -2
- package/dist/templates/pages/src/components/layout/header.astro +0 -3
- package/dist/templates/pages/src/components/layout/mobile-navigation.tsx +1 -1
- package/dist/templates/pages/src/components/layout/page-actions.tsx +1 -7
- package/dist/templates/pages/src/components/primitives/button.tsx +21 -37
- package/dist/templates/pages/src/components/search/search-palette.tsx +1 -3
- package/package.json +4 -4
- package/dist/templates/extension/src/frontend/about-section.tsx +0 -47
- package/dist/templates/extension/src/frontend/bookmarks-panel.tsx +0 -67
|
@@ -12,11 +12,17 @@ An Andesine extension with a Solid frontend and a Node backend.
|
|
|
12
12
|
Each frontend module shows one kind of contribution; remove the ones you do not need from the manifest and `index.tsx`.
|
|
13
13
|
|
|
14
14
|
- `note-view.tsx` — element views: `<Note>` with its editable content in `ContentSlot`, a tone switch that edits the element's props, and a `<NoteTitle>` descendant view.
|
|
15
|
-
- `shout-action.tsx`
|
|
16
|
-
- `
|
|
17
|
-
- `
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
- Block actions for paragraphs and headings; each view decides its UI: `shout-action.tsx` runs without UI (the block menu shows a spinner until it closes), `count-words-action.tsx` shows a `Menu` in the block menu's place with the count and “Insert below”, and `wrap-in-note-action.tsx` asks for the tone in a `Dialog`, then wraps the blocks in a `<Note>`.
|
|
16
|
+
- `hello-panel.tsx` — a right panel for the open entry: configuration, an API call with the member's granted permissions, a call to the backend, and a “More” `Menu` with a copy action and an “About” `Dialog`.
|
|
17
|
+
- `src/backend/server.ts` — lifecycle and `entry.created` webhooks, session verification, and the secret `apiToken` field, read with the extension's credentials.
|
|
18
|
+
|
|
19
|
+
## Local setup
|
|
20
|
+
|
|
21
|
+
Extensions are developed against a fully local Andesine stack; the hosted app has no development mode.
|
|
22
|
+
|
|
23
|
+
1. On the local stack, set `PUBLIC_EXTENSIONS_ENABLED=true` and `EXTENSIONS_DEVELOPMENT_ENABLED=true` for the backend and the web app. For webhooks to reach a backend on your machine, also allow local HTTP destinations: `WEBHOOK_ALLOW_HTTP=true` and a `WEBHOOK_DESTINATION_EXCEPTIONS` entry for the backend's origin.
|
|
24
|
+
2. Sign in the CLI to the local instance (API keys cannot develop extensions): `npx andesine --base-url http://localhost:3333 auth login`.
|
|
25
|
+
3. Start the backend with `npm run backend`, then `npm run dev`. Only you see and run the development extension; `npm run dev -- --remove` uninstalls it.
|
|
20
26
|
|
|
21
27
|
## Commands
|
|
22
28
|
|
|
@@ -8,17 +8,13 @@ export default defineExtension({
|
|
|
8
8
|
name: "example/hello",
|
|
9
9
|
version: "0.1.0",
|
|
10
10
|
displayName: "Hello",
|
|
11
|
-
description:
|
|
12
|
-
"Samples of every extension feature: views, panels, actions, settings, and a backend.",
|
|
11
|
+
description: "Samples of extension features: views, a panel, an action, settings, and a backend.",
|
|
13
12
|
icon: "i-lucide:hand",
|
|
14
13
|
apiVersion: 1,
|
|
15
14
|
// The API permissions that members approve; the frontend gets them only where members have them.
|
|
16
15
|
permissions: ["read:entries"],
|
|
17
|
-
// Other URLs that the frontend can request, link to, and open without confirmation.
|
|
18
|
-
requests: ["https://andesine.app/"],
|
|
19
16
|
backend: {
|
|
20
|
-
//
|
|
21
|
-
// local backend instead (`--backend` in package.json).
|
|
17
|
+
// The deployed src/backend (HTTPS only); `npm run dev` uses the local one (see package.json).
|
|
22
18
|
url: "https://hello.example.com",
|
|
23
19
|
keys: backendKeys.keys
|
|
24
20
|
},
|
|
@@ -40,12 +36,15 @@ export default defineExtension({
|
|
|
40
36
|
{
|
|
41
37
|
id: "note",
|
|
42
38
|
name: "Note",
|
|
39
|
+
// Shown in the editor's slash menu, under Elements.
|
|
40
|
+
description: "A highlighted note with a title and a tone",
|
|
41
|
+
icon: "i-lucide:sticky-note",
|
|
43
42
|
element: "Note",
|
|
44
43
|
entry: "NoteView",
|
|
45
44
|
descendants: [{ element: "NoteTitle", entry: "NoteTitleView" }]
|
|
46
45
|
}
|
|
47
46
|
],
|
|
48
|
-
// Shown in the block menu of the listed block types.
|
|
47
|
+
// Shown in the block menu of the listed block types; each view decides its own UI.
|
|
49
48
|
blockActions: [
|
|
50
49
|
{
|
|
51
50
|
id: "shout",
|
|
@@ -53,16 +52,23 @@ export default defineExtension({
|
|
|
53
52
|
icon: "i-lucide:megaphone",
|
|
54
53
|
blocks: ["paragraph", "heading"],
|
|
55
54
|
entry: "ShoutAction"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "count-words",
|
|
58
|
+
label: "Count words",
|
|
59
|
+
icon: "i-lucide:hash",
|
|
60
|
+
blocks: ["paragraph", "heading"],
|
|
61
|
+
entry: "CountWordsAction"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "wrap-in-note",
|
|
65
|
+
label: "Wrap in Note",
|
|
66
|
+
icon: "i-lucide:sticky-note",
|
|
67
|
+
blocks: ["paragraph", "heading"],
|
|
68
|
+
entry: "WrapInNoteAction"
|
|
56
69
|
}
|
|
57
70
|
],
|
|
58
71
|
panels: [
|
|
59
|
-
{
|
|
60
|
-
id: "bookmarks",
|
|
61
|
-
side: "left",
|
|
62
|
-
name: "Bookmarks",
|
|
63
|
-
icon: "i-lucide:bookmark",
|
|
64
|
-
entry: "BookmarksPanel"
|
|
65
|
-
},
|
|
66
72
|
{
|
|
67
73
|
id: "hello",
|
|
68
74
|
side: "right",
|
|
@@ -71,9 +77,7 @@ export default defineExtension({
|
|
|
71
77
|
context: "entry",
|
|
72
78
|
entry: "HelloPanel"
|
|
73
79
|
}
|
|
74
|
-
]
|
|
75
|
-
// Shown in the extension's settings, below the configuration form.
|
|
76
|
-
settingsSections: [{ id: "about", heading: "About", entry: "AboutSection" }]
|
|
80
|
+
]
|
|
77
81
|
});
|
|
78
82
|
|
|
79
83
|
export const build = defineBuild({ frontend: "src/frontend/index.tsx" });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "andesine-extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"scripts": {
|
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
"node": ">=22.12.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@andesine/extensions": "0.
|
|
17
|
-
"@andesine/sdk": "0.
|
|
16
|
+
"@andesine/extensions": "0.6.0",
|
|
17
|
+
"@andesine/sdk": "0.6.0",
|
|
18
18
|
"solid-js": "^1.9.14",
|
|
19
19
|
"zod": "4.4.3"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "26.1.2",
|
|
23
|
-
"andesine": "0.
|
|
23
|
+
"andesine": "0.6.0",
|
|
24
24
|
"tsx": "^4.23.5",
|
|
25
25
|
"typescript": "6.0.3"
|
|
26
26
|
}
|
|
@@ -57,13 +57,8 @@ const server = createServer(async (request, response) => {
|
|
|
57
57
|
const session = await extension.verifySession(request.headers);
|
|
58
58
|
// Secret fields are readable only here, with the extension's own credentials.
|
|
59
59
|
const { values } = await session.client.extensions.getSelfConfiguration();
|
|
60
|
-
// The frontend's storage entries, e.g. the bookmarks panel.
|
|
61
|
-
const bookmarks = await session.client.extensions
|
|
62
|
-
.getSelfStorageEntry({ key: "bookmarks" })
|
|
63
|
-
.catch(() => null);
|
|
64
60
|
|
|
65
61
|
console.log(`API token ${values.apiToken ? "set" : "not set"}`);
|
|
66
|
-
console.log(`Bookmarks: ${JSON.stringify(bookmarks?.value ?? [])}`);
|
|
67
62
|
send(response, 200, {
|
|
68
63
|
name: session.member.profile.name ?? session.member.profile.email ?? "member"
|
|
69
64
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ContentNode,
|
|
3
|
+
Menu,
|
|
4
|
+
MenuContent,
|
|
5
|
+
MenuItem,
|
|
6
|
+
MenuSeparator,
|
|
7
|
+
Text,
|
|
8
|
+
useEditor
|
|
9
|
+
} from "@andesine/extensions/solid";
|
|
10
|
+
|
|
11
|
+
interface BlockActionProps {
|
|
12
|
+
blocks: ContentNode[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const countWords = (node: ContentNode): number => {
|
|
16
|
+
const own = node.text?.split(/\s+/).filter(Boolean).length ?? 0;
|
|
17
|
+
|
|
18
|
+
return own + (node.content ?? []).reduce((total, child) => total + countWords(child), 0);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A block action menu where the block menu was: a custom item with the count and a structured
|
|
23
|
+
* item. Escape or a click outside closes it; after a choice, the action runs until it closes.
|
|
24
|
+
*/
|
|
25
|
+
export const CountWordsAction = (props: BlockActionProps) => {
|
|
26
|
+
const editor = useEditor();
|
|
27
|
+
const words = () => props.blocks.reduce((total, block) => total + countWords(block), 0);
|
|
28
|
+
const insert = async () => {
|
|
29
|
+
await editor.insertAfter([
|
|
30
|
+
{ type: "paragraph", content: [{ type: "text", text: `${words()} words.` }] }
|
|
31
|
+
]);
|
|
32
|
+
await editor.close();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Menu size="medium">
|
|
37
|
+
<MenuContent>
|
|
38
|
+
<Text size="sm" tone="muted" class="px-2 py-1">
|
|
39
|
+
{words()} words
|
|
40
|
+
</Text>
|
|
41
|
+
</MenuContent>
|
|
42
|
+
<MenuSeparator />
|
|
43
|
+
<MenuItem label="Insert below" icon="i-lucide:list-plus" onSelect={insert} />
|
|
44
|
+
</Menu>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
@@ -3,8 +3,15 @@ import {
|
|
|
3
3
|
apiFetch,
|
|
4
4
|
backend,
|
|
5
5
|
Button,
|
|
6
|
+
copyText,
|
|
7
|
+
Dialog,
|
|
6
8
|
EXTENSION_API_URL,
|
|
7
9
|
hasPermission,
|
|
10
|
+
IconButton,
|
|
11
|
+
Menu,
|
|
12
|
+
MenuItem,
|
|
13
|
+
MenuTrigger,
|
|
14
|
+
notify,
|
|
8
15
|
Stack,
|
|
9
16
|
Text,
|
|
10
17
|
useConfiguration
|
|
@@ -24,11 +31,17 @@ export const HelloPanel = (props: HelloPanelProps) => {
|
|
|
24
31
|
const configuration = useConfiguration<{ greeting: string }>();
|
|
25
32
|
const [name, setName] = createSignal<string | null>(null);
|
|
26
33
|
const [failed, setFailed] = createSignal(false);
|
|
34
|
+
const [aboutOpened, setAboutOpened] = createSignal(false);
|
|
27
35
|
const [entry] = createResource(
|
|
28
36
|
() => (hasPermission("read:entries") ? props.entryID : undefined),
|
|
29
37
|
// A failed request would end the view, so the entry line stays hidden instead.
|
|
30
38
|
(id) => client.entries.get({ id }).catch(() => null)
|
|
31
39
|
);
|
|
40
|
+
const greeting = () => `${configuration().greeting ?? "Hello"}, ${name() ?? "member"}!`;
|
|
41
|
+
const copyGreeting = async () => {
|
|
42
|
+
await copyText(greeting());
|
|
43
|
+
await notify("Greeting copied");
|
|
44
|
+
};
|
|
32
45
|
const askBackend = async () => {
|
|
33
46
|
try {
|
|
34
47
|
const response = await backend.fetch("/hello", { method: "POST" });
|
|
@@ -42,9 +55,28 @@ export const HelloPanel = (props: HelloPanelProps) => {
|
|
|
42
55
|
|
|
43
56
|
return (
|
|
44
57
|
<Stack gap="small">
|
|
45
|
-
<
|
|
46
|
-
{
|
|
47
|
-
|
|
58
|
+
<Stack direction="row" justify="between" align="center">
|
|
59
|
+
<Text>{greeting()}</Text>
|
|
60
|
+
{/* Menus and dialogs work in any view; a dialog opens only right after an interaction. */}
|
|
61
|
+
<Menu>
|
|
62
|
+
<MenuTrigger>
|
|
63
|
+
<IconButton icon="i-lucide:ellipsis" label="More" />
|
|
64
|
+
</MenuTrigger>
|
|
65
|
+
<MenuItem label="Copy greeting" icon="i-lucide:copy" onSelect={copyGreeting} />
|
|
66
|
+
<MenuItem
|
|
67
|
+
label="About this panel"
|
|
68
|
+
icon="i-lucide:info"
|
|
69
|
+
onSelect={() => setAboutOpened(true)}
|
|
70
|
+
/>
|
|
71
|
+
</Menu>
|
|
72
|
+
</Stack>
|
|
73
|
+
<Show when={aboutOpened()}>
|
|
74
|
+
<Dialog title="Hello panel" size="small" onClose={() => setAboutOpened(false)}>
|
|
75
|
+
<Text size="sm">
|
|
76
|
+
A sample panel: it reads the open entry and asks the extension's backend who you are.
|
|
77
|
+
</Text>
|
|
78
|
+
</Dialog>
|
|
79
|
+
</Show>
|
|
48
80
|
<Show when={entry()}>
|
|
49
81
|
{(loaded) => (
|
|
50
82
|
<Text size="sm" tone="muted">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Every view entry of the manifest is a named export of this module.
|
|
2
|
-
export { AboutSection } from "./about-section";
|
|
3
|
-
export { BookmarksPanel } from "./bookmarks-panel";
|
|
4
2
|
export { HelloPanel } from "./hello-panel";
|
|
5
3
|
export { NoteTitleView, NoteView } from "./note-view";
|
|
4
|
+
export { CountWordsAction } from "./count-words-action";
|
|
6
5
|
export { ShoutAction } from "./shout-action";
|
|
6
|
+
export { WrapInNoteAction } from "./wrap-in-note-action";
|
|
@@ -20,10 +20,7 @@ const tones = [
|
|
|
20
20
|
{ value: "warning", label: "Warning" }
|
|
21
21
|
];
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
* The view of `<Note>` elements. `ContentSlot` shows the element's editable content, which
|
|
25
|
-
* Andesine keeps; the toggle edits the element's props as the member's own edit.
|
|
26
|
-
*/
|
|
23
|
+
/** The `<Note>` view: `ContentSlot` shows the editable content; the toggle edits element props. */
|
|
27
24
|
export const NoteView = (props: ElementViewProps) => {
|
|
28
25
|
const editor = useEditor();
|
|
29
26
|
const tone = () => props.props.tone ?? "info";
|
|
@@ -1,57 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { type ContentNode, useEditor } from "@andesine/extensions/solid";
|
|
2
|
+
import { onMount } from "solid-js";
|
|
3
3
|
|
|
4
4
|
interface BlockActionProps {
|
|
5
5
|
/** The selected blocks as editor JSON. */
|
|
6
6
|
blocks: ContentNode[];
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
const shout = (node: ContentNode): ContentNode =>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
const own = node.text?.split(/\s+/).filter(Boolean).length ?? 0;
|
|
16
|
-
|
|
17
|
-
return own + (node.content ?? []).reduce((total, child) => total + countWords(child), 0);
|
|
9
|
+
const shout = (node: ContentNode): ContentNode => {
|
|
10
|
+
return {
|
|
11
|
+
...node,
|
|
12
|
+
...(node.text !== undefined && { text: node.text.toUpperCase() }),
|
|
13
|
+
...(node.content && { content: node.content.map(shout) })
|
|
14
|
+
};
|
|
18
15
|
};
|
|
19
16
|
|
|
20
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* A block action without UI: it renders nothing, so the block menu shows a spinner until it
|
|
19
|
+
* closes. Andesine shows an error if the edit fails.
|
|
20
|
+
*/
|
|
21
21
|
export const ShoutAction = (props: BlockActionProps) => {
|
|
22
22
|
const editor = useEditor();
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const run = async (edit: () => Promise<void>) => {
|
|
23
|
+
|
|
24
|
+
onMount(async () => {
|
|
26
25
|
try {
|
|
27
|
-
await
|
|
26
|
+
await editor.replace(props.blocks.map(shout));
|
|
27
|
+
} finally {
|
|
28
28
|
await editor.close();
|
|
29
|
-
} catch (failure) {
|
|
30
|
-
setError(failure instanceof Error ? failure.message : "The edit failed");
|
|
31
29
|
}
|
|
32
|
-
};
|
|
30
|
+
});
|
|
33
31
|
|
|
34
|
-
return
|
|
35
|
-
<Stack gap="small">
|
|
36
|
-
<Text size="sm">{words()} words selected.</Text>
|
|
37
|
-
<Stack direction="row" gap="small">
|
|
38
|
-
<Button color="primary" onClick={() => run(() => editor.replace(props.blocks.map(shout)))}>
|
|
39
|
-
Shout
|
|
40
|
-
</Button>
|
|
41
|
-
<Button
|
|
42
|
-
variant="outlined"
|
|
43
|
-
onClick={() => {
|
|
44
|
-
return run(() => {
|
|
45
|
-
return editor.insertAfter([
|
|
46
|
-
{ type: "paragraph", content: [{ type: "text", text: `${words()} words.` }] }
|
|
47
|
-
]);
|
|
48
|
-
});
|
|
49
|
-
}}
|
|
50
|
-
>
|
|
51
|
-
Count below
|
|
52
|
-
</Button>
|
|
53
|
-
</Stack>
|
|
54
|
-
<Show when={error()}>{(message) => <Text tone="danger">{message()}</Text>}</Show>
|
|
55
|
-
</Stack>
|
|
56
|
-
);
|
|
32
|
+
return null;
|
|
57
33
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
type ContentNode,
|
|
4
|
+
Dialog,
|
|
5
|
+
Stack,
|
|
6
|
+
Text,
|
|
7
|
+
ToggleGroup,
|
|
8
|
+
useEditor
|
|
9
|
+
} from "@andesine/extensions/solid";
|
|
10
|
+
import { createSignal, Show } from "solid-js";
|
|
11
|
+
|
|
12
|
+
interface BlockActionProps {
|
|
13
|
+
blocks: ContentNode[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const tones = [
|
|
17
|
+
{ value: "info", label: "Info" },
|
|
18
|
+
{ value: "warning", label: "Warning" }
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** A block action in a dialog: it asks for the tone, then wraps the blocks in a `<Note>`. */
|
|
22
|
+
export const WrapInNoteAction = (props: BlockActionProps) => {
|
|
23
|
+
const editor = useEditor();
|
|
24
|
+
const [tone, setTone] = createSignal("info");
|
|
25
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
26
|
+
const wrap = async () => {
|
|
27
|
+
try {
|
|
28
|
+
// Andesine derives the element's tag from its name and props.
|
|
29
|
+
await editor.replace([
|
|
30
|
+
{ type: "element", attrs: { name: "Note", props: { tone: tone() } }, content: props.blocks }
|
|
31
|
+
]);
|
|
32
|
+
await editor.close();
|
|
33
|
+
} catch (failure) {
|
|
34
|
+
setError(failure instanceof Error ? failure.message : "The edit failed");
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Dialog title="Wrap in Note" description="Choose the note's tone." size="small">
|
|
40
|
+
<Stack gap="medium">
|
|
41
|
+
<ToggleGroup value={tone()} options={tones} onChange={setTone} />
|
|
42
|
+
<Show when={error()}>{(message) => <Text tone="danger">{message()}</Text>}</Show>
|
|
43
|
+
<Stack direction="row" gap="small" justify="end">
|
|
44
|
+
<Button onClick={() => editor.close()}>Cancel</Button>
|
|
45
|
+
<Button variant="primary" onClick={wrap}>
|
|
46
|
+
Wrap
|
|
47
|
+
</Button>
|
|
48
|
+
</Stack>
|
|
49
|
+
</Stack>
|
|
50
|
+
</Dialog>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "andesine-pages-site",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"scripts": {
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"node": ">=24"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@andesine/pages": "0.
|
|
17
|
-
"@andesine/ui": "0.
|
|
16
|
+
"@andesine/pages": "0.6.0",
|
|
17
|
+
"@andesine/ui": "0.6.0",
|
|
18
18
|
"@ark-ui/solid": "5.38.0",
|
|
19
19
|
"@astrojs/mdx": "8.0.1",
|
|
20
20
|
"@astrojs/node": "11.1.6",
|
|
@@ -13,16 +13,12 @@ const CallToAction: Component<CallToActionProps> = (props) =>
|
|
|
13
13
|
link={props.link.href}
|
|
14
14
|
icon={props.link.icon}
|
|
15
15
|
label={props.link.label}
|
|
16
|
-
|
|
17
|
-
variant="outlined"
|
|
18
|
-
size="small"
|
|
16
|
+
variant="primary"
|
|
19
17
|
class="flex-row-reverse gap-1 pr-1.5"
|
|
20
18
|
iconClass="opacity-50 h-4.5 w-4.5"
|
|
21
19
|
/>
|
|
22
20
|
) : (
|
|
23
|
-
<Button link={props.link.href}
|
|
24
|
-
{props.link.label}
|
|
25
|
-
</Button>
|
|
21
|
+
<Button link={props.link.href}>{props.link.label}</Button>
|
|
26
22
|
);
|
|
27
23
|
|
|
28
24
|
export { CallToAction };
|
|
@@ -25,7 +25,7 @@ const MobileNavigation: Component<MobileNavigationProps> = (props) => (
|
|
|
25
25
|
aria-label="Navigation"
|
|
26
26
|
class="fixed bottom-[max(1rem,env(safe-area-inset-bottom))] right-4 z-40 cursor-pointer list-none rounded-lg focus-visible:(outline outline-2 outline-tertiary) [&::-webkit-details-marker]:hidden"
|
|
27
27
|
>
|
|
28
|
-
<Button badge
|
|
28
|
+
<Button badge variant="secondary" text="softer" size="medium" class="p-1.5">
|
|
29
29
|
<span aria-hidden="true" class="i-lucide:menu h-5 w-5 group-open/menu:hidden" />
|
|
30
30
|
<span aria-hidden="true" class="i-lucide:x hidden h-5 w-5 group-open/menu:block" />
|
|
31
31
|
</Button>
|
|
@@ -53,9 +53,7 @@ const PageActions: Component<PageActionsProps> = (props) => {
|
|
|
53
53
|
return (
|
|
54
54
|
<div class="flex shrink-0 gap-1">
|
|
55
55
|
<Button
|
|
56
|
-
|
|
57
|
-
variant="outlined"
|
|
58
|
-
size="small"
|
|
56
|
+
variant="secondary"
|
|
59
57
|
text="base"
|
|
60
58
|
class="gap-1.5"
|
|
61
59
|
aria-label="Copy page as Markdown"
|
|
@@ -72,10 +70,6 @@ const PageActions: Component<PageActionsProps> = (props) => {
|
|
|
72
70
|
<IconButton
|
|
73
71
|
{...triggerProps()}
|
|
74
72
|
icon="i-lucide:chevron-down"
|
|
75
|
-
color="contrast"
|
|
76
|
-
variant="outlined"
|
|
77
|
-
size="small"
|
|
78
|
-
text="soft"
|
|
79
73
|
aria-label="More page actions"
|
|
80
74
|
/>
|
|
81
75
|
)}
|
|
@@ -3,9 +3,10 @@ import { type JSX, type ParentComponent, mergeProps, Show, splitProps } from "so
|
|
|
3
3
|
import { Dynamic } from "solid-js/web";
|
|
4
4
|
|
|
5
5
|
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
6
|
-
|
|
6
|
+
/** Defaults to `primary` for buttons and `secondary` for icon buttons. */
|
|
7
7
|
variant?: ButtonVariant;
|
|
8
8
|
size?: ButtonSize;
|
|
9
|
+
/** The gray level of `secondary` and `ghost` buttons; icon buttons default to `soft`. */
|
|
9
10
|
text?: ButtonText;
|
|
10
11
|
/** Renders an `<a>` element. */
|
|
11
12
|
link?: string;
|
|
@@ -20,8 +21,7 @@ interface IconButtonProps extends ButtonProps {
|
|
|
20
21
|
iconClass?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
type
|
|
24
|
-
type ButtonVariant = "solid" | "text" | "outlined";
|
|
24
|
+
type ButtonVariant = "primary" | "secondary" | "ghost";
|
|
25
25
|
type ButtonSize = "xs" | "small" | "medium";
|
|
26
26
|
type ButtonText = "base" | "soft" | "softer";
|
|
27
27
|
|
|
@@ -30,15 +30,19 @@ const sizeClasses: Record<ButtonSize, string> = {
|
|
|
30
30
|
small: ":base: px-1.5 py-1 text-sm",
|
|
31
31
|
medium: ":base: px-2 py-1 text-base"
|
|
32
32
|
};
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
primary: ":base: bg-gradient-to-tr bg-[length:125%_auto] text-white outline-tertiary"
|
|
37
|
-
};
|
|
33
|
+
const outlinedClasses =
|
|
34
|
+
":base: outline outline-1 -outline-offset-1 shadow-md focus-visible:outline-1";
|
|
35
|
+
// Keyboard focus shows the hover style.
|
|
38
36
|
const variantClasses: Record<ButtonVariant, string> = {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
primary: clsx(
|
|
38
|
+
outlinedClasses,
|
|
39
|
+
":base: bg-gradient-to-tr bg-[length:125%_auto] text-white outline-tertiary @hover:bg-right focus-visible:bg-right"
|
|
40
|
+
),
|
|
41
|
+
secondary: clsx(
|
|
42
|
+
outlinedClasses,
|
|
43
|
+
":base: bg-white outline-gray-200 shadow-gray-200 @hover:bg-gray-100 focus-visible:bg-gray-100"
|
|
44
|
+
),
|
|
45
|
+
ghost: ":base: bg-transparent focus:outline-none @hover:bg-gray-200 focus-visible:bg-gray-200"
|
|
42
46
|
};
|
|
43
47
|
const textClasses: Record<ButtonText, string> = {
|
|
44
48
|
base: ":base: text-gray-700",
|
|
@@ -51,28 +55,9 @@ const iconButtonSizes: Record<ButtonSize, Record<"button" | "icon" | "label", st
|
|
|
51
55
|
medium: { button: ":base-2: p-1", icon: ":base: h-6 w-6", label: ":base: pl-1" }
|
|
52
56
|
};
|
|
53
57
|
|
|
54
|
-
// Keyboard focus shows the hover style.
|
|
55
|
-
const getHoverClasses = (color: ButtonColor, variant: ButtonVariant): string => {
|
|
56
|
-
if (color === "primary") return ":base: @hover:bg-right focus-visible:bg-right";
|
|
57
|
-
if (color === "contrast") return ":base: @hover:bg-gray-100 focus-visible:bg-gray-100";
|
|
58
|
-
if (variant === "text") return ":base: @hover:bg-gray-200 focus-visible:bg-gray-200";
|
|
59
|
-
|
|
60
|
-
return ":base: @hover:(shadow-inner outline-gray-300) focus-visible:(shadow-inner outline-gray-300)";
|
|
61
|
-
};
|
|
62
58
|
const Button: ParentComponent<ButtonProps> = (providedProps) => {
|
|
63
|
-
const props = mergeProps(
|
|
64
|
-
|
|
65
|
-
providedProps
|
|
66
|
-
);
|
|
67
|
-
const [local, rest] = splitProps(props, [
|
|
68
|
-
"class",
|
|
69
|
-
"color",
|
|
70
|
-
"variant",
|
|
71
|
-
"size",
|
|
72
|
-
"text",
|
|
73
|
-
"link",
|
|
74
|
-
"badge"
|
|
75
|
-
]);
|
|
59
|
+
const props = mergeProps({ variant: "primary", size: "small" } as const, providedProps);
|
|
60
|
+
const [local, rest] = splitProps(props, ["class", "variant", "size", "text", "link", "badge"]);
|
|
76
61
|
const component = (): "a" | "div" | "button" => {
|
|
77
62
|
if (local.link) return "a";
|
|
78
63
|
if (local.badge) return "div";
|
|
@@ -91,20 +76,19 @@ const Button: ParentComponent<ButtonProps> = (providedProps) => {
|
|
|
91
76
|
!local.badge && ":base: cursor-pointer",
|
|
92
77
|
sizeClasses[local.size],
|
|
93
78
|
variantClasses[local.variant],
|
|
94
|
-
local.variant !== "
|
|
95
|
-
local.text && textClasses[local.text],
|
|
96
|
-
getHoverClasses(local.color, local.variant),
|
|
79
|
+
local.text && local.variant !== "primary" && textClasses[local.text],
|
|
97
80
|
local.class
|
|
98
81
|
)}
|
|
99
82
|
/>
|
|
100
83
|
);
|
|
101
84
|
};
|
|
102
85
|
const IconButton: ParentComponent<IconButtonProps> = (providedProps) => {
|
|
103
|
-
const props = mergeProps({ size: "
|
|
86
|
+
const props = mergeProps({ variant: "secondary", size: "small" } as const, providedProps);
|
|
104
87
|
const [local, rest] = splitProps(props, ["class", "icon", "label", "iconClass", "children"]);
|
|
88
|
+
const text = () => props.text ?? (props.variant === "primary" ? undefined : "soft");
|
|
105
89
|
|
|
106
90
|
return (
|
|
107
|
-
<Button {...rest} class={clsx(iconButtonSizes[props.size].button, local.class)}>
|
|
91
|
+
<Button {...rest} text={text()} class={clsx(iconButtonSizes[props.size].button, local.class)}>
|
|
108
92
|
<span
|
|
109
93
|
aria-hidden="true"
|
|
110
94
|
class={clsx("shrink-0", iconButtonSizes[props.size].icon, local.icon, local.iconClass)}
|
|
@@ -93,9 +93,7 @@ const SearchPalette: Component<SearchPaletteProps> = (props) => {
|
|
|
93
93
|
<Tooltip content="Close">
|
|
94
94
|
<IconButton
|
|
95
95
|
icon="i-lucide:x"
|
|
96
|
-
variant="
|
|
97
|
-
text="soft"
|
|
98
|
-
size="small"
|
|
96
|
+
variant="ghost"
|
|
99
97
|
aria-label="Close search"
|
|
100
98
|
onClick={() => palette.setOpen(false)}
|
|
101
99
|
/>
|