@indietabletop/appkit 3.5.0 → 3.6.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 (45) hide show
  1. package/lib/ClientContext/ClientContext.tsx +25 -0
  2. package/lib/InfoPage/index.tsx +46 -0
  3. package/lib/InfoPage/pages.tsx +36 -0
  4. package/lib/InfoPage/style.css.ts +36 -0
  5. package/lib/Letterhead/index.tsx +3 -3
  6. package/lib/LetterheadForm/index.tsx +1 -1
  7. package/lib/LoginPage/LoginPage.stories.tsx +107 -0
  8. package/lib/LoginPage/LoginPage.tsx +204 -0
  9. package/lib/LoginPage/style.css.ts +17 -0
  10. package/lib/ModernIDB/Cursor.ts +91 -0
  11. package/lib/ModernIDB/ModernIDB.ts +337 -0
  12. package/lib/ModernIDB/ModernIDBError.ts +9 -0
  13. package/lib/ModernIDB/ObjectStore.ts +195 -0
  14. package/lib/ModernIDB/ObjectStoreIndex.ts +102 -0
  15. package/lib/ModernIDB/README.md +9 -0
  16. package/lib/ModernIDB/Transaction.ts +40 -0
  17. package/lib/ModernIDB/VersionChangeManager.ts +57 -0
  18. package/lib/ModernIDB/bindings/factory.tsx +160 -0
  19. package/lib/ModernIDB/bindings/index.ts +2 -0
  20. package/lib/ModernIDB/bindings/types.ts +56 -0
  21. package/lib/ModernIDB/bindings/utils.tsx +32 -0
  22. package/lib/ModernIDB/index.ts +10 -0
  23. package/lib/ModernIDB/types.ts +77 -0
  24. package/lib/ModernIDB/utils.ts +51 -0
  25. package/lib/ReleaseInfo/index.tsx +29 -0
  26. package/lib/RulesetResolver.ts +214 -0
  27. package/lib/SubscribeCard/SubscribeCard.stories.tsx +133 -0
  28. package/lib/SubscribeCard/SubscribeCard.tsx +107 -0
  29. package/lib/SubscribeCard/style.css.ts +14 -0
  30. package/lib/Title/index.tsx +4 -0
  31. package/lib/append-copy-to-text.ts +1 -1
  32. package/lib/async-op.ts +8 -0
  33. package/lib/client.ts +37 -2
  34. package/lib/copyrightRange.ts +6 -0
  35. package/lib/groupBy.ts +25 -0
  36. package/lib/ids.ts +6 -0
  37. package/lib/index.ts +12 -0
  38. package/lib/random.ts +12 -0
  39. package/lib/result/swr.ts +18 -0
  40. package/lib/structs.ts +10 -0
  41. package/lib/typeguards.ts +12 -0
  42. package/lib/types.ts +3 -1
  43. package/lib/unique.test.ts +22 -0
  44. package/lib/unique.ts +24 -0
  45. package/package.json +16 -6
