@indietabletop/appkit 3.6.0-2 → 4.0.0-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.
Files changed (60) hide show
  1. package/lib/AppConfig/AppConfig.tsx +48 -0
  2. package/lib/AuthCard/AuthCard.stories.ts +38 -0
  3. package/lib/AuthCard/AuthCard.tsx +64 -0
  4. package/lib/AuthCard/style.css.ts +49 -0
  5. package/lib/DialogTrigger/index.tsx +2 -2
  6. package/lib/DocumentTitle/DocumentTitle.tsx +9 -0
  7. package/lib/HistoryState.ts +21 -0
  8. package/lib/Letterhead/style.css.ts +2 -0
  9. package/lib/LetterheadForm/index.tsx +53 -0
  10. package/lib/LetterheadForm/style.css.ts +8 -0
  11. package/lib/LoadingIndicator.tsx +1 -0
  12. package/lib/MiddotSeparated/MiddotSeparated.stories.ts +32 -0
  13. package/lib/MiddotSeparated/MiddotSeparated.tsx +24 -0
  14. package/lib/MiddotSeparated/style.css.ts +10 -0
  15. package/lib/ModernIDB/bindings/factory.tsx +8 -3
  16. package/lib/QRCode/QRCode.stories.tsx +47 -0
  17. package/lib/QRCode/QRCode.tsx +49 -0
  18. package/lib/QRCode/style.css.ts +19 -0
  19. package/lib/ShareButton/ShareButton.tsx +141 -0
  20. package/lib/SubscribeCard/LetterheadInfoCard.tsx +23 -0
  21. package/lib/SubscribeCard/SubscribeByEmailCard.stories.tsx +83 -0
  22. package/lib/SubscribeCard/SubscribeByEmailCard.tsx +177 -0
  23. package/lib/SubscribeCard/SubscribeCard.stories.tsx +27 -23
  24. package/lib/SubscribeCard/SubscribeCard.tsx +17 -16
  25. package/lib/account/AccountIssueView.tsx +40 -0
  26. package/lib/account/AlreadyLoggedInView.tsx +44 -0
  27. package/lib/account/CurrentUserFetcher.stories.tsx +325 -0
  28. package/lib/account/CurrentUserFetcher.tsx +133 -0
  29. package/lib/account/FailureFallbackView.tsx +36 -0
  30. package/lib/account/JoinCard.stories.tsx +264 -0
  31. package/lib/account/JoinCard.tsx +291 -0
  32. package/lib/account/LoadingView.tsx +14 -0
  33. package/lib/account/LoginCard.stories.tsx +316 -0
  34. package/lib/account/LoginCard.tsx +141 -0
  35. package/lib/account/LoginView.tsx +136 -0
  36. package/lib/account/NoConnectionView.tsx +34 -0
  37. package/lib/account/PasswordResetCard.stories.tsx +247 -0
  38. package/lib/account/PasswordResetCard.tsx +296 -0
  39. package/lib/account/UserMismatchView.tsx +61 -0
  40. package/lib/account/VerifyPage.tsx +217 -0
  41. package/lib/account/style.css.ts +57 -0
  42. package/lib/account/types.ts +9 -0
  43. package/lib/account/useCurrentUserResult.tsx +38 -0
  44. package/lib/class-names.ts +1 -1
  45. package/lib/client.ts +54 -7
  46. package/lib/globals.css.ts +9 -0
  47. package/lib/hrefs.ts +48 -0
  48. package/lib/idToDate.ts +8 -0
  49. package/lib/index.ts +19 -1
  50. package/lib/mailto.test.ts +66 -0
  51. package/lib/mailto.ts +40 -0
  52. package/lib/types.ts +17 -0
  53. package/lib/useEnsureValue.ts +31 -0
  54. package/lib/utm.ts +89 -0
  55. package/package.json +3 -2
  56. package/lib/ClientContext/ClientContext.tsx +0 -25
  57. package/lib/LoginPage/LoginPage.stories.tsx +0 -107
  58. package/lib/LoginPage/LoginPage.tsx +0 -204
  59. package/lib/LoginPage/style.css.ts +0 -17
  60. package/lib/Title/index.tsx +0 -4
