@stnd/account 0.1.1
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 +8 -0
- package/README.md +146 -0
- package/actions/system.js +28 -0
- package/auth.js +235 -0
- package/client.svelte.js +19 -0
- package/index.module.js +59 -0
- package/middleware.js +59 -0
- package/models/User.js +107 -0
- package/package.json +36 -0
- package/route.js +25 -0
- package/schema.sql +70 -0
- package/ui/Avatar.svelte +115 -0
- package/ui/UserMenu.svelte +225 -0
- package/ui/preferences.js +120 -0
- package/views/LoginView.svelte +233 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stnd/account",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"svelte": "./ui/UserMenu.svelte",
|
|
6
|
+
"main": "./index.module.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.module.js",
|
|
9
|
+
"./auth": "./auth.js",
|
|
10
|
+
"./client": "./client.svelte.js",
|
|
11
|
+
"./models": "./models",
|
|
12
|
+
"./ui": "./ui",
|
|
13
|
+
"./views": "./views",
|
|
14
|
+
"./*": "./*"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@better-auth/kysely-adapter": "1.6.22",
|
|
18
|
+
"@better-auth/passkey": "^1.6.22",
|
|
19
|
+
"better-auth": "^1.6.22",
|
|
20
|
+
"kysely": "^0.29.2",
|
|
21
|
+
"kysely-d1": "^0.4.0",
|
|
22
|
+
"nanostores": "^1.4.0",
|
|
23
|
+
"wrangler": "^4.105.0",
|
|
24
|
+
"@stnd/art": "0.1.0",
|
|
25
|
+
"@stnd/icon": "0.2.0",
|
|
26
|
+
"@stnd/log": "0.5.0",
|
|
27
|
+
"@stnd/styles": "0.5.2",
|
|
28
|
+
"@stnd/utils": "0.5.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"astro": ">=5.0.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/route.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { initAuth } from "./auth.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BetterAuth API Route Handler
|
|
5
|
+
*
|
|
6
|
+
* Mounts the BetterAuth catch-all handler to /api/auth/*
|
|
7
|
+
*/
|
|
8
|
+
export const ALL = async (context) => {
|
|
9
|
+
const { request, locals } = context;
|
|
10
|
+
|
|
11
|
+
let env = locals?.cfContext?.env ?? locals?.env ?? {};
|
|
12
|
+
if (Object.keys(env).length === 0) {
|
|
13
|
+
try {
|
|
14
|
+
const cf = await import("cloudflare:workers");
|
|
15
|
+
if (cf?.env) env = cf.env;
|
|
16
|
+
} catch { } // Ignore
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const auth = initAuth(env);
|
|
20
|
+
return await auth.handler(request);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error("[Account Route Error]", e);
|
|
23
|
+
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
|
24
|
+
}
|
|
25
|
+
};
|
package/schema.sql
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- BetterAuth Standard Schema for Cloudflare D1
|
|
2
|
+
-- Save this file in your app's migrations folder or run directly via wrangler d1 execute
|
|
3
|
+
|
|
4
|
+
CREATE TABLE "user" (
|
|
5
|
+
"id" TEXT NOT NULL PRIMARY KEY,
|
|
6
|
+
"name" TEXT NOT NULL,
|
|
7
|
+
"email" TEXT NOT NULL UNIQUE,
|
|
8
|
+
"emailVerified" INTEGER NOT NULL,
|
|
9
|
+
"image" TEXT,
|
|
10
|
+
"createdAt" INTEGER NOT NULL,
|
|
11
|
+
"updatedAt" INTEGER NOT NULL,
|
|
12
|
+
"access_rights" TEXT,
|
|
13
|
+
"settings" TEXT,
|
|
14
|
+
"role" TEXT,
|
|
15
|
+
"banned" INTEGER,
|
|
16
|
+
"banReason" TEXT,
|
|
17
|
+
"banExpires" INTEGER
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE "session" (
|
|
21
|
+
"id" TEXT NOT NULL PRIMARY KEY,
|
|
22
|
+
"expiresAt" INTEGER NOT NULL,
|
|
23
|
+
"token" TEXT NOT NULL UNIQUE,
|
|
24
|
+
"createdAt" INTEGER NOT NULL,
|
|
25
|
+
"updatedAt" INTEGER NOT NULL,
|
|
26
|
+
"ipAddress" TEXT,
|
|
27
|
+
"userAgent" TEXT,
|
|
28
|
+
"userId" TEXT NOT NULL,
|
|
29
|
+
FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE "account" (
|
|
33
|
+
"id" TEXT NOT NULL PRIMARY KEY,
|
|
34
|
+
"accountId" TEXT NOT NULL,
|
|
35
|
+
"providerId" TEXT NOT NULL,
|
|
36
|
+
"userId" TEXT NOT NULL,
|
|
37
|
+
"accessToken" TEXT,
|
|
38
|
+
"refreshToken" TEXT,
|
|
39
|
+
"idToken" TEXT,
|
|
40
|
+
"accessTokenExpiresAt" INTEGER,
|
|
41
|
+
"refreshTokenExpiresAt" INTEGER,
|
|
42
|
+
"scope" TEXT,
|
|
43
|
+
"password" TEXT,
|
|
44
|
+
"createdAt" INTEGER NOT NULL,
|
|
45
|
+
"updatedAt" INTEGER NOT NULL,
|
|
46
|
+
FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE "verification" (
|
|
50
|
+
"id" TEXT NOT NULL PRIMARY KEY,
|
|
51
|
+
"identifier" TEXT NOT NULL,
|
|
52
|
+
"value" TEXT NOT NULL,
|
|
53
|
+
"expiresAt" INTEGER NOT NULL,
|
|
54
|
+
"createdAt" INTEGER,
|
|
55
|
+
"updatedAt" INTEGER
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE "passkey" (
|
|
59
|
+
"id" TEXT NOT NULL PRIMARY KEY,
|
|
60
|
+
"name" TEXT,
|
|
61
|
+
"publicKey" TEXT NOT NULL,
|
|
62
|
+
"userId" TEXT NOT NULL,
|
|
63
|
+
"webauthnUserID" TEXT NOT NULL,
|
|
64
|
+
"counter" INTEGER NOT NULL,
|
|
65
|
+
"deviceType" TEXT NOT NULL,
|
|
66
|
+
"backedUp" INTEGER NOT NULL,
|
|
67
|
+
"transports" TEXT,
|
|
68
|
+
"createdAt" INTEGER,
|
|
69
|
+
FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE
|
|
70
|
+
);
|
package/ui/Avatar.svelte
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* Standard Avatar — A premium, reusable avatar component.
|
|
4
|
+
* Handles:
|
|
5
|
+
* 1. Profile Image (src)
|
|
6
|
+
* 2. Generative Art Fallback (via @stnd/art)
|
|
7
|
+
* 3. Initials overlay
|
|
8
|
+
*
|
|
9
|
+
* When used inside an <IdentityProvider>, it auto-reads
|
|
10
|
+
* the user's visual identity. Otherwise, falls back to
|
|
11
|
+
* name/email props as the seed.
|
|
12
|
+
*/
|
|
13
|
+
import { getContext } from "svelte";
|
|
14
|
+
import GenerativeArt from "@stnd/art/components/GenerativeArt.svelte";
|
|
15
|
+
import { hashCode } from "@stnd/art/lib/seed.js";
|
|
16
|
+
import { all as allStyles } from "@stnd/art/lib/styles/index.js";
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
src = null,
|
|
20
|
+
name = "",
|
|
21
|
+
email = "",
|
|
22
|
+
size = "var(--space-12)",
|
|
23
|
+
border = false,
|
|
24
|
+
shadow = false,
|
|
25
|
+
} = $props();
|
|
26
|
+
|
|
27
|
+
// Try to read identity from context (IdentityProvider)
|
|
28
|
+
// Falls back to local seed derivation if not wrapped
|
|
29
|
+
const getIdentity = getContext("identity") ?? null;
|
|
30
|
+
|
|
31
|
+
const localSeed = $derived(name || email || "guest");
|
|
32
|
+
const localHash = $derived(hashCode(localSeed));
|
|
33
|
+
const localStyle = $derived(allStyles[localHash % allStyles.length].name);
|
|
34
|
+
|
|
35
|
+
// Use identity context if available, otherwise derive locally
|
|
36
|
+
const seed = $derived(
|
|
37
|
+
getIdentity ? getIdentity().seed("avatar") : localSeed,
|
|
38
|
+
);
|
|
39
|
+
const style = $derived(
|
|
40
|
+
getIdentity ? getIdentity().signatureStyle : localStyle,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const initial = $derived(
|
|
44
|
+
(name || (getIdentity ? getIdentity().username : "") || email || "?")
|
|
45
|
+
.split(/[ @._-]/)
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.map((part) => part[0])
|
|
48
|
+
.join("")
|
|
49
|
+
.slice(0, 2)
|
|
50
|
+
.toUpperCase(),
|
|
51
|
+
);
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<div
|
|
55
|
+
class="stnd-avatar"
|
|
56
|
+
style:--avatar-size={size}
|
|
57
|
+
class:has-border={border}
|
|
58
|
+
class:has-shadow={shadow}
|
|
59
|
+
>
|
|
60
|
+
{#if src}
|
|
61
|
+
<img {src} alt={name || "User Avatar"} loading="lazy" />
|
|
62
|
+
{:else}
|
|
63
|
+
<GenerativeArt {seed} variant={style} resolution={200}>
|
|
64
|
+
<span class="initials">{initial}</span>
|
|
65
|
+
</GenerativeArt>
|
|
66
|
+
{/if}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<style>
|
|
70
|
+
.stnd-avatar {
|
|
71
|
+
width: var(--avatar-size);
|
|
72
|
+
height: var(--avatar-size);
|
|
73
|
+
border-radius: 50%;
|
|
74
|
+
overflow: hidden;
|
|
75
|
+
position: relative;
|
|
76
|
+
display: flex;
|
|
77
|
+
align-items: center;
|
|
78
|
+
justify-content: center;
|
|
79
|
+
background: var(--color-background);
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.has-border {
|
|
84
|
+
outline: 2px solid var(--identity-primary, oklch(100% 0 0 / 0.3));
|
|
85
|
+
outline-offset: -2px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.has-shadow {
|
|
89
|
+
box-shadow: 0 2px 12px var(--identity-glow, oklch(0% 0 0 / 0.25));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
img {
|
|
93
|
+
width: 100%;
|
|
94
|
+
height: 100%;
|
|
95
|
+
object-fit: cover;
|
|
96
|
+
filter: saturate(0.9);
|
|
97
|
+
display: block;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
img:hover {
|
|
101
|
+
filter: saturate(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.initials {
|
|
105
|
+
font-size: calc(var(--avatar-size) * 0.35);
|
|
106
|
+
font-weight: 700;
|
|
107
|
+
letter-spacing: -0.02em;
|
|
108
|
+
color: var(--color-on-accent, white);
|
|
109
|
+
text-shadow:
|
|
110
|
+
0 1px 4px rgba(0, 0, 0, 0.4),
|
|
111
|
+
0 0 12px rgba(0, 0, 0, 0.15);
|
|
112
|
+
font-family: var(--font-interface);
|
|
113
|
+
pointer-events: none;
|
|
114
|
+
}
|
|
115
|
+
</style>
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import Avatar from "./Avatar.svelte";
|
|
3
|
+
import Icon from "@stnd/icon/Icon.svelte";
|
|
4
|
+
import { onMount } from "svelte";
|
|
5
|
+
import { signOut } from "../client.svelte.js";
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
user,
|
|
9
|
+
children,
|
|
10
|
+
onLogoutCallback = () => window.location.reload(),
|
|
11
|
+
avatarHref,
|
|
12
|
+
avatarLauncherView,
|
|
13
|
+
} = $props();
|
|
14
|
+
|
|
15
|
+
let isOpen = $state(false);
|
|
16
|
+
let container = $state(null);
|
|
17
|
+
let hoverTimer;
|
|
18
|
+
|
|
19
|
+
function handleMouseEnter() {
|
|
20
|
+
clearTimeout(hoverTimer);
|
|
21
|
+
isOpen = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function handleMouseLeave() {
|
|
25
|
+
hoverTimer = setTimeout(() => {
|
|
26
|
+
isOpen = false;
|
|
27
|
+
}, 300);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onMount(() => {
|
|
31
|
+
const handleOutside = (e) => {
|
|
32
|
+
if (isOpen && container && !container.contains(e.target)) {
|
|
33
|
+
isOpen = false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
window.addEventListener("click", handleOutside);
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
window.removeEventListener("click", handleOutside);
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function toggleMenu(e) {
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
isOpen = !isOpen;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function handleLogout() {
|
|
49
|
+
isOpen = false;
|
|
50
|
+
await signOut({
|
|
51
|
+
fetchOptions: {
|
|
52
|
+
onSuccess: onLogoutCallback,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
{#if user}
|
|
59
|
+
<div
|
|
60
|
+
class="user-avatar-wrapper no-rhytm"
|
|
61
|
+
bind:this={container}
|
|
62
|
+
onmouseenter={handleMouseEnter}
|
|
63
|
+
onmouseleave={handleMouseLeave}
|
|
64
|
+
role="none"
|
|
65
|
+
>
|
|
66
|
+
{#if avatarLauncherView}
|
|
67
|
+
<button
|
|
68
|
+
data-launcher-view={avatarLauncherView}
|
|
69
|
+
class="user-avatar-trigger no-shadow no-decoration"
|
|
70
|
+
title={user.name || user.username || user.email}
|
|
71
|
+
aria-expanded={isOpen}
|
|
72
|
+
aria-haspopup="true"
|
|
73
|
+
style="background: transparent; border: none; padding: 0;"
|
|
74
|
+
>
|
|
75
|
+
<Avatar
|
|
76
|
+
src={user.image}
|
|
77
|
+
name={user.username || user.name}
|
|
78
|
+
email={user.email}
|
|
79
|
+
size="100%"
|
|
80
|
+
border={false}
|
|
81
|
+
shadow={false}
|
|
82
|
+
/>
|
|
83
|
+
</button>
|
|
84
|
+
{:else}
|
|
85
|
+
<a
|
|
86
|
+
href={avatarHref || `/@${user.username}`}
|
|
87
|
+
class="user-avatar-trigger no-shadow no-decoration"
|
|
88
|
+
title={user.name || user.username || user.email}
|
|
89
|
+
aria-expanded={isOpen}
|
|
90
|
+
aria-haspopup="true"
|
|
91
|
+
>
|
|
92
|
+
<Avatar
|
|
93
|
+
src={user.image}
|
|
94
|
+
name={user.username || user.name}
|
|
95
|
+
email={user.email}
|
|
96
|
+
size="100%"
|
|
97
|
+
border={false}
|
|
98
|
+
shadow={false}
|
|
99
|
+
/>
|
|
100
|
+
</a>
|
|
101
|
+
{/if}
|
|
102
|
+
|
|
103
|
+
{#if isOpen}
|
|
104
|
+
<div class="user-dropdown">
|
|
105
|
+
<div class="dropdown-header">
|
|
106
|
+
<h6 class="name">{user.name || user.username || "User"}</h6>
|
|
107
|
+
<span class="text-2xs mt-trim text-subtle">{user.email}</span>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div class="dropdown-divider"></div>
|
|
111
|
+
|
|
112
|
+
{@render children?.()}
|
|
113
|
+
|
|
114
|
+
<button
|
|
115
|
+
class="ghost"
|
|
116
|
+
data-launcher-view="::account-settings"
|
|
117
|
+
onclick={() => (isOpen = false)}
|
|
118
|
+
>
|
|
119
|
+
<Icon icon="settings" />
|
|
120
|
+
<span>Settings</span>
|
|
121
|
+
</button>
|
|
122
|
+
|
|
123
|
+
<button class="ghost logout" onclick={handleLogout}>
|
|
124
|
+
<Icon icon="logout" />
|
|
125
|
+
<span>Log Out</span>
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
{/if}
|
|
129
|
+
</div>
|
|
130
|
+
{/if}
|
|
131
|
+
|
|
132
|
+
<style>
|
|
133
|
+
.user-avatar-wrapper {
|
|
134
|
+
position: relative;
|
|
135
|
+
display: flex;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.user-avatar-trigger {
|
|
139
|
+
width: var(--space);
|
|
140
|
+
height: var(--space);
|
|
141
|
+
display: flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
justify-content: center;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
transition: all var(--transition);
|
|
146
|
+
|
|
147
|
+
box-shadow: var(--shadow-lg), var(--shadow), var(--shadow-ring);
|
|
148
|
+
border-radius: 50%;
|
|
149
|
+
overflow: hidden;
|
|
150
|
+
flex-shrink: 0;
|
|
151
|
+
padding: 0 !important;
|
|
152
|
+
margin: 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.user-avatar-trigger:hover {
|
|
156
|
+
transform: translateY(-1px);
|
|
157
|
+
box-shadow: var(--shadow-glow);
|
|
158
|
+
border-color: var(--color-accent);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.user-dropdown {
|
|
162
|
+
position: absolute;
|
|
163
|
+
top: calc(100% + var(--space-d2));
|
|
164
|
+
font-family: var(--font-interface);
|
|
165
|
+
font-size: var(--size-sm);
|
|
166
|
+
right: 0;
|
|
167
|
+
width: 200px;
|
|
168
|
+
background: var(--color-surface-high);
|
|
169
|
+
border-radius: var(--radius);
|
|
170
|
+
box-shadow: var(--shadow-lift), var(--shadow-raised);
|
|
171
|
+
z-index: 1000;
|
|
172
|
+
padding: var(--space-d2);
|
|
173
|
+
display: flex;
|
|
174
|
+
flex-direction: column;
|
|
175
|
+
gap: var(--space-d8);
|
|
176
|
+
animation: all var(--transition);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@keyframes slideDown {
|
|
180
|
+
from {
|
|
181
|
+
opacity: 0;
|
|
182
|
+
transform: translateY(-10px);
|
|
183
|
+
}
|
|
184
|
+
to {
|
|
185
|
+
opacity: 1;
|
|
186
|
+
transform: translateY(0);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.dropdown-header {
|
|
191
|
+
display: flex;
|
|
192
|
+
flex-direction: column;
|
|
193
|
+
text-align: center;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
:global(.dropdown-header) {
|
|
197
|
+
font-size: var(--size-2xs);
|
|
198
|
+
margin-top: var(--space-d3);
|
|
199
|
+
}
|
|
200
|
+
:global(.dropdown-header h6) {
|
|
201
|
+
font-weight: var(--font-weight-bold);
|
|
202
|
+
font-size: var(--size-sm);
|
|
203
|
+
color: var(--color-foreground);
|
|
204
|
+
margin-top: 0;
|
|
205
|
+
margin-bottom: var(--space-d8);
|
|
206
|
+
text-align: center;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/*:global(.section-title) {
|
|
210
|
+
font-size: var(--size-xs);
|
|
211
|
+
letter-spacing: 0.05em;
|
|
212
|
+
font-weight: 800;
|
|
213
|
+
color: var(--color-muted);
|
|
214
|
+
margin-inline: auto;
|
|
215
|
+
text-align: center;
|
|
216
|
+
margin-bottom: var(--space-d2);
|
|
217
|
+
}*/
|
|
218
|
+
|
|
219
|
+
.dropdown-divider,
|
|
220
|
+
:global(.dropdown-divider) {
|
|
221
|
+
height: 1px;
|
|
222
|
+
background: var(--color-border);
|
|
223
|
+
margin: var(--space-d2) calc(var(--space-d2) * -1);
|
|
224
|
+
}
|
|
225
|
+
</style>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import Log from "@stnd/log";
|
|
2
|
+
|
|
3
|
+
const log = Log({ scope: "AccountPreferences" });
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a simple, per-app preference helper.
|
|
7
|
+
*
|
|
8
|
+
* - Local-first (localStorage) for instant UX.
|
|
9
|
+
* - Syncs to a given API endpoint which is responsible for writing to KV and/or D1.
|
|
10
|
+
*
|
|
11
|
+
* This helper is intentionally app-agnostic: each app passes its own endpoint.
|
|
12
|
+
*/
|
|
13
|
+
export function createPreferenceHelper({ storageNamespace, endpoint }) {
|
|
14
|
+
if (!storageNamespace) {
|
|
15
|
+
throw new Error("storageNamespace is required for createPreferenceHelper");
|
|
16
|
+
}
|
|
17
|
+
if (!endpoint) {
|
|
18
|
+
throw new Error("endpoint is required for createPreferenceHelper");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STORAGE_KEY = `stnd:prefs:${storageNamespace}`;
|
|
22
|
+
|
|
23
|
+
function readLocal() {
|
|
24
|
+
if (typeof window === "undefined") return null;
|
|
25
|
+
try {
|
|
26
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
27
|
+
if (!raw) return null;
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
log.warn("Failed to parse local preferences", e);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeLocal(prefs) {
|
|
36
|
+
if (typeof window === "undefined") return;
|
|
37
|
+
try {
|
|
38
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs || {}));
|
|
39
|
+
} catch (e) {
|
|
40
|
+
log.warn("Failed to write local preferences", e);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fetchRemote() {
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(endpoint, {
|
|
47
|
+
credentials: "include",
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) return null;
|
|
50
|
+
const json = await res.json();
|
|
51
|
+
return (json && json.preferences) || null;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
log.warn("Failed to fetch remote preferences", e);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function pushRemote(patch) {
|
|
59
|
+
try {
|
|
60
|
+
await fetch(endpoint, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
credentials: "include",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ patch }),
|
|
65
|
+
});
|
|
66
|
+
} catch (e) {
|
|
67
|
+
log.warn("Failed to push remote preferences", e);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
/**
|
|
73
|
+
* Local-first read. Returns immediately with local value if present,
|
|
74
|
+
* and in the background hydrates from remote.
|
|
75
|
+
*/
|
|
76
|
+
async get(key, fallback = null) {
|
|
77
|
+
const local = readLocal();
|
|
78
|
+
if (local && Object.prototype.hasOwnProperty.call(local, key)) {
|
|
79
|
+
return local[key];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Background hydrate
|
|
83
|
+
fetchRemote().then((remote) => {
|
|
84
|
+
if (!remote) return;
|
|
85
|
+
writeLocal(remote);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return fallback;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Optimistic write:
|
|
93
|
+
* - Updates localStorage synchronously
|
|
94
|
+
* - Sends a patch to the API (KV/D1 sync is app responsibility)
|
|
95
|
+
*/
|
|
96
|
+
set(key, value) {
|
|
97
|
+
const current = readLocal() || {};
|
|
98
|
+
const next = { ...current, [key]: value };
|
|
99
|
+
writeLocal(next);
|
|
100
|
+
void pushRemote({ [key]: value });
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Load all preferences for this namespace into localStorage.
|
|
105
|
+
*/
|
|
106
|
+
async hydrate() {
|
|
107
|
+
const local = readLocal();
|
|
108
|
+
if (local) return local;
|
|
109
|
+
|
|
110
|
+
const remote = await fetchRemote();
|
|
111
|
+
if (remote) {
|
|
112
|
+
writeLocal(remote);
|
|
113
|
+
return remote;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|