@astronox/core 0.2.2 → 0.2.4
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/package.json +1 -1
- package/src/admin/cache.ts +217 -0
- package/src/admin/index.ts +1 -0
- package/src/auth/firebase/client.ts +37 -7
- package/src/client/formSubmission.ts +177 -0
- package/src/client/index.ts +1 -0
- package/src/components/actions/Button/Button.css +1 -1
- package/src/components/actions/CardAction/CardAction.css +3 -3
- package/src/components/actions/ScrollToTop/ScrollToTop.css +11 -0
- package/src/components/actions/ThemeToggle/ThemeToggle.astro +59 -0
- package/src/components/actions/ThemeToggle/ThemeToggle.css +83 -0
- package/src/components/actions/ThemeToggle/themeToggleController.ts +111 -0
- package/src/components/content/CardBody/CardBody.css +6 -2
- package/src/components/content/CardFooter/CardFooter.astro +9 -0
- package/src/components/content/CardFooter/CardFooter.css +45 -3
- package/src/components/content/CardLink/CardLink.astro +12 -3
- package/src/components/content/CardLink/CardLink.css +32 -0
- package/src/components/content/MarkdownContent/MarkdownContent.css +14 -4
- package/src/components/content/MarkdownContent/MarkdownInline.astro +1 -1
- package/src/components/content/MarkdownContent/contracts.ts +1 -1
- package/src/components/content/MarkdownContent/markdownContentRenderer.ts +7 -4
- package/src/components/data-display/TextPair/TextPair.css +8 -2
- package/src/components/feedback/ThemeBootstrap/ThemeBootstrap.astro +31 -0
- package/src/components/forms/ChoiceGroup/ChoiceGroup.css +1 -1
- package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +203 -0
- package/src/components/forms/PreparedFormBootstrap/contracts.ts +15 -0
- package/src/components/forms/Textarea/Textarea.astro +3 -0
- package/src/components/forms/Textarea/Textarea.css +8 -0
- package/src/components/icons/CircleSymbolIcon.css +3 -3
- package/src/components/index.ts +10 -0
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +5 -1
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.css +12 -17
- package/src/components/navigation/DesktopNavigation.css +4 -1
- package/src/components/navigation/Footer/Footer.astro +102 -0
- package/src/components/navigation/Footer/Footer.css +155 -0
- package/src/components/navigation/Footer/contracts.ts +17 -0
- package/src/components/navigation/Header.astro +20 -2
- package/src/components/navigation/MobileNavigation.astro +107 -43
- package/src/components/navigation/MobileNavigation.css +109 -24
- package/src/components/navigation/NavigationBrand.css +1 -1
- package/src/components/navigation/NavigationControl.css +16 -2
- package/src/components/navigation/NavigationItemContent.astro +6 -1
- package/src/components/navigation/mobileNavigationController.ts +0 -16
- package/src/components/navigation/navigationTypes.ts +9 -1
- package/src/composition/Grid/Grid.css +16 -0
- package/src/data/firestore/client/contracts.ts +23 -0
- package/src/data/firestore/client/firebase.ts +45 -1
- package/src/display/contracts.ts +5 -1
- package/src/layouts/BaseLayout.astro +13 -1
- package/src/patterns/Combobox/comboboxController.ts +69 -0
- package/src/patterns/MapViewport/MapViewport.css +3 -3
- package/src/patterns/Table/TableBody.css +3 -3
- package/src/patterns/Table/TableCell.css +5 -1
- package/src/styles/base.css +24 -7
- package/src/styles/style.css +2 -0
- package/src/styles/tokens.css +60 -15
- package/src/theme/contracts.ts +2 -0
- package/src/theme/index.ts +1 -0
- package/src/theme/storage.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { AdminPage } from './contracts';
|
|
2
|
+
|
|
3
|
+
export type AdminResourceCacheSerializer<TItem, TStored> = {
|
|
4
|
+
parse(value: unknown): TItem | null;
|
|
5
|
+
serialize(item: TItem): TStored;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type AdminResourceCacheConfig<TItem, TStored> = {
|
|
9
|
+
collectionKey: string;
|
|
10
|
+
editorKey: string;
|
|
11
|
+
getKey(item: TItem): string;
|
|
12
|
+
serializer: AdminResourceCacheSerializer<TItem, TStored>;
|
|
13
|
+
sort(items: readonly TItem[]): TItem[];
|
|
14
|
+
ttlMs?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type StoredPage<TStored> = {
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
items: TStored[];
|
|
20
|
+
nextCursor?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type StoredEditor<TStored> = {
|
|
24
|
+
actorId: string;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
item: TStored;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const localCursorPrefix = 'cache:';
|
|
30
|
+
|
|
31
|
+
export function createAdminResourceCache<TItem, TStored>({
|
|
32
|
+
collectionKey,
|
|
33
|
+
editorKey,
|
|
34
|
+
getKey,
|
|
35
|
+
serializer,
|
|
36
|
+
sort,
|
|
37
|
+
ttlMs = 10 * 60 * 1000,
|
|
38
|
+
}: AdminResourceCacheConfig<TItem, TStored>) {
|
|
39
|
+
const collectionCacheKey = (actorId: string) =>
|
|
40
|
+
`${collectionKey}:${encodeURIComponent(actorId)}`;
|
|
41
|
+
const editorCacheKey = (id: string) =>
|
|
42
|
+
`${editorKey}:${encodeURIComponent(id)}`;
|
|
43
|
+
|
|
44
|
+
const readSnapshot = (actorId: string): AdminPage<TItem> | null => {
|
|
45
|
+
try {
|
|
46
|
+
const key = collectionCacheKey(actorId);
|
|
47
|
+
const raw = sessionStorage.getItem(key);
|
|
48
|
+
if (!raw) return null;
|
|
49
|
+
const stored = JSON.parse(raw) as Partial<StoredPage<TStored>>;
|
|
50
|
+
if (
|
|
51
|
+
typeof stored.expiresAt !== 'number' ||
|
|
52
|
+
stored.expiresAt <= Date.now() ||
|
|
53
|
+
!Array.isArray(stored.items)
|
|
54
|
+
) {
|
|
55
|
+
sessionStorage.removeItem(key);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const items: TItem[] = [];
|
|
60
|
+
for (const value of stored.items) {
|
|
61
|
+
const item = serializer.parse(value);
|
|
62
|
+
if (!item) {
|
|
63
|
+
sessionStorage.removeItem(key);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
items.push(item);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
items: sort(items),
|
|
71
|
+
nextCursor:
|
|
72
|
+
typeof stored.nextCursor === 'string' ? stored.nextCursor : undefined,
|
|
73
|
+
};
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const write = (actorId: string, page: AdminPage<TItem>): void => {
|
|
80
|
+
try {
|
|
81
|
+
const payload: StoredPage<TStored> = {
|
|
82
|
+
expiresAt: Date.now() + ttlMs,
|
|
83
|
+
items: sort(page.items).map(serializer.serialize),
|
|
84
|
+
nextCursor: page.nextCursor,
|
|
85
|
+
};
|
|
86
|
+
sessionStorage.setItem(
|
|
87
|
+
collectionCacheKey(actorId),
|
|
88
|
+
JSON.stringify(payload),
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
// Storage is an optimization and must never block administration.
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
editorKey,
|
|
97
|
+
isLocalCursor(cursor?: string): boolean {
|
|
98
|
+
return cursor?.startsWith(localCursorPrefix) === true;
|
|
99
|
+
},
|
|
100
|
+
read(
|
|
101
|
+
actorId: string,
|
|
102
|
+
input?: { cursor?: string; limit?: number },
|
|
103
|
+
): AdminPage<TItem> | null {
|
|
104
|
+
const cached = readSnapshot(actorId);
|
|
105
|
+
if (!cached) return null;
|
|
106
|
+
if (input?.cursor && !input.cursor.startsWith(localCursorPrefix)) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const offset = input?.cursor
|
|
110
|
+
? Number.parseInt(input.cursor.slice(localCursorPrefix.length), 10)
|
|
111
|
+
: 0;
|
|
112
|
+
if (!Number.isSafeInteger(offset) || offset < 0) return null;
|
|
113
|
+
const limit = Math.max(1, Math.floor(input?.limit ?? 25));
|
|
114
|
+
const end = Math.min(cached.items.length, offset + limit);
|
|
115
|
+
return {
|
|
116
|
+
items: cached.items.slice(offset, end),
|
|
117
|
+
nextCursor:
|
|
118
|
+
end < cached.items.length
|
|
119
|
+
? `${localCursorPrefix}${end}`
|
|
120
|
+
: cached.nextCursor,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
find(actorId: string, id: string): TItem | null {
|
|
124
|
+
return (
|
|
125
|
+
readSnapshot(actorId)?.items.find((item) => getKey(item) === id) ??
|
|
126
|
+
null
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
readEditor(actorId: string, id: string): TItem | null {
|
|
130
|
+
try {
|
|
131
|
+
const key = editorCacheKey(id);
|
|
132
|
+
const raw = sessionStorage.getItem(key);
|
|
133
|
+
if (!raw) return null;
|
|
134
|
+
const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
|
|
135
|
+
if (
|
|
136
|
+
stored.actorId !== actorId ||
|
|
137
|
+
typeof stored.expiresAt !== 'number' ||
|
|
138
|
+
stored.expiresAt <= Date.now()
|
|
139
|
+
) {
|
|
140
|
+
sessionStorage.removeItem(key);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return serializer.parse(stored.item);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
write,
|
|
149
|
+
append(actorId: string, page: AdminPage<TItem>): void {
|
|
150
|
+
const cached = readSnapshot(actorId);
|
|
151
|
+
if (!cached) return;
|
|
152
|
+
const items = new Map(cached.items.map((item) => [getKey(item), item]));
|
|
153
|
+
page.items.forEach((item) => items.set(getKey(item), item));
|
|
154
|
+
write(actorId, {
|
|
155
|
+
items: [...items.values()],
|
|
156
|
+
nextCursor: page.nextCursor,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
upsert(actorId: string, item: TItem): void {
|
|
160
|
+
const cached = readSnapshot(actorId);
|
|
161
|
+
if (!cached) return;
|
|
162
|
+
const items = new Map(
|
|
163
|
+
cached.items.map((candidate) => [getKey(candidate), candidate]),
|
|
164
|
+
);
|
|
165
|
+
items.set(getKey(item), item);
|
|
166
|
+
write(actorId, {
|
|
167
|
+
items: [...items.values()],
|
|
168
|
+
nextCursor: cached.nextCursor,
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
remove(actorId: string, id: string): void {
|
|
172
|
+
const cached = readSnapshot(actorId);
|
|
173
|
+
if (cached) {
|
|
174
|
+
write(actorId, {
|
|
175
|
+
items: cached.items.filter((item) => getKey(item) !== id),
|
|
176
|
+
nextCursor: cached.nextCursor,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
sessionStorage.removeItem(editorCacheKey(id));
|
|
181
|
+
} catch {
|
|
182
|
+
// Cleanup is best effort.
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
writeEditor(actorId: string, item: TItem): boolean {
|
|
186
|
+
try {
|
|
187
|
+
const payload: StoredEditor<TStored> = {
|
|
188
|
+
actorId,
|
|
189
|
+
expiresAt: Date.now() + ttlMs,
|
|
190
|
+
item: serializer.serialize(item),
|
|
191
|
+
};
|
|
192
|
+
sessionStorage.setItem(
|
|
193
|
+
editorCacheKey(getKey(item)),
|
|
194
|
+
JSON.stringify(payload),
|
|
195
|
+
);
|
|
196
|
+
return true;
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
clear(actorId: string): void {
|
|
202
|
+
try {
|
|
203
|
+
sessionStorage.removeItem(collectionCacheKey(actorId));
|
|
204
|
+
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
|
205
|
+
const key = sessionStorage.key(index);
|
|
206
|
+
if (!key?.startsWith(`${editorKey}:`)) continue;
|
|
207
|
+
const raw = sessionStorage.getItem(key);
|
|
208
|
+
if (!raw) continue;
|
|
209
|
+
const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
|
|
210
|
+
if (stored.actorId === actorId) sessionStorage.removeItem(key);
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Cleanup is best effort.
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
} as const;
|
|
217
|
+
}
|
package/src/admin/index.ts
CHANGED
|
@@ -1,21 +1,47 @@
|
|
|
1
|
-
import { getApps, initializeApp } from 'firebase/app';
|
|
1
|
+
import { getApps, initializeApp, type FirebaseApp } from 'firebase/app';
|
|
2
2
|
import {
|
|
3
|
+
browserLocalPersistence,
|
|
4
|
+
browserPopupRedirectResolver,
|
|
3
5
|
GoogleAuthProvider,
|
|
4
6
|
getAuth,
|
|
7
|
+
initializeAuth,
|
|
5
8
|
onAuthStateChanged,
|
|
6
9
|
signInWithPopup,
|
|
7
10
|
signOut,
|
|
11
|
+
type Auth,
|
|
8
12
|
type User,
|
|
9
13
|
} from 'firebase/auth';
|
|
10
14
|
import type { AuthActor } from '../contracts';
|
|
11
15
|
import type { FirebaseClientConfig } from './contracts';
|
|
12
16
|
|
|
13
17
|
export type FirebaseGoogleAuthClient = {
|
|
14
|
-
observe(
|
|
18
|
+
observe(
|
|
19
|
+
listener: (actor: AuthActor | null) => void,
|
|
20
|
+
onError?: (reason: unknown) => void,
|
|
21
|
+
): () => void;
|
|
15
22
|
signIn(): Promise<AuthActor>;
|
|
16
23
|
signOut(): Promise<void>;
|
|
17
24
|
};
|
|
18
25
|
|
|
26
|
+
function initializeBrowserAuth(app: FirebaseApp): Auth {
|
|
27
|
+
try {
|
|
28
|
+
return initializeAuth(app, {
|
|
29
|
+
persistence: browserLocalPersistence,
|
|
30
|
+
popupRedirectResolver: browserPopupRedirectResolver,
|
|
31
|
+
});
|
|
32
|
+
} catch (reason) {
|
|
33
|
+
if (
|
|
34
|
+
typeof reason === 'object' &&
|
|
35
|
+
reason !== null &&
|
|
36
|
+
'code' in reason &&
|
|
37
|
+
reason.code === 'auth/already-initialized'
|
|
38
|
+
) {
|
|
39
|
+
return getAuth(app);
|
|
40
|
+
}
|
|
41
|
+
throw reason;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
function toAuthActor(user: User): AuthActor {
|
|
20
46
|
return {
|
|
21
47
|
id: user.uid,
|
|
@@ -32,15 +58,19 @@ export function createFirebaseGoogleAuthClient(
|
|
|
32
58
|
const app =
|
|
33
59
|
getApps().find((candidate) => candidate.name === config.projectId) ??
|
|
34
60
|
initializeApp(config, config.projectId);
|
|
35
|
-
const auth =
|
|
61
|
+
const auth = initializeBrowserAuth(app);
|
|
36
62
|
const provider = new GoogleAuthProvider();
|
|
37
63
|
provider.setCustomParameters({ prompt: 'select_account' });
|
|
38
64
|
|
|
39
65
|
return {
|
|
40
|
-
observe(listener) {
|
|
41
|
-
return onAuthStateChanged(
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
observe(listener, onError) {
|
|
67
|
+
return onAuthStateChanged(
|
|
68
|
+
auth,
|
|
69
|
+
(user) => {
|
|
70
|
+
listener(user ? toAuthActor(user) : null);
|
|
71
|
+
},
|
|
72
|
+
onError,
|
|
73
|
+
);
|
|
44
74
|
},
|
|
45
75
|
async signIn() {
|
|
46
76
|
const result = await signInWithPopup(auth, provider);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export type FormSubmissionMode = 'create' | 'edit';
|
|
2
|
+
|
|
3
|
+
export type FormSubmissionState = {
|
|
4
|
+
dirty: boolean;
|
|
5
|
+
pending: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type FormSubmissionResult<TValue> = {
|
|
9
|
+
status: 'saved' | 'unchanged';
|
|
10
|
+
value: TValue;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type FormSubmissionControllerConfig<TValue> = {
|
|
14
|
+
form: HTMLFormElement;
|
|
15
|
+
mode: FormSubmissionMode;
|
|
16
|
+
readValue(): TValue;
|
|
17
|
+
save(value: TValue): Promise<void>;
|
|
18
|
+
onSuccess(result: FormSubmissionResult<TValue>): void | Promise<void>;
|
|
19
|
+
onError?(reason: unknown): void;
|
|
20
|
+
onStateChange?(state: FormSubmissionState): void;
|
|
21
|
+
changeEvents?: readonly string[];
|
|
22
|
+
equals?(left: TValue, right: TValue): boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type FormSubmissionController = {
|
|
26
|
+
destroy(): void;
|
|
27
|
+
refresh(): void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const defaultChangeEvents = ['input', 'change'] as const;
|
|
31
|
+
|
|
32
|
+
export function createFormSubmissionController<TValue>({
|
|
33
|
+
form,
|
|
34
|
+
mode,
|
|
35
|
+
readValue,
|
|
36
|
+
save,
|
|
37
|
+
onSuccess,
|
|
38
|
+
onError,
|
|
39
|
+
onStateChange,
|
|
40
|
+
changeEvents = defaultChangeEvents,
|
|
41
|
+
equals = areStructurallyEqual,
|
|
42
|
+
}: FormSubmissionControllerConfig<TValue>): FormSubmissionController {
|
|
43
|
+
let baseline!: TValue;
|
|
44
|
+
if (mode === 'edit') baseline = readValue();
|
|
45
|
+
let dirty = mode === 'create';
|
|
46
|
+
let pending = false;
|
|
47
|
+
let destroyed = false;
|
|
48
|
+
|
|
49
|
+
const publishState = (): void => {
|
|
50
|
+
onStateChange?.({ dirty, pending });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const refresh = (): void => {
|
|
54
|
+
if (destroyed || pending) return;
|
|
55
|
+
|
|
56
|
+
let nextDirty = true;
|
|
57
|
+
if (mode === 'edit') {
|
|
58
|
+
try {
|
|
59
|
+
nextDirty = !equals(baseline, readValue());
|
|
60
|
+
} catch {
|
|
61
|
+
// An incomplete value is still a change; native validation owns errors.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (nextDirty === dirty) return;
|
|
66
|
+
dirty = nextDirty;
|
|
67
|
+
publishState();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleChange = (): void => refresh();
|
|
71
|
+
|
|
72
|
+
const handleSubmit = async (event: SubmitEvent): Promise<void> => {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
if (pending || !form.reportValidity()) return;
|
|
75
|
+
|
|
76
|
+
let value: TValue;
|
|
77
|
+
try {
|
|
78
|
+
value = readValue();
|
|
79
|
+
} catch (reason) {
|
|
80
|
+
onError?.(reason);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (mode === 'edit' && equals(baseline, value)) {
|
|
85
|
+
await onSuccess({ status: 'unchanged', value });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
pending = true;
|
|
90
|
+
publishState();
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await save(value);
|
|
94
|
+
baseline = value;
|
|
95
|
+
dirty = false;
|
|
96
|
+
pending = false;
|
|
97
|
+
publishState();
|
|
98
|
+
await onSuccess({ status: 'saved', value });
|
|
99
|
+
} catch (reason) {
|
|
100
|
+
pending = false;
|
|
101
|
+
refresh();
|
|
102
|
+
publishState();
|
|
103
|
+
onError?.(reason);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const eventName of new Set(changeEvents)) {
|
|
108
|
+
form.addEventListener(eventName, handleChange);
|
|
109
|
+
}
|
|
110
|
+
form.addEventListener('submit', handleSubmit);
|
|
111
|
+
publishState();
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
destroy(): void {
|
|
115
|
+
if (destroyed) return;
|
|
116
|
+
destroyed = true;
|
|
117
|
+
for (const eventName of new Set(changeEvents)) {
|
|
118
|
+
form.removeEventListener(eventName, handleChange);
|
|
119
|
+
}
|
|
120
|
+
form.removeEventListener('submit', handleSubmit);
|
|
121
|
+
},
|
|
122
|
+
refresh,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function areStructurallyEqual<TValue>(left: TValue, right: TValue): boolean {
|
|
127
|
+
return compareValues(left, right, new WeakMap<object, object>());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function compareValues(
|
|
131
|
+
left: unknown,
|
|
132
|
+
right: unknown,
|
|
133
|
+
visited: WeakMap<object, object>,
|
|
134
|
+
): boolean {
|
|
135
|
+
if (Object.is(left, right)) return true;
|
|
136
|
+
if (left instanceof Date || right instanceof Date) {
|
|
137
|
+
return (
|
|
138
|
+
left instanceof Date &&
|
|
139
|
+
right instanceof Date &&
|
|
140
|
+
left.getTime() === right.getTime()
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!left || !right || typeof left !== 'object' || typeof right !== 'object') {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const knownRight = visited.get(left);
|
|
148
|
+
if (knownRight) return knownRight === right;
|
|
149
|
+
visited.set(left, right);
|
|
150
|
+
|
|
151
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
152
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
153
|
+
return (
|
|
154
|
+
left.length === right.length &&
|
|
155
|
+
left.every((value, index) => compareValues(value, right[index], visited))
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const leftPrototype = Object.getPrototypeOf(left);
|
|
160
|
+
const rightPrototype = Object.getPrototypeOf(right);
|
|
161
|
+
if (leftPrototype !== rightPrototype) return false;
|
|
162
|
+
|
|
163
|
+
const leftKeys = Object.keys(left).sort();
|
|
164
|
+
const rightKeys = Object.keys(right).sort();
|
|
165
|
+
if (
|
|
166
|
+
leftKeys.length !== rightKeys.length ||
|
|
167
|
+
leftKeys.some((key, index) => key !== rightKeys[index])
|
|
168
|
+
) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const leftRecord = left as Record<string, unknown>;
|
|
173
|
+
const rightRecord = right as Record<string, unknown>;
|
|
174
|
+
return leftKeys.every((key) =>
|
|
175
|
+
compareValues(leftRecord[key], rightRecord[key], visited),
|
|
176
|
+
);
|
|
177
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
background: color-mix(
|
|
131
131
|
in srgb,
|
|
132
132
|
var(--card-action-hover-seed) 12%,
|
|
133
|
-
var(--nox-
|
|
133
|
+
var(--nox-theme-surface-base)
|
|
134
134
|
);
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
background: color-mix(
|
|
146
146
|
in srgb,
|
|
147
147
|
var(--card-action-hover-seed) 12%,
|
|
148
|
-
var(--nox-
|
|
148
|
+
var(--nox-theme-surface-base)
|
|
149
149
|
);
|
|
150
150
|
}
|
|
151
151
|
}
|
|
@@ -154,7 +154,7 @@
|
|
|
154
154
|
background: color-mix(
|
|
155
155
|
in srgb,
|
|
156
156
|
var(--card-action-hover-seed) 18%,
|
|
157
|
-
var(--nox-
|
|
157
|
+
var(--nox-theme-surface-base)
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
|
|
@@ -11,9 +11,20 @@
|
|
|
11
11
|
block-size: var(--nox-section-compact-block-size);
|
|
12
12
|
min-block-size: 0;
|
|
13
13
|
box-shadow: var(--nox-shadow-surface);
|
|
14
|
+
opacity: 0.8;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.scroll-to-top.nox-button:is(:focus-visible, :active) {
|
|
18
|
+
opacity: 1;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
html:has(dialog[open]) .scroll-to-top,
|
|
17
22
|
html:has(:fullscreen) .scroll-to-top {
|
|
18
23
|
display: none;
|
|
19
24
|
}
|
|
25
|
+
|
|
26
|
+
@media (hover: hover) and (pointer: fine) {
|
|
27
|
+
.scroll-to-top.nox-button:hover {
|
|
28
|
+
opacity: 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
import DayIcon from '../../icons/DayIcon.astro';
|
|
4
|
+
import NightIcon from '../../icons/NightIcon.astro';
|
|
5
|
+
|
|
6
|
+
type Props = HTMLAttributes<'button'> & {
|
|
7
|
+
appearance?: 'circle' | 'utility';
|
|
8
|
+
darkLabel: string;
|
|
9
|
+
lightLabel: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
appearance = 'circle',
|
|
14
|
+
class: className,
|
|
15
|
+
darkLabel,
|
|
16
|
+
lightLabel,
|
|
17
|
+
type = 'button',
|
|
18
|
+
...attrs
|
|
19
|
+
}: Props = Astro.props;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<button
|
|
23
|
+
{...attrs}
|
|
24
|
+
class:list={['theme-toggle', className]}
|
|
25
|
+
type={type}
|
|
26
|
+
aria-label={darkLabel}
|
|
27
|
+
title={darkLabel}
|
|
28
|
+
data-theme-toggle
|
|
29
|
+
data-theme-toggle-appearance={appearance}
|
|
30
|
+
data-theme-toggle-dark-label={darkLabel}
|
|
31
|
+
data-theme-toggle-light-label={lightLabel}
|
|
32
|
+
data-navigation-control={appearance === 'circle' ? '' : undefined}
|
|
33
|
+
data-navigation-control-appearance={
|
|
34
|
+
appearance === 'circle' ? 'bar' : undefined
|
|
35
|
+
}
|
|
36
|
+
>
|
|
37
|
+
<span
|
|
38
|
+
class="theme-toggle__icon"
|
|
39
|
+
data-theme-toggle-target="dark"
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
>
|
|
42
|
+
<NightIcon />
|
|
43
|
+
</span>
|
|
44
|
+
<span
|
|
45
|
+
class="theme-toggle__icon"
|
|
46
|
+
data-theme-toggle-target="light"
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
>
|
|
49
|
+
<DayIcon />
|
|
50
|
+
</span>
|
|
51
|
+
</button>
|
|
52
|
+
|
|
53
|
+
<script>
|
|
54
|
+
import { initThemeToggle } from './themeToggleController';
|
|
55
|
+
|
|
56
|
+
for (const toggle of document.querySelectorAll('[data-theme-toggle]')) {
|
|
57
|
+
if (toggle instanceof HTMLElement) initThemeToggle(toggle);
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
.theme-toggle {
|
|
2
|
+
display: grid;
|
|
3
|
+
flex: 0 0 auto;
|
|
4
|
+
place-items: center;
|
|
5
|
+
padding: 0;
|
|
6
|
+
border: var(--nox-border-width) solid var(--nox-border);
|
|
7
|
+
background: var(--nox-surface);
|
|
8
|
+
color: var(--nox-text-muted);
|
|
9
|
+
cursor: pointer;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.theme-toggle[data-theme-toggle-appearance='circle'] {
|
|
13
|
+
min-inline-size: 4.75rem;
|
|
14
|
+
block-size: 100%;
|
|
15
|
+
border: 0;
|
|
16
|
+
border-radius: 0;
|
|
17
|
+
background: transparent;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.theme-toggle[data-theme-toggle-appearance='utility'] {
|
|
21
|
+
inline-size: 100%;
|
|
22
|
+
min-block-size: var(--nox-navigation-block-size);
|
|
23
|
+
border-radius: var(--nox-radius-md);
|
|
24
|
+
background: var(--nox-surface-muted);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.theme-toggle__icon {
|
|
28
|
+
display: grid;
|
|
29
|
+
inline-size: 2rem;
|
|
30
|
+
block-size: 2rem;
|
|
31
|
+
grid-area: 1 / 1;
|
|
32
|
+
place-items: center;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.theme-toggle[data-theme-toggle-appearance='utility'] .theme-toggle__icon {
|
|
36
|
+
inline-size: 2.125rem;
|
|
37
|
+
block-size: 2.125rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.theme-toggle[data-theme-toggle-appearance='circle'] .theme-toggle__icon {
|
|
41
|
+
border: var(--nox-border-width) solid var(--nox-border);
|
|
42
|
+
border-radius: 50%;
|
|
43
|
+
background: var(--nox-surface);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.theme-toggle__icon > svg {
|
|
47
|
+
display: block;
|
|
48
|
+
inline-size: 100%;
|
|
49
|
+
block-size: 100%;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.theme-toggle [data-theme-toggle-target='light'] {
|
|
53
|
+
display: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
:root[data-theme='dark'] .theme-toggle [data-theme-toggle-target='dark'] {
|
|
57
|
+
display: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
:root[data-theme='dark'] .theme-toggle [data-theme-toggle-target='light'] {
|
|
61
|
+
display: grid;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.theme-toggle:focus-visible {
|
|
65
|
+
outline: var(--nox-focus-ring-width) solid var(--nox-focus-ring);
|
|
66
|
+
outline-offset: var(--nox-focus-ring-offset);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@media (hover: hover) {
|
|
70
|
+
.theme-toggle:hover {
|
|
71
|
+
background: var(--nox-surface-strong);
|
|
72
|
+
color: var(--nox-link-hover);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
77
|
+
.theme-toggle {
|
|
78
|
+
transition:
|
|
79
|
+
background-color var(--nox-motion-duration-sm)
|
|
80
|
+
var(--nox-motion-easing-standard),
|
|
81
|
+
color var(--nox-motion-duration-sm) var(--nox-motion-easing-standard);
|
|
82
|
+
}
|
|
83
|
+
}
|