@@ -0,0 +1,48 @@
1
+ import { createContext, type ReactNode, useContext } from "react";
2
+ import type { IndieTabletopClient } from "../client.ts";
3
+ import type { AppHrefs } from "../hrefs.ts";
4
+
5
+ export type AppConfig = {
6
+ appName: string;
7
+ client: IndieTabletopClient;
8
+ hrefs: AppHrefs;
9
+ placeholders: {
10
+ email: string;
11
+ };
12
+ };
13
+
14
+ const AppConfigContext = createContext<AppConfig | null>(null);
15
+
16
+ export function AppConfigProvider(props: {
17
+ config: AppConfig;
18
+ children: ReactNode;
19
+ }) {
20
+ const { config, children } = props;
21
+ return <AppConfigContext value={config}>{children}</AppConfigContext>;
22
+ }
23
+
24
+ export function useAppConfig() {
25
+ const config = useContext(AppConfigContext);
26
+
27
+ if (!config) {
28
+ throw new Error(
29
+ `Attempting to retrieve app config, but none was found within context. ` +
30
+ `Make sure that AppConfigProvider is used in the component hierarchy.`,
31
+ );
32
+ }
33
+
34
+ return config;
35
+ }
36
+
37
+ export function useClient() {
38
+ const config = useContext(AppConfigContext);
39
+
40
+ if (!config?.client) {
41
+ throw new Error(
42
+ `Attempting to retrieve ITC client, but none was found within context. ` +
43
+ `Make sure that AppConfigProvider is used in the component hierarchy.`,
44
+ );
45
+ }
46
+
47
+ return config.client;
48
+ }
@@ -0,0 +1,38 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { fn } from "storybook/test";
3
+ import { AuthCard } from "./AuthCard.tsx";
4
+
5
+ const meta = {
6
+ title: "Account/Auth Card",
7
+ component: AuthCard,
8
+ tags: ["autodocs"],
9
+ args: {
10
+ onLogout: fn(),
11
+ },
12
+ } satisfies Meta<typeof AuthCard>;
13
+
14
+ export default meta;
15
+
16
+ type Story = StoryObj<typeof meta>;
17
+
18
+ /**
19
+ * The default case where elements are correctly separated with middots.
20
+ */
21
+ export const Authenticated: Story = {
22
+ args: {
23
+ currentUser: {
24
+ id: "martin",
25
+ email: "martin@example.com",
26
+ isVerified: true,
27
+ },
28
+ },
29
+ };
30
+
31
+ /**
32
+ * The default case in which all steps of the flow succeed.
33
+ */
34
+ export const Anonymous: Story = {
35
+ args: {
36
+ currentUser: null,
37
+ },
38
+ };
@@ -0,0 +1,64 @@
1
+ import { Button } from "@ariakit/react";
2
+ import { Link } from "wouter";
3
+ import { useAppConfig } from "../AppConfig/AppConfig.tsx";
4
+ import { interactiveText } from "../common.css.ts";
5
+ import { IndieTabletopClubLogo } from "../IndieTabletopClubLogo.tsx";
6
+ import { LetterheadParagraph } from "../Letterhead/index.tsx";
7
+ import { button } from "../Letterhead/style.css.ts";
8
+ import { MiddotSeparated } from "../MiddotSeparated/MiddotSeparated.tsx";
9
+ import type { CurrentUser } from "../types.ts";
10
+ import { card } from "./style.css.ts";
11
+
12
+ /**
13
+ * Small, ITC-branded card that shows authentication status.
14
+ *
15
+ * Primarily intended to be used within the sidenav.
16
+ */
17
+ export function AuthCard(props: {
18
+ onLogout: () => void;
19
+ currentUser: CurrentUser | null;
20
+ }) {
21
+ const { currentUser, onLogout } = props;
22
+ const { hrefs } = useAppConfig();
23
+ const align = !currentUser ? "center" : "start";
24
+
25
+ return (
26
+ <div className={card.container({ align })}>
27
+ <IndieTabletopClubLogo className={card.logo({ align })} />
28
+
29
+ {currentUser ? (
30
+ <>
31
+ <LetterheadParagraph>{currentUser.email}</LetterheadParagraph>
32
+
33
+ <MiddotSeparated className={card.loggedInAction}>
34
+ <Link className={interactiveText} href={hrefs.account()}>
35
+ Account
36
+ </Link>
37
+
38
+ <Button className={interactiveText} onClick={() => onLogout()}>
39
+ Log out
40
+ </Button>
41
+ </MiddotSeparated>
42
+ </>
43
+ ) : (
44
+ <>
45
+ <LetterheadParagraph size="small">
46
+ Enable backup & sync, access your pledge data, and more!
47
+ </LetterheadParagraph>
48
+
49
+ <Link href={hrefs.join()} className={button()}>
50
+ Join
51
+ </Link>
52
+
53
+ <LetterheadParagraph size="small">
54
+ {"Already have an account? "}
55
+ <Link href={hrefs.login()} className={interactiveText}>
56
+ Log in
57
+ </Link>
58
+ {"."}
59
+ </LetterheadParagraph>
60
+ </>
61
+ )}
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,49 @@
1
+ import { style } from "@vanilla-extract/css";
2
+ import { recipe } from "@vanilla-extract/recipes";
3
+
4
+ export const card = {
5
+ container: recipe({
6
+ base: {
7
+ display: "flex",
8
+ flexDirection: "column",
9
+ gap: "0.75rem",
10
+ backgroundColor: "white",
11
+ padding: "1.5rem",
12
+ borderRadius: "0.5rem",
13
+ color: "black",
14
+ },
15
+
16
+ variants: {
17
+ align: {
18
+ center: {
19
+ textAlign: "center",
20
+ },
21
+ start: {
22
+ textAlign: "start",
23
+ },
24
+ },
25
+ },
26
+ }),
27
+
28
+ logo: recipe({
29
+ base: {
30
+ maxInlineSize: "11rem",
31
+ },
32
+
33
+ variants: {
34
+ align: {
35
+ center: {
36
+ marginInline: "auto",
37
+ },
38
+ start: {
39
+ marginInline: "0",
40
+ },
41
+ },
42
+ },
43
+ }),
44
+
45
+ loggedInAction: style({
46
+ borderBlockStart: "1px solid hsl(0 0% 0% / 0.1)",
47
+ paddingBlockStart: "0.75rem",
48
+ }),
49
+ };
@@ -15,8 +15,8 @@ function DialogGuard(props: { children: ReactNode }) {
15
15
  }
16
16
 
17
17
  /**
18
- * Wraps AriaKit's DialogProvider, but take a tuple of Dialog a DialogDisclosure
19
- * elements as children, and makes sense that the Dialog component is not
18
+ * Wraps AriaKit's DialogProvider, but takes a tuple of Dialog a DialogDisclosure
19
+ * elements as children, and makes sure that the Dialog component is not
20
20
  * rendered when it is hidden.
21
21
  *
22
22
  * This is important in cases where the dialog contains a form that should only
@@ -0,0 +1,9 @@
1
+ import { useAppConfig } from "../AppConfig/AppConfig.tsx";
2
+
3
+ export function DocumentTitle(props: { children: string }) {
4
+ const { children: title } = props;
5
+ const { appName } = useAppConfig();
6
+ const itc = `${appName} · Indie Tabletop Club`;
7
+
8
+ return <title>{title ? `${title} | ${itc}` : itc}</title>;
9
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * History state interface. This value can be extended via the standard
3
+ * typescript declaration merging mechanism.
4
+ *
5
+ * Use it via {@link getHistoryState}.
6
+ *
7
+ * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html
8
+ */
9
+ export interface HistoryState {
10
+ emailValue?: string;
11
+ }
12
+
13
+ /**
14
+ * Get's window history state with correct typings.
15
+ *
16
+ * Note that this is not a reactive value. It will not trigger re-renders if
17
+ * it is changed.
18
+ */
19
+ export function getHistoryState() {
20
+ return window.history.state as HistoryState | null;
21
+ }
@@ -25,6 +25,8 @@ export const letterhead = recipe({
25
25
  borderRadius: "1rem",
26
26
  marginInline: "auto",
27
27
  maxInlineSize: "36rem",
28
+ transition: "height 200ms",
29
+ height: "calc-size(auto)",
28
30
 
29
31
  "@media": {
30
32
  [MinWidth.SMALL]: {
@@ -1,4 +1,6 @@
1
1
  import {
2
+ FormCheckbox,
3
+ type FormCheckboxProps,
2
4
  FormError,
3
5
  FormInput,
4
6
  type FormInputProps,
@@ -7,6 +9,7 @@ import {
7
9
  useStoreState,
8
10
  } from "@ariakit/react";
9
11
  import { type ReactNode } from "react";
12
+ import { cx } from "../class-names.ts";
10
13
  import * as css from "./style.css.ts";
11
14
 
12
15
  export type LetterheadTextFieldProps = FormInputProps & {
@@ -34,6 +37,23 @@ export function LetterheadTextField(props: LetterheadTextFieldProps) {
34
37
  );
35
38
  }
36
39
 
40
+ export function LetterheadCheckboxField(
41
+ props: FormCheckboxProps & { label: ReactNode },
42
+ ) {
43
+ const { label, name, ...inputProps } = props;
44
+
45
+ return (
46
+ <div {...cx(css.checkboxField)}>
47
+ <div>
48
+ <FormCheckbox name={name} {...inputProps} />{" "}
49
+ <FormLabel name={name}>{label}</FormLabel>
50
+ </div>
51
+
52
+ <FormError name={name} className={css.fieldIssue} />
53
+ </div>
54
+ );
55
+ }
56
+
37
57
  type LetterheadReadonlyTextFieldProps = {
38
58
  label: string;
39
59
  value: string;
@@ -92,3 +112,36 @@ export function LetterheadSubmitError(props: LetterheadSubmitErrorProps) {
92
112
  </div>
93
113
  );
94
114
  }
115
+
116
+ export function LetterheadHeader(props: { children: ReactNode }) {
117
+ return <header className={css.header}>{props.children}</header>;
118
+ }
119
+
120
+ export function LetterheadFormActions(props: { children: ReactNode }) {
121
+ return (
122
+ <div
123
+ style={{
124
+ marginBlockStart: "2rem",
125
+ display: "flex",
126
+ flexDirection: "column",
127
+ gap: "1.125rem",
128
+ }}
129
+ >
130
+ {props.children}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ export function InputsStack(props: { children: ReactNode }) {
136
+ return (
137
+ <div
138
+ style={{
139
+ display: "flex",
140
+ flexDirection: "column",
141
+ gap: "1.125rem",
142
+ }}
143
+ >
144
+ {props.children}
145
+ </div>
146
+ );
147
+ }
@@ -48,6 +48,10 @@ export const fieldInput = style([
48
48
  },
49
49
  ]);
50
50
 
51
+ export const checkboxField = style({
52
+ textAlign: "start",
53
+ });
54
+
51
55
  export const fieldIssue = style({
52
56
  color: Color.PURPLE,
53
57
  fontSize: "0.875rem",
@@ -83,3 +87,7 @@ export const submitError = style({
83
87
  display: "none",
84
88
  },
85
89
  });
90
+
91
+ export const header = style({
92
+ marginBlockEnd: "3rem",
93
+ });
@@ -17,6 +17,7 @@ export function LoadingIndicator(props: { className?: string }) {
17
17
  width={width}
18
18
  height={height}
19
19
  className={props.className}
20
+ preserveAspectRatio="xMidYMid meet"
20
21
  >
21
22
  <g stroke="none" fill="inherit">
22
23
  {Array.from({ length: 3 }, (_, index) => {
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { MiddotSeparated } from "./MiddotSeparated.tsx";
3
+
4
+ type ComponentType = typeof MiddotSeparated;
5
+
6
+ type Story = StoryObj<typeof meta>;
7
+
8
+ const meta = {
9
+ title: "Components/Middot Separated",
10
+ component: MiddotSeparated,
11
+ tags: ["autodocs"],
12
+ } satisfies Meta<ComponentType>;
13
+
14
+ export default meta;
15
+
16
+ /**
17
+ * The default case in which all steps of the flow succeed.
18
+ */
19
+ export const Default: Story = {
20
+ args: {
21
+ children: ["Lorem", "Ipsum", " Dolor"],
22
+ },
23
+ };
24
+
25
+ /**
26
+ * Edge case when it comes to handling children in React.
27
+ */
28
+ export const SingleElement: Story = {
29
+ args: {
30
+ children: "Lorem",
31
+ },
32
+ };
@@ -0,0 +1,24 @@
1
+ import { type HTMLAttributes, Children } from "react";
2
+ import { withMiddlot } from "./style.css.ts";
3
+
4
+ type MiddotSeparatedProps = HTMLAttributes<HTMLDivElement>;
5
+
6
+ /**
7
+ * A utility component that wraps children into spans and adds middledots
8
+ * between each item using CSS ::before pseudo elements.
9
+ */
10
+ export function MiddotSeparated(props: MiddotSeparatedProps) {
11
+ const { children, ...divProps } = props;
12
+
13
+ return (
14
+ <div {...divProps}>
15
+ {Children.toArray(children).map((item, index) => {
16
+ return (
17
+ <span className={withMiddlot} key={index}>
18
+ {item}
19
+ </span>
20
+ );
21
+ })}
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,10 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const withMiddlot = style({
4
+ selectors: {
5
+ "& + &::before": {
6
+ content: " · ",
7
+ opacity: 0.6,
8
+ },
9
+ },
10
+ });
@@ -114,13 +114,18 @@ export function createDatabaseBindings<T extends AnyModernIDB>(db: T) {
114
114
  return useContext(DatabaseOpenRequest);
115
115
  }
116
116
 
117
- function DatabaseProvider(props: { children: ReactNode }) {
117
+ function DatabaseProvider(props: { children: ReactNode; open?: boolean }) {
118
+ const { children, open = true } = props;
118
119
  const { op, setSuccess, setFailure } = useAsyncOp<
119
120
  T,
120
121
  InaccessibleDatabaseError
121
122
  >();
122
123
 
123
124
  useEffect(() => {
125
+ if (!open) {
126
+ return;
127
+ }
128
+
124
129
  db.open({
125
130
  onBlocking() {
126
131
  setFailure({ type: "CLOSED_FOR_UPGRADE" });
@@ -142,11 +147,11 @@ export function createDatabaseBindings<T extends AnyModernIDB>(db: T) {
142
147
  db.close();
143
148
  console.info("Database closed.");
144
149
  };
145
- }, [setFailure, setSuccess]);
150
+ }, [setFailure, setSuccess, open]);
146
151
 
147
152
  return (
148
153
  <DatabaseOpenRequest.Provider value={op}>
149
- {props.children}
154
+ {children}
150
155
  </DatabaseOpenRequest.Provider>
151
156
  );
152
157
  }
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { http, HttpResponse } from "msw";
3
+ import { Failure, Pending, Success } from "../async-op.ts";
4
+ import { QRCode } from "./QRCode.tsx";
5
+
6
+ type StoryMeta = Meta<typeof QRCode>;
7
+
8
+ type Story = StoryObj<typeof meta>;
9
+
10
+ const meta = {
11
+ title: "Components/QR Code",
12
+ component: QRCode,
13
+ tags: ["autodocs"],
14
+ args: {
15
+ url: new Success("example.com"),
16
+ },
17
+ parameters: {
18
+ msw: {
19
+ handlers: {
20
+ qr: http.get("http://mock.api/qr", async () => {
21
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 41 41" shape-rendering="crispEdges"><path fill="#ffffff" d="M0 0h41v41H0z"/><path stroke="#000000" d="M4 4.5h7m2 0h2m2 0h1m3 0h1m1 0h3m1 0h1m2 0h7M4 5.5h1m5 0h1m2 0h2m1 0h4m2 0h1m1 0h3m1 0h1m1 0h1m5 0h1M4 6.5h1m1 0h3m1 0h1m1 0h1m3 0h2m1 0h1m4 0h2m1 0h1m2 0h1m1 0h3m1 0h1M4 7.5h1m1 0h3m1 0h1m1 0h5m1 0h3m3 0h5m1 0h1m1 0h3m1 0h1M4 8.5h1m1 0h3m1 0h1m1 0h2m3 0h1m1 0h1m2 0h3m2 0h1m2 0h1m1 0h3m1 0h1M4 9.5h1m5 0h1m1 0h1m1 0h1m1 0h5m3 0h1m1 0h1m3 0h1m5 0h1M4 10.5h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7M12 11.5h4m3 0h1m2 0h6M4 12.5h1m1 0h5m3 0h1m2 0h5m4 0h1m1 0h1m1 0h5M4 13.5h2m2 0h1m2 0h1m2 0h3m3 0h6m1 0h1m2 0h2m1 0h2m1 0h1M4 14.5h7m1 0h1m3 0h1m2 0h3m2 0h3m1 0h1m1 0h1m1 0h1m1 0h2M4 15.5h6m1 0h4m1 0h1m1 0h1m1 0h1m1 0h1m2 0h5m2 0h4M4 16.5h1m2 0h4m1 0h2m1 0h2m5 0h2m2 0h1m1 0h1m3 0h2m1 0h1M6 17.5h2m3 0h2m1 0h3m1 0h3m1 0h3m5 0h1m3 0h3M4 18.5h3m3 0h2m3 0h1m2 0h1m3 0h1m1 0h3m2 0h2m4 0h1M4 19.5h3m1 0h1m7 0h1m3 0h1m3 0h2m2 0h3m1 0h1m1 0h1M4 20.5h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m2 0h2m2 0h1m1 0h1m3 0h1m1 0h1m1 0h2m3 0h1M6 21.5h1m2 0h1m1 0h5m4 0h5m2 0h1m2 0h2m1 0h4M4 22.5h8m1 0h3m1 0h3m1 0h1m4 0h1m2 0h4m1 0h2M4 23.5h1m1 0h2m4 0h1m2 0h8m1 0h3m2 0h6M4 24.5h3m2 0h2m1 0h1m2 0h1m1 0h1m1 0h2m5 0h1m2 0h1m1 0h3m1 0h2M4 25.5h5m3 0h3m2 0h3m2 0h3m4 0h2m2 0h1m2 0h1M4 26.5h1m2 0h1m1 0h3m3 0h2m2 0h4m1 0h1m1 0h1m2 0h1m1 0h3m1 0h1M4 27.5h1m3 0h2m1 0h2m1 0h3m1 0h2m2 0h1m1 0h2m1 0h1m1 0h1m1 0h1m1 0h2m1 0h1M4 28.5h1m1 0h2m2 0h3m2 0h7m4 0h7m2 0h1M12 29.5h2m2 0h3m1 0h1m1 0h4m2 0h1m3 0h1m1 0h1m1 0h1M4 30.5h7m2 0h1m1 0h1m3 0h2m1 0h1m3 0h3m1 0h1m1 0h1m1 0h2M4 31.5h1m5 0h1m1 0h1m3 0h2m1 0h2m2 0h6m3 0h5M4 32.5h1m1 0h3m1 0h1m1 0h1m6 0h1m2 0h5m1 0h6M4 33.5h1m1 0h3m1 0h1m1 0h2m5 0h3m1 0h1m4 0h2m2 0h1m1 0h3M4 34.5h1m1 0h3m1 0h1m1 0h1m2 0h1m1 0h1m6 0h2m1 0h5m1 0h2M4 35.5h1m5 0h1m2 0h2m1 0h3m1 0h1m1 0h1m2 0h2m5 0h3M4 36.5h7m1 0h6m3 0h1m4 0h1m1 0h4m1 0h1m1 0h1"/></svg>`;
22
+ const { buffer } = new TextEncoder().encode(svg);
23
+
24
+ return HttpResponse.arrayBuffer(buffer, {
25
+ headers: { "Content-Type": "image/svg+xml" },
26
+ });
27
+ }),
28
+ },
29
+ },
30
+ },
31
+ } satisfies StoryMeta;
32
+
33
+ export default meta;
34
+
35
+ export const SuccessCase: Story = {};
36
+
37
+ export const PendingCase: Story = {
38
+ args: {
39
+ url: new Pending(),
40
+ },
41
+ };
42
+
43
+ export const FailureCase: Story = {
44
+ args: {
45
+ url: new Failure("Failed"),
46
+ },
47
+ };
@@ -0,0 +1,49 @@
1
+ import { useAppConfig } from "../AppConfig/AppConfig.tsx";
2
+ import type { AsyncOp } from "../async-op.ts";
3
+ import { cx } from "../class-names.ts";
4
+ import { LoadingIndicator } from "../LoadingIndicator.js";
5
+ import { code } from "./style.css.ts";
6
+
7
+ export function QRCode(props: { url: AsyncOp<string, unknown> }) {
8
+ const { url: result } = props;
9
+ const { client } = useAppConfig();
10
+
11
+ return (
12
+ <div {...cx(code.container)}>
13
+ {result.unpack(
14
+ (url) => {
15
+ const params = new URLSearchParams({ url });
16
+ const src = `${client.origin}/qr?${params}`;
17
+ return (
18
+ <img
19
+ {...cx(code.image)}
20
+ src={src}
21
+ alt=""
22
+ width={150}
23
+ height={150}
24
+ />
25
+ );
26
+ },
27
+ () => {
28
+ return (
29
+ <svg
30
+ width="24px"
31
+ height="24px"
32
+ viewBox="0 0 24 24"
33
+ version="1.1"
34
+ xmlns="http://www.w3.org/2000/svg"
35
+ >
36
+ <g strokeWidth="2" stroke="#000000" strokeLinecap="square">
37
+ <line x1="7" y1="7" x2="17" y2="17" />
38
+ <line x1="17" y1="7" x2="7" y2="17" />
39
+ </g>
40
+ </svg>
41
+ );
42
+ },
43
+ () => {
44
+ return <LoadingIndicator />;
45
+ },
46
+ )}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,19 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const code = {
4
+ container: style({
5
+ display: "flex",
6
+ alignItems: "center",
7
+ justifyContent: "center",
8
+ backgroundColor: "white",
9
+ borderRadius: "0.5rem",
10
+ maxInlineSize: "12rem",
11
+ aspectRatio: "1",
12
+ }),
13
+
14
+ image: style({
15
+ inlineSize: "100%",
16
+ blockSize: "auto",
17
+ borderRadius: "inherit",
18
+ }),
19
+ };