@indietabletop/appkit 6.1.6 → 7.0.0-rc.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/lib/AppConfig/AppConfig.tsx +47 -28
- package/lib/AppConfig/formatters.tsx +43 -0
- package/lib/CacheProvider.tsx +20 -0
- package/lib/Sync/SyncIcon.stories.tsx +67 -0
- package/lib/Sync/SyncIcon.tsx +102 -0
- package/lib/Sync/SyncLog.tsx +222 -0
- package/lib/Sync/SyncLogList.stories.tsx +219 -0
- package/lib/Sync/style.css.ts +126 -0
- package/lib/account/AccountIcon.tsx +15 -0
- package/lib/account/iconMask.svg +3 -0
- package/lib/account/style.css.ts +24 -0
- package/lib/createStrictContext.ts +15 -0
- package/lib/index.ts +4 -0
- package/lib/store/index.tsx +134 -81
- package/lib/store/store.ts +5 -2
- package/package.json +19 -18
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
2
|
import type { IndieTabletopClient } from "../client.ts";
|
|
3
|
+
import { createStrictContext } from "../createStrictContext.ts";
|
|
3
4
|
import type { AppHrefs } from "../hrefs.ts";
|
|
5
|
+
import type { ModernIDB } from "../ModernIDB/index.ts";
|
|
6
|
+
import type { DatabaseAppMachineMethods } from "../store/index.tsx";
|
|
7
|
+
import type { AppkitFormatters } from "./formatters.tsx";
|
|
4
8
|
|
|
5
9
|
export type AppConfig = {
|
|
6
10
|
app: {
|
|
@@ -16,15 +20,56 @@ export type AppConfig = {
|
|
|
16
20
|
*/
|
|
17
21
|
icon: string;
|
|
18
22
|
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Usually the value of `import.meta.env.DEV` when using Vite.
|
|
26
|
+
*
|
|
27
|
+
* This is used primarily by the DocumentTitle component, to make it clear
|
|
28
|
+
* which browser tab is in DEV mode.
|
|
29
|
+
*/
|
|
19
30
|
isDev: boolean;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A reference to the ITC client.
|
|
34
|
+
*
|
|
35
|
+
* This is necessary in order for prefab components to make thier own calls.
|
|
36
|
+
*/
|
|
20
37
|
client: IndieTabletopClient;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A hrefs configuration.
|
|
41
|
+
*
|
|
42
|
+
* This is necessary so that prefab components can navigate around the app
|
|
43
|
+
* without hard-coded values.
|
|
44
|
+
*/
|
|
21
45
|
hrefs: AppHrefs;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Customize input placeholders so that they fit their given app thematically.
|
|
49
|
+
*/
|
|
22
50
|
placeholders: {
|
|
23
51
|
email: string;
|
|
24
52
|
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Formatters accessible to prefab components.
|
|
56
|
+
*
|
|
57
|
+
* You might want to create different formatters based on the app's locale.
|
|
58
|
+
*/
|
|
59
|
+
fmt: AppkitFormatters;
|
|
60
|
+
|
|
61
|
+
database: ModernIDB<any, any> & DatabaseAppMachineMethods;
|
|
25
62
|
};
|
|
26
63
|
|
|
27
|
-
const AppConfigContext =
|
|
64
|
+
const [AppConfigContext, useAppConfig] = createStrictContext<AppConfig>();
|
|
65
|
+
|
|
66
|
+
export { createFormatters } from "./formatters.tsx";
|
|
67
|
+
|
|
68
|
+
export { useAppConfig };
|
|
69
|
+
|
|
70
|
+
export function useClient() {
|
|
71
|
+
return useAppConfig().client;
|
|
72
|
+
}
|
|
28
73
|
|
|
29
74
|
export function AppConfigProvider(props: {
|
|
30
75
|
config: AppConfig;
|
|
@@ -33,29 +78,3 @@ export function AppConfigProvider(props: {
|
|
|
33
78
|
const { config, children } = props;
|
|
34
79
|
return <AppConfigContext value={config}>{children}</AppConfigContext>;
|
|
35
80
|
}
|
|
36
|
-
|
|
37
|
-
export function useAppConfig() {
|
|
38
|
-
const config = useContext(AppConfigContext);
|
|
39
|
-
|
|
40
|
-
if (!config) {
|
|
41
|
-
throw new Error(
|
|
42
|
-
`Attempting to retrieve app config, but none was found within context. ` +
|
|
43
|
-
`Make sure that AppConfigProvider is used in the component hierarchy.`,
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return config;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function useClient() {
|
|
51
|
-
const config = useContext(AppConfigContext);
|
|
52
|
-
|
|
53
|
-
if (!config?.client) {
|
|
54
|
-
throw new Error(
|
|
55
|
-
`Attempting to retrieve ITC client, but none was found within context. ` +
|
|
56
|
-
`Make sure that AppConfigProvider is used in the component hierarchy.`,
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return config.client;
|
|
61
|
-
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// English only uses "one" and "other"
|
|
2
|
+
type EnglishCardinalRule = Extract<Intl.LDMLPluralRule, "one" | "other">;
|
|
3
|
+
|
|
4
|
+
export type AppkitFormatters = ReturnType<typeof createFormatters>;
|
|
5
|
+
|
|
6
|
+
// Currently, we only support localizing English variants. Not translating the
|
|
7
|
+
// app into other locales.
|
|
8
|
+
type EnglishLocale = "en" | `en-${string}`;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates formatters that can be used across the app.
|
|
12
|
+
*/
|
|
13
|
+
export function createFormatters(locale: EnglishLocale) {
|
|
14
|
+
const cardinalRules = new Intl.PluralRules(locale);
|
|
15
|
+
|
|
16
|
+
const dateTimeFmt = new Intl.DateTimeFormat(locale);
|
|
17
|
+
|
|
18
|
+
const conjunctionFmt = new Intl.ListFormat(locale, {
|
|
19
|
+
style: "long",
|
|
20
|
+
type: "conjunction",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const disjunctionFmt = new Intl.ListFormat(locale, {
|
|
24
|
+
style: "long",
|
|
25
|
+
type: "disjunction",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
plural(count: number, rules: Record<EnglishCardinalRule, string>) {
|
|
30
|
+
const tag = cardinalRules.select(count) as EnglishCardinalRule;
|
|
31
|
+
return rules[tag].replace("#", count.toString());
|
|
32
|
+
},
|
|
33
|
+
dateTime(date: Date | string | number) {
|
|
34
|
+
return dateTimeFmt.format(new Date(date));
|
|
35
|
+
},
|
|
36
|
+
conjunction(items: string[]) {
|
|
37
|
+
return conjunctionFmt.format(items);
|
|
38
|
+
},
|
|
39
|
+
disjunction(items: string[]) {
|
|
40
|
+
return disjunctionFmt.format(items);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useCurrentUser } from "@indietabletop/appkit";
|
|
2
|
+
import { useMemo, type ReactNode } from "react";
|
|
3
|
+
import { SWRConfig } from "swr";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Links SWR cache to the current user. This makes sure that if a user logs
|
|
7
|
+
* out and continues using the app in an anonymous state, they will not be
|
|
8
|
+
* shown stale data.
|
|
9
|
+
*/
|
|
10
|
+
export function CacheProvider(props: { children: ReactNode }) {
|
|
11
|
+
const currentUser = useCurrentUser();
|
|
12
|
+
const cacheKey = currentUser ? currentUser.id : "anonymous";
|
|
13
|
+
|
|
14
|
+
const provider = useMemo(() => {
|
|
15
|
+
console.info(`Using SWR cache for "${cacheKey}"`);
|
|
16
|
+
return () => new Map();
|
|
17
|
+
}, [cacheKey]);
|
|
18
|
+
|
|
19
|
+
return <SWRConfig value={{ provider }}>{props.children}</SWRConfig>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { assignInlineVars } from "@vanilla-extract/dynamic";
|
|
2
|
+
import preview from "../../.storybook/preview.tsx";
|
|
3
|
+
import { SyncIcon } from "./SyncIcon.tsx";
|
|
4
|
+
import { syncTheme } from "./style.css.ts";
|
|
5
|
+
|
|
6
|
+
const meta = preview.meta({
|
|
7
|
+
title: "Components/SyncIcon",
|
|
8
|
+
component: SyncIcon,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const Inactive = meta.story({
|
|
12
|
+
args: {
|
|
13
|
+
status: "INACTIVE",
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const Authenticating = meta.story({
|
|
18
|
+
args: {
|
|
19
|
+
status: "AUTHENTICATING",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const Idle = meta.story({
|
|
24
|
+
args: {
|
|
25
|
+
status: "IDLE",
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const Error = meta.story({
|
|
30
|
+
args: {
|
|
31
|
+
status: "ERROR",
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const Syncing = meta.story({
|
|
36
|
+
args: {
|
|
37
|
+
status: "SYNCING",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const Waiting = meta.story({
|
|
42
|
+
args: {
|
|
43
|
+
status: "WAITING",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const Overrides = meta.story({
|
|
48
|
+
args: {
|
|
49
|
+
status: "AUTHENTICATING",
|
|
50
|
+
},
|
|
51
|
+
decorators: [
|
|
52
|
+
(Story) => {
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
style={assignInlineVars(syncTheme, {
|
|
56
|
+
idle: "black",
|
|
57
|
+
inactive: "black",
|
|
58
|
+
syncing: "black",
|
|
59
|
+
error: "black",
|
|
60
|
+
})}
|
|
61
|
+
>
|
|
62
|
+
<Story />
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { cx } from "../class-names.ts";
|
|
2
|
+
import type { SyncState } from "../store/index.tsx";
|
|
3
|
+
import { icon, syncTheme } from "./style.css.ts";
|
|
4
|
+
|
|
5
|
+
export { syncTheme };
|
|
6
|
+
|
|
7
|
+
function Icon(props: { variant: SyncState }) {
|
|
8
|
+
switch (props.variant) {
|
|
9
|
+
case "IDLE": {
|
|
10
|
+
return (
|
|
11
|
+
<g className={icon.group}>
|
|
12
|
+
<circle className={icon.idle} />
|
|
13
|
+
|
|
14
|
+
<path
|
|
15
|
+
d="M9.0026 14L16.0737 6.92893L14.6595 5.51472L9.0026 11.1716L6.17421 8.3431L4.75999 9.7574L9.0026 14Z"
|
|
16
|
+
className={icon.shape}
|
|
17
|
+
/>
|
|
18
|
+
</g>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
case "AUTHENTICATING":
|
|
23
|
+
case "WAITING": {
|
|
24
|
+
return (
|
|
25
|
+
<g className={icon.group}>
|
|
26
|
+
<circle className={icon.syncing} />
|
|
27
|
+
|
|
28
|
+
<g>
|
|
29
|
+
<circle cx="14" cy="10" className={icon.bounce} />
|
|
30
|
+
<circle cx="10" cy="10" className={icon.bounce} />
|
|
31
|
+
<circle cx="6" cy="10" className={icon.bounce} />
|
|
32
|
+
</g>
|
|
33
|
+
</g>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
case "SYNCING": {
|
|
38
|
+
return (
|
|
39
|
+
<g className={icon.group}>
|
|
40
|
+
<circle className={icon.idle} />
|
|
41
|
+
|
|
42
|
+
<path
|
|
43
|
+
d="M14.8201 15.0761C16.1628 13.8007 17 11.9981 17 10C17 6.13401 13.866 3 10 3C8.9391 3 7.9334 3.23599 7.03241 3.65834L8.0072 5.41292C8.6177 5.14729 9.2917 5 10 5C12.7614 5 15 7.23858 15 10H12L14.8201 15.0761ZM12.9676 16.3417L11.9928 14.5871C11.3823 14.8527 10.7083 15 10 15C7.23858 15 5 12.7614 5 10H8L5.17993 4.92387C3.83719 6.19929 3 8.0019 3 10C3 13.866 6.13401 17 10 17C11.0609 17 12.0666 16.764 12.9676 16.3417Z"
|
|
44
|
+
className={icon.shape}
|
|
45
|
+
/>
|
|
46
|
+
</g>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
case "ERROR": {
|
|
51
|
+
return (
|
|
52
|
+
<g className={icon.group}>
|
|
53
|
+
<circle className={icon.error} />
|
|
54
|
+
|
|
55
|
+
<path
|
|
56
|
+
d="M10 8.58579L7.17157 5.75735L5.75736 7.17156L8.5858 9.99999L5.75736 12.8284L7.17157 14.2426L10 11.4142L12.8284 14.2426L14.2426 12.8284L11.4142 9.99999L14.2426 7.17156L12.8284 5.75735L10 8.58579Z"
|
|
57
|
+
className={icon.shape}
|
|
58
|
+
/>
|
|
59
|
+
</g>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case "INACTIVE": {
|
|
64
|
+
return (
|
|
65
|
+
<g className={icon.group}>
|
|
66
|
+
<circle className={icon.inactive} />
|
|
67
|
+
|
|
68
|
+
<path
|
|
69
|
+
d="M9 13V15H11V13H9ZM11 11.3551C12.4457 10.9248 13.5 9.5855 13.5 8C13.5 6.067 11.933 4.5 10 4.5C8.302 4.5 6.88637 5.70919 6.56731 7.31346L8.5288 7.70577C8.6656 7.01823 9.2723 6.5 10 6.5C10.8284 6.5 11.5 7.17157 11.5 8C11.5 8.8284 10.8284 9.5 10 9.5C9.4477 9.5 9 9.9477 9 10.5V12H11V11.3551Z"
|
|
70
|
+
className={icon.shape}
|
|
71
|
+
/>
|
|
72
|
+
</g>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
default: {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function SyncIcon(props: {
|
|
83
|
+
status: SyncState;
|
|
84
|
+
title?: string;
|
|
85
|
+
className?: string;
|
|
86
|
+
}) {
|
|
87
|
+
const { status, title } = props;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<svg
|
|
91
|
+
{...cx(props, icon.container({ rotate: status === "SYNCING" }))}
|
|
92
|
+
width="20px"
|
|
93
|
+
height="20px"
|
|
94
|
+
viewBox="0 0 20 20"
|
|
95
|
+
version="1.1"
|
|
96
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
97
|
+
>
|
|
98
|
+
<title>{title}</title>
|
|
99
|
+
<Icon variant={status} />
|
|
100
|
+
</svg>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Disclosure,
|
|
3
|
+
DisclosureContent,
|
|
4
|
+
DisclosureProvider,
|
|
5
|
+
} from "@ariakit/react";
|
|
6
|
+
import { useAppConfig } from "../AppConfig/AppConfig.tsx";
|
|
7
|
+
import {
|
|
8
|
+
useLastSuccessfulSyncTs,
|
|
9
|
+
useSyncLog,
|
|
10
|
+
useSyncState,
|
|
11
|
+
type SyncState,
|
|
12
|
+
} from "../store/index.tsx";
|
|
13
|
+
import type { SyncAttempt } from "../store/types.ts";
|
|
14
|
+
import type { FailurePayload } from "../types.ts";
|
|
15
|
+
import { log } from "./style.css.ts";
|
|
16
|
+
import { SyncIcon } from "./SyncIcon.tsx";
|
|
17
|
+
|
|
18
|
+
function SyncActionSuccess(props: {
|
|
19
|
+
heading: string;
|
|
20
|
+
items: { id: string; name: string; deleted: boolean }[];
|
|
21
|
+
}) {
|
|
22
|
+
const { heading, items } = props;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div>
|
|
26
|
+
<div>
|
|
27
|
+
<strong>{heading}</strong>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div style={{ marginBlockStart: "0.25rem" }}>
|
|
31
|
+
{items.length > 0 ?
|
|
32
|
+
<ul
|
|
33
|
+
style={{
|
|
34
|
+
listStyle: "initial",
|
|
35
|
+
paddingInlineStart: "1.5rem",
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
{items.map((item) => {
|
|
39
|
+
const name = item.name || "Unnamed item";
|
|
40
|
+
|
|
41
|
+
if (item.deleted) {
|
|
42
|
+
return (
|
|
43
|
+
<li key={item.id}>
|
|
44
|
+
Synced deletion of {name} (<code>{item.id}</code>)
|
|
45
|
+
</li>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<li key={item.id}>
|
|
51
|
+
Synced {name} (<code>{item.id}</code>)
|
|
52
|
+
</li>
|
|
53
|
+
);
|
|
54
|
+
})}
|
|
55
|
+
</ul>
|
|
56
|
+
: <div style={{ opacity: 0.5 }}>
|
|
57
|
+
<em>Everything up to date.</em>
|
|
58
|
+
</div>
|
|
59
|
+
}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const failureToLabel: Record<FailurePayload["type"], string> = {
|
|
66
|
+
NETWORK_ERROR: "A network error occurred.",
|
|
67
|
+
API_ERROR: "Server responded with error ",
|
|
68
|
+
UNKNOWN_ERROR: "An unexpected error ocurred. Try restarting the app.",
|
|
69
|
+
VALIDATION_ERROR: "Data validation error occurred. Try restarting the app.",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function SyncActionFailure(props: {
|
|
73
|
+
heading: string;
|
|
74
|
+
failure: FailurePayload;
|
|
75
|
+
}) {
|
|
76
|
+
const { heading, failure } = props;
|
|
77
|
+
const label = failureToLabel[failure.type];
|
|
78
|
+
return (
|
|
79
|
+
<div>
|
|
80
|
+
{heading}
|
|
81
|
+
{": "}
|
|
82
|
+
{failure.type === "API_ERROR" ?
|
|
83
|
+
` ${label} ${failure.code}.`
|
|
84
|
+
: ` ${label}`}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const syncStateToLabel: Record<SyncState, string> = {
|
|
90
|
+
WAITING: "Waiting for additional changes...",
|
|
91
|
+
IDLE: "Sync enabled. Waiting for changes.",
|
|
92
|
+
AUTHENTICATING: "Verifying identity...",
|
|
93
|
+
ERROR: "Last sync unsuccessful. Waiting for changes.",
|
|
94
|
+
INACTIVE: "Sync not enabled. Log in or join to enable.",
|
|
95
|
+
SYNCING: "Syncing...",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export function SyncLogList(props: {
|
|
99
|
+
syncState: SyncState;
|
|
100
|
+
syncLog: SyncAttempt[];
|
|
101
|
+
lastSuccessfulSyncTs: number | null;
|
|
102
|
+
}) {
|
|
103
|
+
const { syncLog, syncState, lastSuccessfulSyncTs } = props;
|
|
104
|
+
const { fmt } = useAppConfig();
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div>
|
|
108
|
+
<div className={log.item}>
|
|
109
|
+
<SyncIcon status={syncState} />
|
|
110
|
+
|
|
111
|
+
<div>
|
|
112
|
+
<div>{syncStateToLabel[syncState]}</div>
|
|
113
|
+
|
|
114
|
+
<div className={log.itemTimestamp}>
|
|
115
|
+
{lastSuccessfulSyncTs ?
|
|
116
|
+
`Last successful sync at: ${fmt.dateTime(lastSuccessfulSyncTs)}`
|
|
117
|
+
: "Not synced yet."}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{syncLog.map((logItem) => {
|
|
123
|
+
const isFailure =
|
|
124
|
+
logItem.pull?.type === "FAILURE" || logItem.push?.type == "FAILURE";
|
|
125
|
+
|
|
126
|
+
const itemsSynced =
|
|
127
|
+
(logItem.pull?.type === "SUCCESS" ?
|
|
128
|
+
logItem.pull.value.pulled.length
|
|
129
|
+
: 0) +
|
|
130
|
+
(logItem.push?.type === "SUCCESS" ?
|
|
131
|
+
logItem.push.value.pushed.length + logItem.push.value.pulled.length
|
|
132
|
+
: 0);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<DisclosureProvider key={logItem.startedTs}>
|
|
136
|
+
<Disclosure className={log.item}>
|
|
137
|
+
{isFailure ?
|
|
138
|
+
<>
|
|
139
|
+
<SyncIcon status="ERROR" />
|
|
140
|
+
<div>
|
|
141
|
+
Sync attempt failed
|
|
142
|
+
<div className={log.itemTimestamp}>
|
|
143
|
+
{fmt.dateTime(logItem.startedTs)}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</>
|
|
147
|
+
: <>
|
|
148
|
+
<SyncIcon status="IDLE" />
|
|
149
|
+
<div>
|
|
150
|
+
{"Sync successful. "}
|
|
151
|
+
{itemsSynced === 0 ?
|
|
152
|
+
"Everything up to date."
|
|
153
|
+
: fmt.plural(itemsSynced, {
|
|
154
|
+
one: "1 item synced.",
|
|
155
|
+
other: "# items synced.",
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
<div className={log.itemTimestamp}>
|
|
159
|
+
{fmt.dateTime(logItem.startedTs)}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</>
|
|
163
|
+
}
|
|
164
|
+
</Disclosure>
|
|
165
|
+
|
|
166
|
+
<DisclosureContent className={log.disclosureContent}>
|
|
167
|
+
{logItem.pull && (
|
|
168
|
+
<>
|
|
169
|
+
{logItem.pull.type === "SUCCESS" ?
|
|
170
|
+
<SyncActionSuccess
|
|
171
|
+
heading="Pull successful"
|
|
172
|
+
items={logItem.pull.value.pulled}
|
|
173
|
+
/>
|
|
174
|
+
: <SyncActionFailure
|
|
175
|
+
heading="Pull failed"
|
|
176
|
+
failure={logItem.pull.failure}
|
|
177
|
+
/>
|
|
178
|
+
}
|
|
179
|
+
</>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{logItem.push && (
|
|
183
|
+
<>
|
|
184
|
+
{logItem.push.type === "SUCCESS" ?
|
|
185
|
+
<>
|
|
186
|
+
<SyncActionSuccess
|
|
187
|
+
heading="Push successful"
|
|
188
|
+
items={logItem.push.value.pushed}
|
|
189
|
+
/>
|
|
190
|
+
<SyncActionSuccess
|
|
191
|
+
heading="Patch successful"
|
|
192
|
+
items={logItem.push.value.pulled}
|
|
193
|
+
/>
|
|
194
|
+
</>
|
|
195
|
+
: <SyncActionFailure
|
|
196
|
+
heading="Push failed"
|
|
197
|
+
failure={logItem.push.failure}
|
|
198
|
+
/>
|
|
199
|
+
}
|
|
200
|
+
</>
|
|
201
|
+
)}
|
|
202
|
+
</DisclosureContent>
|
|
203
|
+
</DisclosureProvider>
|
|
204
|
+
);
|
|
205
|
+
})}
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function SyncLog() {
|
|
211
|
+
const syncState = useSyncState();
|
|
212
|
+
const syncLog = useSyncLog();
|
|
213
|
+
const lastSuccessfulSyncTs = useLastSuccessfulSyncTs();
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<SyncLogList
|
|
217
|
+
syncState={syncState}
|
|
218
|
+
syncLog={syncLog}
|
|
219
|
+
lastSuccessfulSyncTs={lastSuccessfulSyncTs}
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import preview from "../../.storybook/preview.tsx";
|
|
2
|
+
import { Failure, Success } from "../async-op.ts";
|
|
3
|
+
import { SyncLogList } from "./SyncLog.tsx";
|
|
4
|
+
|
|
5
|
+
const meta = preview.meta({
|
|
6
|
+
title: "Components/SyncLogList",
|
|
7
|
+
component: SyncLogList,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const Inactive = meta.story({
|
|
11
|
+
args: {
|
|
12
|
+
syncState: "INACTIVE",
|
|
13
|
+
lastSuccessfulSyncTs: null,
|
|
14
|
+
syncLog: [],
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const Syncing = meta.story({
|
|
19
|
+
args: {
|
|
20
|
+
syncState: "SYNCING",
|
|
21
|
+
lastSuccessfulSyncTs: 1773753718052,
|
|
22
|
+
syncLog: [
|
|
23
|
+
{
|
|
24
|
+
startedTs: 1773753718052,
|
|
25
|
+
pull: null,
|
|
26
|
+
push: new Success({
|
|
27
|
+
pushed: [
|
|
28
|
+
{
|
|
29
|
+
id: "123",
|
|
30
|
+
name: "Test Item",
|
|
31
|
+
updatedTs: 1773753718052,
|
|
32
|
+
deleted: false,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
pulled: [],
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const Idle = meta.story({
|
|
43
|
+
args: {
|
|
44
|
+
syncState: "IDLE",
|
|
45
|
+
lastSuccessfulSyncTs: 1773753718052,
|
|
46
|
+
syncLog: [],
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const Waiting = meta.story({
|
|
51
|
+
args: {
|
|
52
|
+
syncState: "WAITING",
|
|
53
|
+
lastSuccessfulSyncTs: 1773753718052,
|
|
54
|
+
syncLog: [],
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const Authenticating = meta.story({
|
|
59
|
+
args: {
|
|
60
|
+
syncState: "AUTHENTICATING",
|
|
61
|
+
lastSuccessfulSyncTs: null,
|
|
62
|
+
syncLog: [],
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const Error = meta.story({
|
|
67
|
+
args: {
|
|
68
|
+
syncState: "ERROR",
|
|
69
|
+
lastSuccessfulSyncTs: 1773753718052,
|
|
70
|
+
syncLog: [
|
|
71
|
+
{
|
|
72
|
+
startedTs: 1773753718052,
|
|
73
|
+
pull: null,
|
|
74
|
+
push: new Failure({ type: "API_ERROR" as const, code: 500 }),
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const SuccessfulPullAndPush = meta.story({
|
|
81
|
+
args: {
|
|
82
|
+
syncState: "IDLE",
|
|
83
|
+
lastSuccessfulSyncTs: 1773753718052,
|
|
84
|
+
syncLog: [
|
|
85
|
+
{
|
|
86
|
+
startedTs: 1773753718052,
|
|
87
|
+
pull: new Success({
|
|
88
|
+
pulled: [
|
|
89
|
+
{
|
|
90
|
+
id: "p1",
|
|
91
|
+
name: "Pulled Item",
|
|
92
|
+
updatedTs: 1773753718052,
|
|
93
|
+
deleted: false,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
}),
|
|
97
|
+
push: new Success({
|
|
98
|
+
pushed: [
|
|
99
|
+
{
|
|
100
|
+
id: "s1",
|
|
101
|
+
name: "Pushed Item",
|
|
102
|
+
updatedTs: 1773753718052,
|
|
103
|
+
deleted: false,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
pulled: [
|
|
107
|
+
{
|
|
108
|
+
id: "s2",
|
|
109
|
+
name: "Patch Item",
|
|
110
|
+
updatedTs: 1773753718052,
|
|
111
|
+
deleted: false,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export const AllUpToDate = meta.story({
|
|
121
|
+
args: {
|
|
122
|
+
syncState: "IDLE",
|
|
123
|
+
lastSuccessfulSyncTs: 1773753718052,
|
|
124
|
+
syncLog: [
|
|
125
|
+
{
|
|
126
|
+
startedTs: 1773753718052,
|
|
127
|
+
pull: new Success({ pulled: [] }),
|
|
128
|
+
push: new Success({ pushed: [], pulled: [] }),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const PullFailure = meta.story({
|
|
135
|
+
args: {
|
|
136
|
+
syncState: "ERROR",
|
|
137
|
+
lastSuccessfulSyncTs: 1773753718052,
|
|
138
|
+
syncLog: [
|
|
139
|
+
{
|
|
140
|
+
startedTs: 1773753718052,
|
|
141
|
+
pull: new Failure({ type: "NETWORK_ERROR" as const }),
|
|
142
|
+
push: null,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export const DeletedAndUnnamedItems = meta.story({
|
|
149
|
+
args: {
|
|
150
|
+
syncState: "IDLE",
|
|
151
|
+
lastSuccessfulSyncTs: 1773753718052,
|
|
152
|
+
syncLog: [
|
|
153
|
+
{
|
|
154
|
+
startedTs: 1773753718052,
|
|
155
|
+
pull: null,
|
|
156
|
+
push: new Success({
|
|
157
|
+
pushed: [
|
|
158
|
+
{
|
|
159
|
+
id: "d1",
|
|
160
|
+
name: "Removed Campaign",
|
|
161
|
+
updatedTs: 1773753718052,
|
|
162
|
+
deleted: true,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "d2",
|
|
166
|
+
name: "",
|
|
167
|
+
updatedTs: 1773753718052,
|
|
168
|
+
deleted: false,
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
pulled: [],
|
|
172
|
+
}),
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
export const MultipleSyncEntries = meta.story({
|
|
179
|
+
args: {
|
|
180
|
+
syncState: "IDLE",
|
|
181
|
+
lastSuccessfulSyncTs: 1773753718052,
|
|
182
|
+
syncLog: [
|
|
183
|
+
{
|
|
184
|
+
startedTs: 1773753718052,
|
|
185
|
+
pull: new Success({ pulled: [] }),
|
|
186
|
+
push: new Success({ pushed: [], pulled: [] }),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
startedTs: 1773753608052,
|
|
190
|
+
pull: new Success({
|
|
191
|
+
pulled: [
|
|
192
|
+
{
|
|
193
|
+
id: "m1",
|
|
194
|
+
name: "Synced Item",
|
|
195
|
+
updatedTs: 1773753608052,
|
|
196
|
+
deleted: false,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
}),
|
|
200
|
+
push: new Success({
|
|
201
|
+
pushed: [
|
|
202
|
+
{
|
|
203
|
+
id: "m2",
|
|
204
|
+
name: "Pushed Item",
|
|
205
|
+
updatedTs: 1773753608052,
|
|
206
|
+
deleted: false,
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
pulled: [],
|
|
210
|
+
}),
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
startedTs: 1773753508052,
|
|
214
|
+
pull: new Failure({ type: "NETWORK_ERROR" as const }),
|
|
215
|
+
push: null,
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createThemeContract,
|
|
3
|
+
fallbackVar,
|
|
4
|
+
keyframes,
|
|
5
|
+
style,
|
|
6
|
+
} from "@vanilla-extract/css";
|
|
7
|
+
import { recipe } from "@vanilla-extract/recipes";
|
|
8
|
+
|
|
9
|
+
const bounce = keyframes({
|
|
10
|
+
"0%": { translate: "0 0" },
|
|
11
|
+
"25%": { translate: "0 -10%" },
|
|
12
|
+
"50%": { translate: "0 0" },
|
|
13
|
+
"100%": { translate: "0 0" },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const rotation = keyframes({
|
|
17
|
+
from: { transform: `rotate(0deg)` },
|
|
18
|
+
to: { transform: `rotate(360deg)` },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const syncTheme = createThemeContract({
|
|
22
|
+
idle: null,
|
|
23
|
+
inactive: null,
|
|
24
|
+
syncing: null,
|
|
25
|
+
error: null,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const backplate = style({
|
|
29
|
+
cx: 10,
|
|
30
|
+
cy: 10,
|
|
31
|
+
r: 10,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const icon = {
|
|
35
|
+
container: recipe({
|
|
36
|
+
base: {},
|
|
37
|
+
|
|
38
|
+
variants: {
|
|
39
|
+
rotate: {
|
|
40
|
+
true: {
|
|
41
|
+
animation: `${rotation} linear 2s both infinite`,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
|
|
47
|
+
group: style({
|
|
48
|
+
stroke: "none",
|
|
49
|
+
fill: "none",
|
|
50
|
+
fillRule: "evenodd",
|
|
51
|
+
}),
|
|
52
|
+
|
|
53
|
+
shape: style({
|
|
54
|
+
fill: "white",
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
idle: style([
|
|
58
|
+
backplate,
|
|
59
|
+
{
|
|
60
|
+
fill: fallbackVar(syncTheme.idle, "green"),
|
|
61
|
+
},
|
|
62
|
+
]),
|
|
63
|
+
|
|
64
|
+
inactive: style([
|
|
65
|
+
backplate,
|
|
66
|
+
{
|
|
67
|
+
fill: fallbackVar(syncTheme.inactive, "blue"),
|
|
68
|
+
},
|
|
69
|
+
]),
|
|
70
|
+
|
|
71
|
+
error: style([
|
|
72
|
+
backplate,
|
|
73
|
+
{
|
|
74
|
+
fill: fallbackVar(syncTheme.error, "red"),
|
|
75
|
+
},
|
|
76
|
+
]),
|
|
77
|
+
|
|
78
|
+
syncing: style([
|
|
79
|
+
backplate,
|
|
80
|
+
{
|
|
81
|
+
fill: fallbackVar(syncTheme.syncing, "purple"),
|
|
82
|
+
},
|
|
83
|
+
]),
|
|
84
|
+
|
|
85
|
+
bounce: style({
|
|
86
|
+
vars: {
|
|
87
|
+
"--duration": "1.5s",
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
animation: `${bounce} var(--duration) both infinite`,
|
|
91
|
+
r: 1,
|
|
92
|
+
fill: "white",
|
|
93
|
+
|
|
94
|
+
selectors: {
|
|
95
|
+
"&:nth-child(2)": {
|
|
96
|
+
animationDelay: "calc(var(--duration) * -0.1)",
|
|
97
|
+
},
|
|
98
|
+
"&:nth-child(3)": {
|
|
99
|
+
animationDelay: "calc(var(--duration) * -0.2)",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const log = {
|
|
106
|
+
item: style({
|
|
107
|
+
display: "grid",
|
|
108
|
+
gridTemplateColumns: "auto minmax(0, 1fr)",
|
|
109
|
+
alignItems: "center",
|
|
110
|
+
gap: "1rem",
|
|
111
|
+
padding: "1rem 0",
|
|
112
|
+
textAlign: "start",
|
|
113
|
+
borderBlockStart: "1px solid hsl(0 0% 90%)",
|
|
114
|
+
inlineSize: "100%",
|
|
115
|
+
}),
|
|
116
|
+
|
|
117
|
+
disclosureContent: style({
|
|
118
|
+
display: "grid",
|
|
119
|
+
gap: "1rem",
|
|
120
|
+
borderInlineStart: "1px solid hsl(0 0% 90%)",
|
|
121
|
+
paddingInlineStart: "1rem",
|
|
122
|
+
marginBlockEnd: "1.5rem",
|
|
123
|
+
}),
|
|
124
|
+
|
|
125
|
+
itemTimestamp: style({ opacity: 0.5 }),
|
|
126
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useSyncState } from "../store/index.tsx";
|
|
2
|
+
import { SyncIcon } from "../Sync/SyncIcon.tsx";
|
|
3
|
+
import { accountIcon } from "./style.css.ts";
|
|
4
|
+
|
|
5
|
+
export function AccountIcon(props: { imgSrc: string }) {
|
|
6
|
+
const { imgSrc } = props;
|
|
7
|
+
const syncState = useSyncState();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className={accountIcon.container}>
|
|
11
|
+
<SyncIcon status={syncState} className={accountIcon.syncIcon} />
|
|
12
|
+
<img src={imgSrc} alt="" className={accountIcon.image} />
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M28 0C43.464 0 56 12.536 56 28C56 31.3019 55.4265 34.4697 54.377 37.4111C52.2143 35.3015 49.2598 34 46 34C39.3726 34 34 39.3726 34 46C34 49.2598 35.3015 52.2143 37.4111 54.377C34.4697 55.4265 31.3019 56 28 56C12.536 56 0 43.464 0 28C0 12.536 12.536 0 28 0Z" fill="white"/>
|
|
3
|
+
</svg>
|
package/lib/account/style.css.ts
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import { style } from "@vanilla-extract/css";
|
|
2
2
|
import { Hover, MinWidth } from "../media.ts";
|
|
3
|
+
import iconMask from "./iconMask.svg";
|
|
4
|
+
|
|
5
|
+
export const accountIcon = {
|
|
6
|
+
container: style({
|
|
7
|
+
position: "relative",
|
|
8
|
+
marginInline: "auto",
|
|
9
|
+
|
|
10
|
+
inlineSize: "56px",
|
|
11
|
+
blockSize: "56px",
|
|
12
|
+
}),
|
|
13
|
+
|
|
14
|
+
syncIcon: style({
|
|
15
|
+
position: "absolute",
|
|
16
|
+
right: "0",
|
|
17
|
+
bottom: "0",
|
|
18
|
+
}),
|
|
19
|
+
|
|
20
|
+
image: style({
|
|
21
|
+
mask: `url("${iconMask}") no-repeat`,
|
|
22
|
+
inlineSize: "100%",
|
|
23
|
+
blockSize: "100%",
|
|
24
|
+
borderRadius: "50%",
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
3
27
|
|
|
4
28
|
export const page = style({
|
|
5
29
|
backgroundColor: "white",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createContext, use } from "react";
|
|
2
|
+
|
|
3
|
+
export function createStrictContext<T>() {
|
|
4
|
+
const Context = createContext<T | null>(null);
|
|
5
|
+
|
|
6
|
+
const useStrictContext = () => {
|
|
7
|
+
const value = use(Context);
|
|
8
|
+
if (!value) {
|
|
9
|
+
throw new Error(`Value not found in context.`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
return [Context, useStrictContext] as const;
|
|
15
|
+
}
|
package/lib/index.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Components
|
|
2
|
+
export * from "./account/AccountIcon.tsx";
|
|
2
3
|
export * from "./account/CurrentUserFetcher.tsx";
|
|
3
4
|
export * from "./account/JoinCard.tsx";
|
|
4
5
|
export * from "./account/LoginCard.tsx";
|
|
5
6
|
export * from "./account/PasswordResetCard.tsx";
|
|
6
7
|
export * from "./AppConfig/AppConfig.tsx";
|
|
7
8
|
export * from "./AuthCard/AuthCard.tsx";
|
|
9
|
+
export * from "./CacheProvider.tsx";
|
|
8
10
|
export * from "./DialogTrigger/index.tsx";
|
|
9
11
|
export * from "./DocumentTitle/DocumentTitle.tsx";
|
|
10
12
|
export * from "./ExternalLink.tsx";
|
|
@@ -25,6 +27,8 @@ export * from "./ServiceWorkerHandler.tsx";
|
|
|
25
27
|
export * from "./ShareButton/ShareButton.tsx";
|
|
26
28
|
export * from "./SubscribeCard/SubscribeByEmailCard.tsx";
|
|
27
29
|
export * from "./SubscribeCard/SubscribeByPledgeCard.tsx";
|
|
30
|
+
export * from "./Sync/SyncIcon.tsx";
|
|
31
|
+
export * from "./Sync/SyncLog.tsx";
|
|
28
32
|
|
|
29
33
|
// Hooks
|
|
30
34
|
export * from "./RulesetResolver.ts";
|
package/lib/store/index.tsx
CHANGED
|
@@ -1,54 +1,109 @@
|
|
|
1
1
|
import type { UserGameData } from "@indietabletop/types";
|
|
2
|
-
import {
|
|
3
|
-
import { createContext,
|
|
4
|
-
import { fromCallback, fromPromise } from "xstate";
|
|
2
|
+
import { useActorRef, useSelector } from "@xstate/react";
|
|
3
|
+
import { createContext, useMemo, type ReactNode } from "react";
|
|
4
|
+
import { Actor, fromCallback, fromPromise } from "xstate";
|
|
5
|
+
import { useAppConfig } from "../AppConfig/AppConfig.tsx";
|
|
5
6
|
import { Failure, Success } from "../async-op.ts";
|
|
6
7
|
import type { GameCode, IndieTabletopClient } from "../client.ts";
|
|
8
|
+
import { createStrictContext } from "../createStrictContext.ts";
|
|
7
9
|
import type { ModernIDB } from "../ModernIDB/ModernIDB.ts";
|
|
8
10
|
import type { ModernIDBIndexes, ModernIDBSchema } from "../ModernIDB/types.ts";
|
|
9
11
|
import type { CurrentUser } from "../types.ts";
|
|
10
12
|
import {
|
|
11
13
|
machine,
|
|
14
|
+
type AppMachine,
|
|
12
15
|
type PullChangesInput,
|
|
13
16
|
type PushChangesInput,
|
|
14
17
|
} from "./store.ts";
|
|
15
18
|
import type { MachineEvent, PullResult, PushResult } from "./types.ts";
|
|
16
19
|
import { toSyncedItems } from "./utils.ts";
|
|
17
20
|
|
|
18
|
-
export type AppActions =
|
|
19
|
-
clientLogout: (params: { serverUser: CurrentUser }) => Promise<void>;
|
|
20
|
-
serverLogout: () => Promise<void>;
|
|
21
|
-
logout: () => Promise<void>;
|
|
22
|
-
push: () => void;
|
|
23
|
-
pull: () => void;
|
|
24
|
-
};
|
|
21
|
+
export type AppActions = ReturnType<typeof useCreateActions>;
|
|
25
22
|
|
|
26
|
-
|
|
23
|
+
type AppActor = Actor<AppMachine>;
|
|
27
24
|
|
|
28
|
-
const {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} = createActorContext(machine);
|
|
25
|
+
export const [AppMachineContext, useAppMachineContext] = createStrictContext<{
|
|
26
|
+
actorRef: AppActor;
|
|
27
|
+
actions: AppActions;
|
|
28
|
+
}>();
|
|
33
29
|
|
|
34
|
-
export
|
|
30
|
+
export const AppActionsContext = createContext<null | AppActions>(null);
|
|
35
31
|
|
|
36
32
|
export function useCurrentUser() {
|
|
37
|
-
|
|
33
|
+
const { actorRef } = useAppMachineContext();
|
|
34
|
+
return useSelector(actorRef, (s) => s.context.currentUser);
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
export function useSessionInfo() {
|
|
41
|
-
|
|
38
|
+
const { actorRef } = useAppMachineContext();
|
|
39
|
+
return useSelector(actorRef, (s) => s.context.sessionInfo);
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Checks if the app is in an authenticated state.
|
|
44
|
+
*
|
|
45
|
+
* This means that the user that might have been provided in local storage
|
|
46
|
+
* has been checked with the server and they a) have a valid session and
|
|
47
|
+
* b) are matching the current user.
|
|
48
|
+
*/
|
|
49
|
+
export function useIsAuthenticated() {
|
|
50
|
+
const { actorRef } = useAppMachineContext();
|
|
51
|
+
return useSelector(actorRef, (s) => s.matches("authenticated"));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useSyncLog() {
|
|
55
|
+
const { actorRef } = useAppMachineContext();
|
|
56
|
+
return useSelector(actorRef, (s) => s.context.syncLog);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useLastSuccessfulSyncTs() {
|
|
60
|
+
const { actorRef } = useAppMachineContext();
|
|
61
|
+
return useSelector(actorRef, (s) => s.context.lastSuccessfulSyncTs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SyncState = ReturnType<typeof useSyncState>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns the app's current sync state.
|
|
68
|
+
*/
|
|
69
|
+
export function useSyncState() {
|
|
70
|
+
const { actorRef } = useAppMachineContext();
|
|
71
|
+
return useSelector(actorRef, (s) => {
|
|
72
|
+
switch (true) {
|
|
73
|
+
case s.matches({ authenticated: { sync: "syncing" } }): {
|
|
74
|
+
return "SYNCING";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case s.matches({ authenticated: { sync: "waiting" } }): {
|
|
78
|
+
return "WAITING";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
case s.matches({ authenticated: { sync: "idle" } }): {
|
|
82
|
+
const lastSync = s.context.syncLog[0];
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
lastSync?.pull?.type === "FAILURE" ||
|
|
86
|
+
lastSync?.push?.type === "FAILURE"
|
|
87
|
+
) {
|
|
88
|
+
return "ERROR";
|
|
89
|
+
}
|
|
46
90
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
91
|
+
return "IDLE";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case s.matches("unauthenticated") && !!s.context.currentUser: {
|
|
95
|
+
return "AUTHENTICATING";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
default: {
|
|
99
|
+
return "INACTIVE";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
50
104
|
|
|
51
|
-
|
|
105
|
+
export function useAppActions() {
|
|
106
|
+
return useAppMachineContext().actions;
|
|
52
107
|
}
|
|
53
108
|
|
|
54
109
|
export type DatabaseAppMachineMethods = {
|
|
@@ -62,7 +117,7 @@ export type DatabaseAppMachineMethods = {
|
|
|
62
117
|
clearAll(): Promise<Success<string> | Failure<string>>;
|
|
63
118
|
};
|
|
64
119
|
|
|
65
|
-
export function
|
|
120
|
+
export function createMachine<
|
|
66
121
|
Schema extends ModernIDBSchema,
|
|
67
122
|
Indexes extends ModernIDBIndexes<Schema>,
|
|
68
123
|
>(options: {
|
|
@@ -180,62 +235,60 @@ export function createAppMachineProvider<
|
|
|
180
235
|
},
|
|
181
236
|
);
|
|
182
237
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const app = useActorRef();
|
|
188
|
-
|
|
189
|
-
const actions: AppActions = useMemo(() => {
|
|
190
|
-
async function clientLogout(params: { serverUser: CurrentUser }) {
|
|
191
|
-
await database.clearAll();
|
|
192
|
-
app.send({ type: "serverUser", ...params });
|
|
193
|
-
|
|
194
|
-
// Get new session info
|
|
195
|
-
await client.refreshTokens();
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function serverLogout() {
|
|
199
|
-
await client.logout();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async function logout() {
|
|
203
|
-
await client.logout();
|
|
204
|
-
await database.clearAll();
|
|
205
|
-
app.send({ type: "reset" });
|
|
206
|
-
}
|
|
238
|
+
return machine.provide({
|
|
239
|
+
actors: { auth, sync, pullChanges, pushChanges },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
207
242
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
243
|
+
function useCreateActions(app: AppActor) {
|
|
244
|
+
const { database, client } = useAppConfig();
|
|
245
|
+
|
|
246
|
+
return useMemo(() => {
|
|
247
|
+
async function clientLogout(params: { serverUser: CurrentUser }) {
|
|
248
|
+
await database.clearAll();
|
|
249
|
+
app.send({ type: "serverUser", ...params });
|
|
250
|
+
|
|
251
|
+
// Get new session info
|
|
252
|
+
await client.refreshTokens();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function serverLogout() {
|
|
256
|
+
await client.logout();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function logout() {
|
|
260
|
+
await client.logout();
|
|
261
|
+
await database.clearAll();
|
|
262
|
+
app.send({ type: "reset" });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function push() {
|
|
266
|
+
app.send({ type: "push" });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function pull() {
|
|
270
|
+
app.send({ type: "pull" });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
pull,
|
|
275
|
+
push,
|
|
276
|
+
logout,
|
|
277
|
+
serverLogout,
|
|
278
|
+
clientLogout,
|
|
279
|
+
};
|
|
280
|
+
}, [app]);
|
|
281
|
+
}
|
|
211
282
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
283
|
+
export function AppMachineProvider(props: {
|
|
284
|
+
value: typeof machine;
|
|
285
|
+
children: ReactNode;
|
|
286
|
+
}) {
|
|
287
|
+
const actorRef = useActorRef(props.value);
|
|
288
|
+
const actions = useCreateActions(actorRef);
|
|
289
|
+
const context = useMemo(() => ({ actorRef, actions }), [actorRef, actions]);
|
|
215
290
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
logout,
|
|
220
|
-
serverLogout,
|
|
221
|
-
clientLogout,
|
|
222
|
-
};
|
|
223
|
-
}, [app]);
|
|
224
|
-
|
|
225
|
-
return <AppActionsContext value={actions}>{children}</AppActionsContext>;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return function AppMachineProvider(props: { children: ReactNode }) {
|
|
229
|
-
const { children } = props;
|
|
230
|
-
|
|
231
|
-
return (
|
|
232
|
-
<InternalMachineProvider
|
|
233
|
-
logic={machine.provide({
|
|
234
|
-
actors: { auth, sync, pullChanges, pushChanges },
|
|
235
|
-
})}
|
|
236
|
-
>
|
|
237
|
-
<AppActionsProvider>{children}</AppActionsProvider>
|
|
238
|
-
</InternalMachineProvider>
|
|
239
|
-
);
|
|
240
|
-
};
|
|
291
|
+
return (
|
|
292
|
+
<AppMachineContext value={context}>{props.children}</AppMachineContext>
|
|
293
|
+
);
|
|
241
294
|
}
|
package/lib/store/store.ts
CHANGED
|
@@ -150,8 +150,9 @@ const config = setup({
|
|
|
150
150
|
}),
|
|
151
151
|
|
|
152
152
|
patchSessionInfo: assign(({ context }, newSessionInfo: SessionInfo) => {
|
|
153
|
-
const sessionInfo =
|
|
154
|
-
|
|
153
|
+
const sessionInfo =
|
|
154
|
+
context.sessionInfo ?
|
|
155
|
+
{ ...context.sessionInfo, expiresTs: newSessionInfo.expiresTs }
|
|
155
156
|
: newSessionInfo;
|
|
156
157
|
|
|
157
158
|
safeStorage.setItem("sessionInfo", sessionInfo);
|
|
@@ -225,6 +226,8 @@ const config = setup({
|
|
|
225
226
|
},
|
|
226
227
|
});
|
|
227
228
|
|
|
229
|
+
export type AppMachine = typeof machine;
|
|
230
|
+
|
|
228
231
|
export const machine = config.createMachine({
|
|
229
232
|
/** @xstate-layout N4IgpgJg5mDOIC5QEMAOqDEAnOYAuA2gAwC6ioqA9rAJZ42UB25IAHogLQBMRAbAHQBOQbwCMggOy8ALES7DRAGhABPTvIn8AzAA5B0rdN4BWCRKKDRvAL7XlaVPwCujZE7wALMI3oBjZHiQGL5OWDg+AKqwYFjEZEggVLT0TCzsCEZc-FyiXHqiOtJcvLwSymoI3Fqi-LkiIjkSIrIStvbozq7uXj40-oEQwaHheFExBKLxFNR0DMwJ6dJL-GJ1gkTSwubS5Yj6mjrG602F0ietdiAOnW6e3n4BQSFh92OxXFOJMynzoIs6uwQljaVw6LluPQeAww0SwADcYm84iwkrNUgtEHkiICOKI8SDrhD7n1HoNniMkaQUd85mlELkctktLxDkQdDoJFYuGVVPTREZaoZjMKdNzRFILu1HETev0nsNXrCJp9UT86QhRVkiHVjDIiHwSlxAXiBaJDEVqkRzNVpASOjKofKXpElR9qclaRiENjed67dLusS5YNorBaEwAJKMABmlGRCVVnr+9JKAkE3LyPF4zN0Sl9JoEZukwuMos5Ev9-AdJOhofDjAAoqxUDQcBB49MPejkwhDDUtERqjwDBIuMYszitOnsmaOdJcqW5zZLoTA7LSfxYCpGL5N9vfDRGFAMBAmGBN3hHlX7WvHRA9zuHwejx2vl3fmxMcVpPxjOLNgOeISAYgLcsY-DmJIxjVJYg6iBslbVsGT5Poex6oE4sAeK+ibdp+CDwdqQjSHogimPopiGICxaaAU6bchseS6IIiG3jWkAoVuO5ofwGEADZ8WhJ5nvwh5wpQADW56rnc64DJx+48fxglHggYmUP0cxxDhNJ4ekuQbPwhz6AYA6FCUWjGgIHLCLopj8tqJmsbJd4KdxR68U4AlCTEWCUFgvF8QEsZYAAtteAYuex95cbusVKV5KlQGpjDiZpTDaVSCa6R++n8j+YjmKYo7ATovDGgY2SbCY2YkUczmQtFbnPlAnlYUJp6MOe6lSRFVZschsWoR5GHtap6npYwmUqjl6pWBIOhGdq1r6pyYqAgOtR8MY2q6HkYiag1QYbkN8UjZhHg+WE-mBcF-nhTJjWDfuw2taNl3jalGkBFppA6e+c26tZpksiRFjzgCvpcFwP7QUU46CDoQH8sYR1yRxQ00BAfFgBg73-WiuX0usAh-sBghaOYZWFICUg-nR0HajDejAWjrmY9juPKQTapejkxRLbk7KDoawqAtmgi-tDxhLNBZljmzTUczjGA80m+EFGI2haDrZhyKKxZGr6SNaNrOvjkU2aiqIivPY+ADuyCzEeeMXWren0lIWRkwBFhIzyFQslkdE8DLXBTtmkqgpFT0nS9jvO8erCwJegRVtGgRYAAFKIO1EAAlBgj3HfJQ0J-QL5ZZ2hNzUUWTcrwMMSNBZEmYCpaaI3lN8PqPDAbaIKMJQEBwCwDjujXXrcDLQgiOIdNyAoOKrLUBjjloPDHFOlbggNpIT7zPa4sBEH6gObImBy+qQxUHBGDUueitmMhmufqMrjeUXBgf6vpAbTI6CIKYAC9RLK+jvlIFYa9zakUcu-KU-Uv4bkPGAQSUAaAACMcY-w9kCQy4ckZ4gUGRcQgIKbgRRpsM0RYmhR2LujGK+4cFEw1KOABQDyYRwppOZYMgtC6m1DIUUSNlwIKQnHR8Z0oDMPVOmIg7DgEU1ATicUi0+FwxkLBeCdDP6x1Li9KRnlvJHhkV6EQi0CEcJAbwbh+YCrbW1MtWQiMWIfxjiXDGBjFLnTGtI7KAMvT8jyPwIw1ReBskKFYaCFUjI6z4Pwng1DCq2wkXFbxrVYqQFMT2cQeJT4imECRIoAd6Sm34SYfUiMzDh3HDoFJ+jJHpP4NGJ2OMIDZPwokzQ9l5xHHgrnBaoEpy1DMCRGGMMRBmFEdHRBejPGPixtg-xk8ew5AKNoJmHIdDdw5LTRu2hdTimbsAsU9T5m7nLmhDp+kIbaE5Drfko4ii5D2VkcpRzhTAW5FoWwtggA */
|
|
230
233
|
id: "app",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indietabletop/appkit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0-rc.0",
|
|
4
4
|
"description": "A collection of modules used in apps built by Indie Tabletop Club",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"test": "vitest run",
|
|
12
12
|
"test:dev": "vitest watch",
|
|
13
13
|
"test:release": "npm run test && npm run typecheck",
|
|
14
|
-
"storybook": "storybook dev",
|
|
14
|
+
"storybook": "storybook dev --port 11111",
|
|
15
15
|
"typecheck": "tsc --noEmit"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
@@ -31,32 +31,33 @@
|
|
|
31
31
|
"react": "^18.0.0 || ^19.0.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@storybook/addon-docs": "^10.
|
|
35
|
-
"@storybook/addon-links": "^10.
|
|
36
|
-
"@storybook/react-vite": "^10.
|
|
37
|
-
"@types/react": "^19.2.
|
|
38
|
-
"
|
|
34
|
+
"@storybook/addon-docs": "^10.2.19",
|
|
35
|
+
"@storybook/addon-links": "^10.2.19",
|
|
36
|
+
"@storybook/react-vite": "^10.2.19",
|
|
37
|
+
"@types/react": "^19.2.14",
|
|
38
|
+
"fake-indexeddb": "^6.2.5",
|
|
39
|
+
"msw": "^2.12.12",
|
|
39
40
|
"msw-storybook-addon": "^2.0.6",
|
|
40
|
-
"np": "^
|
|
41
|
-
"storybook": "^10.
|
|
41
|
+
"np": "^11.0.2",
|
|
42
|
+
"storybook": "^10.2.19",
|
|
42
43
|
"typescript": "^5.9.3",
|
|
43
44
|
"vite": "^7.3.1",
|
|
44
|
-
"vitest": "^4.0
|
|
45
|
+
"vitest": "^4.1.0"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
|
-
"@ariakit/react": "^0.4.
|
|
48
|
-
"@indietabletop/tooling": "^
|
|
49
|
-
"@indietabletop/types": "^1.
|
|
50
|
-
"@vanilla-extract/css": "^1.
|
|
48
|
+
"@ariakit/react": "^0.4.23",
|
|
49
|
+
"@indietabletop/tooling": "^6.0.0",
|
|
50
|
+
"@indietabletop/types": "^1.5.0",
|
|
51
|
+
"@vanilla-extract/css": "^1.19.0",
|
|
51
52
|
"@vanilla-extract/dynamic": "^2.1.5",
|
|
52
53
|
"@vanilla-extract/recipes": "^0.5.7",
|
|
53
54
|
"@vanilla-extract/sprinkles": "^1.6.5",
|
|
54
|
-
"@xstate/react": "^6.
|
|
55
|
-
"nanoid": "^5.1.
|
|
55
|
+
"@xstate/react": "^6.1.0",
|
|
56
|
+
"nanoid": "^5.1.7",
|
|
56
57
|
"superstruct": "^2.0.2",
|
|
57
|
-
"swr": "^2.
|
|
58
|
+
"swr": "^2.4.1",
|
|
58
59
|
"wouter": "^3.9.0",
|
|
59
|
-
"xstate": "^5.
|
|
60
|
+
"xstate": "^5.28.0"
|
|
60
61
|
},
|
|
61
62
|
"msw": {
|
|
62
63
|
"workerDirectory": [
|