@@ -0,0 +1,25 @@
1
+ import { createContext, type ReactNode, useContext } from "react";
2
+ import type { IndieTabletopClient } from "../client.ts";
3
+
4
+ const ClientContext = createContext<IndieTabletopClient | null>(null);
5
+
6
+ export function ClientProvider(props: {
7
+ client: IndieTabletopClient;
8
+ children: ReactNode;
9
+ }) {
10
+ const { client, children } = props;
11
+ return <ClientContext value={client}>{children}</ClientContext>;
12
+ }
13
+
14
+ export function useClient() {
15
+ const client = useContext(ClientContext);
16
+
17
+ if (!client) {
18
+ throw new Error(
19
+ `Attempting to retrieve ITC client, but none was found within context. ` +
20
+ `Make sure that ClientProvider is used in the component hierarchy.`,
21
+ );
22
+ }
23
+
24
+ return client;
25
+ }
@@ -0,0 +1,46 @@
1
+ import { Heading, HeadingLevel } from "@ariakit/react";
2
+ import { LoadingIndicator } from "@indietabletop/appkit";
3
+ import type { ReactNode } from "react";
4
+ import * as css from "./style.css.ts";
5
+
6
+ export function InfoPage(props: {
7
+ heading: string;
8
+ description: ReactNode;
9
+ action?: ReactNode;
10
+ }) {
11
+ return (
12
+ <HeadingLevel>
13
+ <div className={css.page}>
14
+ <Heading className={css.heading}>{props.heading}</Heading>
15
+ <p className={css.description}>{props.description}</p>
16
+ {props.action && (
17
+ <div
18
+ style={{
19
+ marginBlockStart: "1rem",
20
+ marginInline: "auto",
21
+ inlineSize: "fit-content",
22
+ }}
23
+ >
24
+ {props.action}
25
+ </div>
26
+ )}
27
+ </div>
28
+ </HeadingLevel>
29
+ );
30
+ }
31
+
32
+ export function LoadingPage() {
33
+ return (
34
+ <div className={css.loadingPage}>
35
+ <LoadingIndicator />
36
+ </div>
37
+ );
38
+ }
39
+
40
+ export function LoadingView() {
41
+ return (
42
+ <div className={css.loadingView}>
43
+ <LoadingIndicator />
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,36 @@
1
+ import { interactiveText } from "@indietabletop/appkit/common.css.ts";
2
+ import { Link } from "wouter";
3
+ import { InfoPage } from "./index.tsx";
4
+
5
+ export function NotFoundPage() {
6
+ return (
7
+ <InfoPage
8
+ heading="Nothing here..."
9
+ description={
10
+ <>
11
+ The link you have followed might be broken.{" "}
12
+ <Link href="/" className={interactiveText}>
13
+ Back to dashboard
14
+ </Link>
15
+ .
16
+ </>
17
+ }
18
+ />
19
+ );
20
+ }
21
+
22
+ export function ClientSideErrorPage() {
23
+ const description = (
24
+ <>
25
+ Sorry, this wasn't supposed to happen. If the error persists, please{" "}
26
+ <a className={interactiveText} href={`mailto:support@indietabletop.club`}>
27
+ get in touch
28
+ </a>
29
+ .
30
+ </>
31
+ );
32
+
33
+ return (
34
+ <InfoPage heading="An wild Error appears!" description={description} />
35
+ );
36
+ }
@@ -0,0 +1,36 @@
1
+ import { manofa } from "@indietabletop/appkit/common.css.ts";
2
+ import { style } from "@vanilla-extract/css";
3
+
4
+ export const page = style({
5
+ minBlockSize: "100svh",
6
+ paddingBlockStart: "30svh",
7
+ paddingInline: "clamp(1rem, 5vw, 3rem)",
8
+ textAlign: "center",
9
+ });
10
+
11
+ export const heading = style([
12
+ manofa,
13
+ {
14
+ fontSize: "1.5rem",
15
+ lineHeight: 1.2,
16
+ },
17
+ ]);
18
+
19
+ export const description = style({
20
+ marginBlockStart: "0.5rem",
21
+ fontSize: "1rem",
22
+ });
23
+
24
+ export const loadingPage = style([
25
+ page,
26
+ { display: "flex", justifyContent: "center" },
27
+ ]);
28
+
29
+ export const loadingView = style({
30
+ display: "flex",
31
+ alignItems: "center",
32
+ justifyContent: "center",
33
+ paddingInline: "clamp(1rem, 5vw, 3rem)",
34
+ textAlign: "center",
35
+ minBlockSize: "30svh",
36
+ });
@@ -1,6 +1,6 @@
1
1
  import { Heading, type HeadingProps } from "@ariakit/react";
2
2
  import type { RecipeVariants } from "@vanilla-extract/recipes";
3
- import type { ComponentProps, ReactNode } from "react";
3
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
4
4
  import { cx } from "../class-names.ts";
5
5
  import { interactiveText } from "../common.css.ts";
6
6
  import { ExternalLink } from "../ExternalLink.tsx";
@@ -22,14 +22,14 @@ export function LetterheadHeading(props: LetterheadHeadingProps) {
22
22
  }
23
23
 
24
24
  type LetterheadParagraphProps = RecipeVariants<typeof css.paragraph> &
25
- ComponentProps<"p">;
25
+ ComponentPropsWithoutRef<"p">;
26
26
 
27
27
  export function LetterheadParagraph(props: LetterheadParagraphProps) {
28
28
  const { size, align, ...rest } = props;
29
29
  return <p {...rest} {...cx(props, css.paragraph({ size, align }))} />;
30
30
  }
31
31
 
32
- type LetterheadFooterProps = ComponentProps<"div">;
32
+ type LetterheadFooterProps = ComponentPropsWithoutRef<"div">;
33
33
 
34
34
  export function LetterheadFooter(props: LetterheadFooterProps) {
35
35
  return (
@@ -9,7 +9,7 @@ import {
9
9
  import { type ReactNode } from "react";
10
10
  import * as css from "./style.css.ts";
11
11
 
12
- type LetterheadTextFieldProps = FormInputProps & {
12
+ export type LetterheadTextFieldProps = FormInputProps & {
13
13
  label: string;
14
14
  hint?: ReactNode;
15
15
  };
@@ -0,0 +1,107 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { http, HttpResponse } from "msw";
3
+ import { fn } from "storybook/test";
4
+ import { IndieTabletopClient } from "../client.ts";
5
+ import { sleep } from "../sleep.ts";
6
+ import { LoginPage } from "./LoginPage.tsx";
7
+
8
+ const userA = {
9
+ id: "a",
10
+ email: "a@example.com",
11
+ isVerified: true,
12
+ };
13
+
14
+ const userB = {
15
+ id: "b",
16
+ email: "b@example.com",
17
+ isVerified: true,
18
+ };
19
+
20
+ const meta = {
21
+ title: "Pages/Login Page",
22
+ component: LoginPage,
23
+ tags: ["autodocs"],
24
+ args: {
25
+ client: new IndieTabletopClient({ apiOrigin: "" }),
26
+ onSuccess: fn(),
27
+ onLogout: fn(),
28
+ localUser: null,
29
+ },
30
+ parameters: {
31
+ msw: {
32
+ handlers: {
33
+ getCurrentUser: http.get("/v1/users/me", async () => {
34
+ await sleep(2000);
35
+
36
+ return HttpResponse.json(userA);
37
+ }),
38
+
39
+ createNewSession: http.post("/v1/sessions", async () => {
40
+ await sleep(2000);
41
+
42
+ return HttpResponse.json({
43
+ currentUser: userA,
44
+ sessionInfo: {
45
+ createdTs: 123,
46
+ expiresTs: 123,
47
+ },
48
+ });
49
+ }),
50
+ },
51
+ },
52
+ },
53
+ } satisfies Meta<typeof LoginPage>;
54
+
55
+ export default meta;
56
+
57
+ type Story = StoryObj<typeof meta>;
58
+
59
+ // There is no local user and server response reports user not authenticated.
60
+ // This is the majority case.
61
+ export const Default: Story = {
62
+ parameters: {
63
+ msw: {
64
+ handlers: {
65
+ getCurrentUser: http.get("/v1/users/me", async () => {
66
+ await sleep(2000);
67
+
68
+ return HttpResponse.text("Not authenticated", { status: 401 });
69
+ }),
70
+ },
71
+ },
72
+ },
73
+ };
74
+
75
+ // User is logged in via a server cookie. They can proceed to app.
76
+ export const AlreadyLoggedIn: Story = {
77
+ args: {
78
+ localUser: userA,
79
+ },
80
+ };
81
+
82
+ // App has local data that doesn't match server-data. User has to choose
83
+ // how to proceed.
84
+
85
+ // TODO Style UserMismatch and add handler.
86
+ export const UserMismatch: Story = {
87
+ args: {
88
+ localUser: userB,
89
+ },
90
+ };
91
+
92
+ // TODO: Implement Not Found story (user was probably deleted)
93
+ export const UserNotFound: Story = {
94
+ args: {
95
+ localUser: null,
96
+ },
97
+ };
98
+
99
+ // TODO: Implement no connection story
100
+ export const NoConnection: Story = {};
101
+
102
+ // TODO: Implement fallback failure (all other cases)
103
+ export const FallbackFailure: Story = {
104
+ args: {
105
+ localUser: userB,
106
+ },
107
+ };
@@ -0,0 +1,204 @@
1
+ import { Form } from "@ariakit/react";
2
+ import { Button } from "@ariakit/react/button";
3
+
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { Link, useLocation } from "wouter";
6
+ import { Pending, type Failure, type Success } from "../async-op.js";
7
+ import { IndieTabletopClient } from "../client.ts";
8
+ import { interactiveText } from "../common.css.ts";
9
+ import { getSubmitFailureMessage } from "../failureMessages.ts";
10
+ import { LoadingPage } from "../InfoPage/index.tsx";
11
+ import {
12
+ Letterhead,
13
+ LetterheadHeading,
14
+ LetterheadParagraph,
15
+ LetterheadSubmitButton,
16
+ } from "../Letterhead/index.tsx";
17
+ import {
18
+ LetterheadSubmitError,
19
+ LetterheadTextField,
20
+ } from "../LetterheadForm/index.tsx";
21
+ import { Title } from "../Title/index.tsx";
22
+ import type { CurrentUser, FailurePayload } from "../types.ts";
23
+ import { useForm } from "../use-form.ts";
24
+ import { validEmail } from "../validations.ts";
25
+ import * as css from "./style.css.ts";
26
+
27
+ type EventHandler = () => Promise<void> | void;
28
+
29
+ function LoginForm(props: {
30
+ client: IndieTabletopClient;
31
+ onSuccess: EventHandler;
32
+ }) {
33
+ const { client, onSuccess } = props;
34
+
35
+ const [_, navigate] = useLocation();
36
+ const { form, submitName } = useForm({
37
+ defaultValues: { email: "", password: "" },
38
+ validate: { email: validEmail },
39
+ async onSubmit({ values }) {
40
+ const result = await client.login(values);
41
+
42
+ return result.mapFailure((failure) => {
43
+ return getSubmitFailureMessage(failure, {
44
+ 401: "Username and password do not match. Please try again.",
45
+ 404: "Could not find a user with this email.",
46
+ });
47
+ });
48
+ },
49
+
50
+ async onSuccess() {
51
+ await onSuccess();
52
+ navigate("~/");
53
+ },
54
+ });
55
+
56
+ return (
57
+ <Form store={form} resetOnSubmit={false}>
58
+ <div
59
+ style={{
60
+ display: "flex",
61
+ flexDirection: "column",
62
+ gap: "1.125rem",
63
+ }}
64
+ >
65
+ <LetterheadTextField
66
+ name={form.names.email}
67
+ placeholder="james.workshop@example.com"
68
+ label="Email"
69
+ type="email"
70
+ required
71
+ />
72
+ <LetterheadTextField
73
+ name={form.names.password}
74
+ label="Password"
75
+ placeholder="Your password"
76
+ type="password"
77
+ required
78
+ />
79
+ </div>
80
+ <div
81
+ style={{
82
+ marginBlockStart: "2rem",
83
+ display: "flex",
84
+ flexDirection: "column",
85
+ gap: "1.125rem",
86
+ }}
87
+ >
88
+ <LetterheadSubmitError name={submitName} />
89
+ <LetterheadSubmitButton>Log in</LetterheadSubmitButton>
90
+ </div>
91
+ </Form>
92
+ );
93
+ }
94
+
95
+ function useCurrentUserResult(client: IndieTabletopClient) {
96
+ const getCurrentUser = useCallback(() => client.getCurrentUser(), [client]);
97
+ const [result, setResult] = useState<
98
+ Success<CurrentUser> | Failure<FailurePayload> | Pending
99
+ >(new Pending());
100
+
101
+ useEffect(() => {
102
+ getCurrentUser().then((result) => setResult(result));
103
+ }, [getCurrentUser]);
104
+
105
+ return result;
106
+ }
107
+
108
+ export function LoginPage(props: {
109
+ client: IndieTabletopClient;
110
+ localUser: CurrentUser | null;
111
+ onSuccess: EventHandler;
112
+ onLogout: EventHandler;
113
+ }) {
114
+ const { client, localUser, onLogout } = props;
115
+ const currentUserResult = useCurrentUserResult(client);
116
+
117
+ return (
118
+ <div className={css.page}>
119
+ <Title>Login</Title>
120
+
121
+ {currentUserResult.unpack(
122
+ (serverUser) => {
123
+ if (localUser && localUser.id !== serverUser.id) {
124
+ return (
125
+ <Letterhead>
126
+ <header className={css.header}>
127
+ <LetterheadHeading>User mismatch</LetterheadHeading>
128
+
129
+ <LetterheadParagraph>
130
+ You are logged into Indie Tabletop Club as{" "}
131
+ {serverUser.email}, but the account currently logged in on
132
+ this device is {localUser.email}.
133
+ </LetterheadParagraph>
134
+
135
+ <LetterheadParagraph>
136
+ Please pick which user account to continue with to prevent
137
+ syncing issues.
138
+ </LetterheadParagraph>
139
+
140
+ <div>
141
+ <Button type="button">
142
+ <div>Continue as {serverUser.email}</div>
143
+ <div>Local data will be deleted.</div>
144
+ </Button>
145
+
146
+ <Button type="button">
147
+ <div>Continue as {localUser.email}</div>
148
+ <div>You will be asked for your credentials again.</div>
149
+ </Button>
150
+ </div>
151
+ </header>
152
+ </Letterhead>
153
+ );
154
+ }
155
+
156
+ return (
157
+ <Letterhead>
158
+ <header className={css.header}>
159
+ <LetterheadHeading>Logged in</LetterheadHeading>
160
+ <LetterheadParagraph>
161
+ You are already logged into Indie Tabletop Club as{" "}
162
+ {serverUser.email}.
163
+ </LetterheadParagraph>
164
+
165
+ <LetterheadParagraph>
166
+ <Link className={interactiveText} href="~/">
167
+ Continue
168
+ </Link>
169
+ {` as current user, or `}
170
+ <Button className={interactiveText} onClick={onLogout}>
171
+ log out
172
+ </Button>
173
+ .
174
+ </LetterheadParagraph>
175
+ </header>
176
+ </Letterhead>
177
+ );
178
+ },
179
+ (failure) => {
180
+ if (failure.type === "API_ERROR" && failure.code === 401) {
181
+ return (
182
+ <Letterhead>
183
+ <header className={css.header}>
184
+ <LetterheadHeading>Log in</LetterheadHeading>
185
+ <LetterheadParagraph>
186
+ Log into your Indie Tabletop Club account to access creator
187
+ features.
188
+ </LetterheadParagraph>
189
+ </header>
190
+
191
+ <LoginForm {...props} />
192
+ </Letterhead>
193
+ );
194
+ }
195
+
196
+ return null;
197
+ },
198
+ () => (
199
+ <LoadingPage />
200
+ ),
201
+ )}
202
+ </div>
203
+ );
204
+ }
@@ -0,0 +1,17 @@
1
+ import { MinWidth } from "@indietabletop/appkit";
2
+ import { style } from "@vanilla-extract/css";
3
+
4
+ export const page = style({
5
+ backgroundColor: "white",
6
+
7
+ "@media": {
8
+ [MinWidth.SMALL]: {
9
+ backgroundColor: "transparent",
10
+ padding: "clamp(1rem, 5vw, 5rem)",
11
+ },
12
+ },
13
+ });
14
+
15
+ export const header = style({
16
+ marginBlockEnd: "3rem",
17
+ });
@@ -0,0 +1,91 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-return */
2
+
3
+ import { requestToPromise } from "./utils.ts";
4
+
5
+ export class Cursor<C extends IDBCursor = IDBCursor> {
6
+ idbCursor: C;
7
+
8
+ constructor(idbCursor: C) {
9
+ this.idbCursor = idbCursor;
10
+ }
11
+
12
+ get key() {
13
+ return this.idbCursor.key;
14
+ }
15
+
16
+ get primaryKey() {
17
+ return this.idbCursor.primaryKey;
18
+ }
19
+
20
+ get direction() {
21
+ return this.idbCursor.direction;
22
+ }
23
+
24
+ /**
25
+ * Advances the cursor through the next count records in range.
26
+ *
27
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/IDBCursor/advance
28
+ */
29
+ advance(count: number): void {
30
+ this.idbCursor.advance(count);
31
+ }
32
+ /**
33
+ * Advances the cursor to the next record in range.
34
+ *
35
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/IDBCursor/continue)
36
+ */
37
+ continue(key?: IDBValidKey): void {
38
+ this.idbCursor.continue(key);
39
+ }
40
+ }
41
+
42
+ export class CursorWithValue<Item> extends Cursor<IDBCursorWithValue> {
43
+ get value(): Item {
44
+ return this.idbCursor.value;
45
+ }
46
+
47
+ /**
48
+ * Delete the record pointed at by the cursor.
49
+ *
50
+ * If successful, request's result will be undefined.
51
+ *
52
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/IDBCursor/delete)
53
+ */
54
+ delete(): Promise<undefined> {
55
+ return requestToPromise(this.idbCursor.delete());
56
+ }
57
+ /**
58
+ * Updated the record pointed at by the cursor with a new value.
59
+ *
60
+ * Throws a "DataError" DOMException if the effective object store uses in-line keys and the key would have changed.
61
+ *
62
+ * If successful, request's result will be the record's key.
63
+ *
64
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/IDBCursor/update)
65
+ */
66
+ update(value: Item): Promise<IDBValidKey> {
67
+ return requestToPromise(this.idbCursor.update(value));
68
+ }
69
+ }
70
+
71
+ export class IndexCursor extends Cursor {
72
+ /**
73
+ * Advances the cursor to the next record in range matching or after key and primaryKey. Throws an "InvalidAccessError" DOMException if the source is not an index.
74
+ *
75
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/IDBCursor/continuePrimaryKey)
76
+ */
77
+ continuePrimaryKey(key: IDBValidKey, primaryKey: IDBValidKey): void {
78
+ this.idbCursor.continuePrimaryKey(key, primaryKey);
79
+ }
80
+ }
81
+
82
+ export class IndexCursorWithValue<Item> extends CursorWithValue<Item> {
83
+ /**
84
+ * Advances the cursor to the next record in range matching or after key and primaryKey. Throws an "InvalidAccessError" DOMException if the source is not an index.
85
+ *
86
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/IDBCursor/continuePrimaryKey)
87
+ */
88
+ continuePrimaryKey(key: IDBValidKey, primaryKey: IDBValidKey): void {
89
+ this.idbCursor.continuePrimaryKey(key, primaryKey);
90
+ }
91
+ }