@mohasinac/appkit 4.35.2 → 4.35.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/dist/_internal/client/features/layout/DashboardLayoutClient.js +18 -3
- package/dist/_internal/client/features/layout/filterNavItems.d.ts +22 -1
- package/dist/_internal/client/features/layout/filterNavItems.js +26 -1
- package/dist/_internal/server/features/media/contextGuards.d.ts +1 -1
- package/dist/_internal/server/features/media/contextGuards.js +34 -1
- package/dist/features/products/components/CardStoreLine.js +22 -3
- package/dist/features/products/hooks/useProducts.js +40 -3
- package/dist/features/seller/components/SellerProductShell.js +7 -0
- package/dist/features/tester/seed-data/authored/seo__page-metadata.js +6 -6
- package/dist/react/contexts/SessionContext.d.ts +13 -0
- package/dist/react/contexts/SessionContext.js +15 -0
- package/dist/ui/components/ImageLightbox.js +24 -1
- package/package.json +1 -1
|
@@ -122,13 +122,28 @@ function useResponsiveDrawer(storageKey) {
|
|
|
122
122
|
}
|
|
123
123
|
/** Filter nav groups by navConfig (enabled toggle) + requiredPermission. */
|
|
124
124
|
function filterGroups(groups, navConfig, permissions) {
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
/*
|
|
126
|
+
* `permissions === null` MEANS ADMIN, and that must hold whether or not a
|
|
127
|
+
* navConfig exists.
|
|
128
|
+
*
|
|
129
|
+
* The early return below encoded the convention correctly and then applied it
|
|
130
|
+
* only when `!navConfig`. The moment site settings carried ANY nav config,
|
|
131
|
+
* an admin fell through to `filterNavItems(..., undefined)` — and
|
|
132
|
+
* `undefined?.includes(x)` is falsy, so every item carrying a
|
|
133
|
+
* `requiredPermission` was dropped. The Admin Panel sidebar rendered empty
|
|
134
|
+
* while every admin page stayed reachable by URL.
|
|
135
|
+
*
|
|
136
|
+
* Passing `isAdmin` keeps the admin's navConfig toggles working (disabling an
|
|
137
|
+
* entry is a configuration decision, not a permission) while granting the
|
|
138
|
+
* permissions an admin implicitly holds.
|
|
139
|
+
*/
|
|
140
|
+
const isAdmin = permissions === null;
|
|
141
|
+
if (isAdmin && !navConfig)
|
|
127
142
|
return groups;
|
|
128
143
|
return groups
|
|
129
144
|
.map((group) => ({
|
|
130
145
|
...group,
|
|
131
|
-
items: filterNavItems(group.items, navConfig, permissions ?? undefined),
|
|
146
|
+
items: filterNavItems(group.items, navConfig, permissions ?? undefined, isAdmin),
|
|
132
147
|
}))
|
|
133
148
|
.filter((group) => group.items.length > 0);
|
|
134
149
|
}
|
|
@@ -8,8 +8,29 @@ type FilterableItem = {
|
|
|
8
8
|
* 2. requiredPermission — item hidden when user lacks the permission
|
|
9
9
|
*
|
|
10
10
|
* Items without an `id` always pass through (legacy / unmanaged items).
|
|
11
|
+
*
|
|
12
|
+
* 🛑 A FULL ADMIN HOLDS EVERY PERMISSION IMPLICITLY — `permissions[]` REFINES
|
|
13
|
+
* EMPLOYEES, IT DOES NOT DEFINE ADMINS.
|
|
14
|
+
*
|
|
15
|
+
* That is how the rest of the system already works: every admin route guard is
|
|
16
|
+
* `roles: ROLES_ADMIN_ONLY`, and employees are the ones who carry explicit
|
|
17
|
+
* grants (the seeded employees hold 5 and 24 permissions respectively, while
|
|
18
|
+
* `user-admin-letitrip` holds NONE — matching its seed, so this is by design
|
|
19
|
+
* rather than drift). There is no role→permission derivation anywhere in the
|
|
20
|
+
* codebase for the nav to lean on.
|
|
21
|
+
*
|
|
22
|
+
* Without `isAdmin` here, an admin matched no `requiredPermission` and EVERY
|
|
23
|
+
* admin nav item was filtered out: the Admin Panel sidebar rendered completely
|
|
24
|
+
* empty, and searching it returned "No matches". Every admin page stayed
|
|
25
|
+
* reachable by typing its URL, which is exactly what made it look cosmetic.
|
|
26
|
+
*
|
|
27
|
+
* This surfaced only recently because the check had never run: `filterNavItems`
|
|
28
|
+
* opens with `if (!item.id) return true;` and no item carried an `id` until
|
|
29
|
+
* ids were derived (see navigation.tsx). Turning a dormant check on without the
|
|
30
|
+
* data to satisfy it is the same shape as seeding a ratchet from the wrong
|
|
31
|
+
* measurement — Root Cause #84.
|
|
11
32
|
*/
|
|
12
33
|
export declare function filterNavItems<T extends FilterableItem>(items: T[], navConfig: Record<string, {
|
|
13
34
|
enabled: boolean;
|
|
14
|
-
}> | undefined, userPermissions: string[] | undefined): T[];
|
|
35
|
+
}> | undefined, userPermissions: string[] | undefined, isAdmin?: boolean): T[];
|
|
15
36
|
export {};
|
|
@@ -4,13 +4,38 @@
|
|
|
4
4
|
* 2. requiredPermission — item hidden when user lacks the permission
|
|
5
5
|
*
|
|
6
6
|
* Items without an `id` always pass through (legacy / unmanaged items).
|
|
7
|
+
*
|
|
8
|
+
* 🛑 A FULL ADMIN HOLDS EVERY PERMISSION IMPLICITLY — `permissions[]` REFINES
|
|
9
|
+
* EMPLOYEES, IT DOES NOT DEFINE ADMINS.
|
|
10
|
+
*
|
|
11
|
+
* That is how the rest of the system already works: every admin route guard is
|
|
12
|
+
* `roles: ROLES_ADMIN_ONLY`, and employees are the ones who carry explicit
|
|
13
|
+
* grants (the seeded employees hold 5 and 24 permissions respectively, while
|
|
14
|
+
* `user-admin-letitrip` holds NONE — matching its seed, so this is by design
|
|
15
|
+
* rather than drift). There is no role→permission derivation anywhere in the
|
|
16
|
+
* codebase for the nav to lean on.
|
|
17
|
+
*
|
|
18
|
+
* Without `isAdmin` here, an admin matched no `requiredPermission` and EVERY
|
|
19
|
+
* admin nav item was filtered out: the Admin Panel sidebar rendered completely
|
|
20
|
+
* empty, and searching it returned "No matches". Every admin page stayed
|
|
21
|
+
* reachable by typing its URL, which is exactly what made it look cosmetic.
|
|
22
|
+
*
|
|
23
|
+
* This surfaced only recently because the check had never run: `filterNavItems`
|
|
24
|
+
* opens with `if (!item.id) return true;` and no item carried an `id` until
|
|
25
|
+
* ids were derived (see navigation.tsx). Turning a dormant check on without the
|
|
26
|
+
* data to satisfy it is the same shape as seeding a ratchet from the wrong
|
|
27
|
+
* measurement — Root Cause #84.
|
|
7
28
|
*/
|
|
8
|
-
export function filterNavItems(items, navConfig, userPermissions) {
|
|
29
|
+
export function filterNavItems(items, navConfig, userPermissions, isAdmin = false) {
|
|
9
30
|
return items.filter((item) => {
|
|
10
31
|
if (!item.id)
|
|
11
32
|
return true;
|
|
33
|
+
// The admin toggle still applies to admins — disabling a nav entry is a
|
|
34
|
+
// deliberate configuration choice, not a permission the admin outranks.
|
|
12
35
|
if (!(navConfig?.[item.id]?.enabled ?? true))
|
|
13
36
|
return false;
|
|
37
|
+
if (isAdmin)
|
|
38
|
+
return true;
|
|
14
39
|
if (item.requiredPermission && !userPermissions?.includes(item.requiredPermission))
|
|
15
40
|
return false;
|
|
16
41
|
return true;
|
|
@@ -49,5 +49,5 @@ interface GuardInput {
|
|
|
49
49
|
* derive the SEO filename. Mirrors the inline logic that used to live
|
|
50
50
|
* in /api/media/upload.
|
|
51
51
|
*/
|
|
52
|
-
export declare function applyMediaContextGuards(
|
|
52
|
+
export declare function applyMediaContextGuards(input: GuardInput): GuardResult;
|
|
53
53
|
export {};
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { classifyMime, MIME_TO_EXT, PRODUCT_IMAGE_INDEX_MAX, PRODUCT_MAX_VIDEOS, } from "../../../shared/media/limits";
|
|
13
13
|
import { generateMediaFilename, validateMediaFilename, } from "../../../../utils/id-generators";
|
|
14
|
+
import { normalizeError } from "../../../../errors/normalize";
|
|
14
15
|
/**
|
|
15
16
|
* W1-51 — validate the generated filename before returning ok:true so a
|
|
16
17
|
* malformed name can never enter signed-URL storage. Generators producing
|
|
@@ -89,7 +90,39 @@ function indexGuard(receivedIndex, max, errorMessage) {
|
|
|
89
90
|
* derive the SEO filename. Mirrors the inline logic that used to live
|
|
90
91
|
* in /api/media/upload.
|
|
91
92
|
*/
|
|
92
|
-
|
|
93
|
+
/*
|
|
94
|
+
* 🛑 A MISSING CONTEXT FIELD IS A 400, NEVER A 500.
|
|
95
|
+
*
|
|
96
|
+
* The filename generators slugify their inputs, and `slugify(undefined)` throws
|
|
97
|
+
* `Cannot read properties of undefined (reading 'toLowerCase')` from deep inside
|
|
98
|
+
* `id-generators`. That escaped `applyMediaContextGuards` as an unhandled throw,
|
|
99
|
+
* so `/api/media/sign` answered 500 INTERNAL — twice in production — for what is
|
|
100
|
+
* simply a caller that forgot a field. The guards below validate `index` but
|
|
101
|
+
* have never validated the string fields, and enumerating every generator's
|
|
102
|
+
* requirements here would drift from the generators themselves (Root Cause #61).
|
|
103
|
+
*
|
|
104
|
+
* So the boundary catches instead: whatever the generator needs, failing to
|
|
105
|
+
* supply it now returns an actionable 400 naming the context type, and the real
|
|
106
|
+
* message is logged rather than shown. The caller-side fix still matters — this
|
|
107
|
+
* only guarantees the failure mode is honest.
|
|
108
|
+
*/
|
|
109
|
+
export function applyMediaContextGuards(input) {
|
|
110
|
+
try {
|
|
111
|
+
return applyMediaContextGuardsInner(input);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const normalized = normalizeError(err);
|
|
115
|
+
const message = normalized.message;
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
status: 400,
|
|
119
|
+
error: `The upload context for "${input.context?.type ?? "unknown"}" is missing a required field. ` +
|
|
120
|
+
`Check every field its filename pattern needs.`,
|
|
121
|
+
details: { context: input.context?.type, thrown: message },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function applyMediaContextGuardsInner({ detectedMime, context: ctx, }) {
|
|
93
126
|
const kind = classifyMime(detectedMime);
|
|
94
127
|
if (!kind) {
|
|
95
128
|
return {
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Text } from "../../../ui/components/Typography";
|
|
4
4
|
import { safeDisplayName } from "../../../security/pii-redact";
|
|
5
|
+
/**
|
|
6
|
+
* `store-beyblade-arena` → `Beyblade Arena`.
|
|
7
|
+
*
|
|
8
|
+
* The slug fallback was described as "readable", and next to a raw Firestore id
|
|
9
|
+
* it is — but a buyer reading "by store-beyblade-arena" under a product does not
|
|
10
|
+
* see a readable name, they see a leaked identifier. Pre-order and auction
|
|
11
|
+
* documents do not denormalize `storeName`, so this fallback is what a large
|
|
12
|
+
* share of cards actually render, on every listing page.
|
|
13
|
+
*
|
|
14
|
+
* Humanising here fixes all six card variants at once and needs no data change.
|
|
15
|
+
* Denormalising `storeName` onto those documents is the better long-term fix and
|
|
16
|
+
* would simply stop this path being reached.
|
|
17
|
+
*/
|
|
18
|
+
function humanizeStoreSlug(slug) {
|
|
19
|
+
return slug
|
|
20
|
+
.replace(/^store-/, "")
|
|
21
|
+
.split("-")
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
24
|
+
.join(" ");
|
|
25
|
+
}
|
|
5
26
|
export function CardStoreLine({ storeName, storeId, className }) {
|
|
6
27
|
if (!storeId && !storeName)
|
|
7
28
|
return null;
|
|
8
29
|
// `safeDisplayName` also guards the encrypted-value case: a PII-ciphertext
|
|
9
30
|
// string reaching a card must render as a label, never as `enc:v1:…`.
|
|
10
|
-
|
|
11
|
-
// resort rather than an opaque key.
|
|
12
|
-
const seller = safeDisplayName(storeName, storeId ?? "Seller");
|
|
31
|
+
const seller = safeDisplayName(storeName, storeId ? humanizeStoreSlug(storeId) : "Seller");
|
|
13
32
|
return (_jsxs(Text, { className: className ?? "mt-0.5 text-[11px]", color: "faint", children: ["by ", seller] }));
|
|
14
33
|
}
|
|
@@ -3,6 +3,8 @@ import { useEffect } from "react";
|
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { apiClient } from "../../../http";
|
|
5
5
|
import { useToast } from "../../../ui";
|
|
6
|
+
import { useOptionalSession } from "../../../react/contexts/SessionContext";
|
|
7
|
+
import { isAdminUser, isTesterUser } from "../../auth/role-predicates";
|
|
6
8
|
import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
7
9
|
export function useProducts(params = {}, opts) {
|
|
8
10
|
const { showToast } = useToast();
|
|
@@ -73,11 +75,46 @@ export function useProducts(params = {}, opts) {
|
|
|
73
75
|
sp.set(key, value);
|
|
74
76
|
}
|
|
75
77
|
const qs = sp.toString();
|
|
78
|
+
/*
|
|
79
|
+
* 🛑 A TESTER MUST REFETCH; EVERYONE ELSE MUST KEEP THE CACHED SSR PAINT.
|
|
80
|
+
*
|
|
81
|
+
* Sandbox rows are correctly hidden from the public. But the SSR listing views
|
|
82
|
+
* pass no `viewer` to `listPublicProducts`, so the server-rendered grid is the
|
|
83
|
+
* anonymous one for EVERY visitor — and with `initialData` + `staleTime:
|
|
84
|
+
* Infinity` the client never refetches, so a signed-in tester saw a catalogue
|
|
85
|
+
* with no sandbox fixtures in it, on every listing page. The items were
|
|
86
|
+
* reachable by direct URL, which is what made it look like a data problem
|
|
87
|
+
* rather than a visibility one.
|
|
88
|
+
*
|
|
89
|
+
* The fix is NOT to read the session in those SSR views. `getServerSessionUser`
|
|
90
|
+
* is a dynamic API, and calling it there would make every public listing page
|
|
91
|
+
* per-request — no caching, a billed invocation per visitor, and every
|
|
92
|
+
* `revalidate` silently overridden. That is Root Cause #82, and paying it for a
|
|
93
|
+
* testing affordance would be a poor trade.
|
|
94
|
+
*
|
|
95
|
+
* `/api/products` ALREADY resolves the session and passes `viewer`. So the only
|
|
96
|
+
* thing missing was a reason for the client to ask again: the query key now
|
|
97
|
+
* carries the viewer class, giving testers their own cache entry, and their
|
|
98
|
+
* `staleTime` is not frozen. Anonymous visitors keep the cached SSR data
|
|
99
|
+
* untouched and issue no extra request.
|
|
100
|
+
*
|
|
101
|
+
* `useOptionalSession` rather than `useAuth()` on purpose — `useSession`
|
|
102
|
+
* THROWS outside a provider, and making a public appkit hook require one would
|
|
103
|
+
* be a breaking contract change (Root Cause #20). Absent provider degrades to
|
|
104
|
+
* "not a tester", which is the fail-closed answer.
|
|
105
|
+
*/
|
|
106
|
+
const session = useOptionalSession();
|
|
107
|
+
const viewer = session?.user;
|
|
108
|
+
const canSeeTestData = isTesterUser(viewer) || isAdminUser(viewer);
|
|
76
109
|
const query = useQuery({
|
|
77
|
-
queryKey: ["products", qs],
|
|
110
|
+
queryKey: ["products", qs, canSeeTestData ? "with-test-data" : "public"],
|
|
78
111
|
queryFn: () => apiClient.get(`${PRODUCT_ENDPOINTS.LIST}${qs ? `?${qs}` : ""}`),
|
|
79
|
-
|
|
80
|
-
|
|
112
|
+
// The SSR paint is the anonymous view, so it is only a valid starting point
|
|
113
|
+
// for an anonymous viewer. Handing it to a tester would show them the very
|
|
114
|
+
// rows this refetch exists to reveal.
|
|
115
|
+
initialData: canSeeTestData ? undefined : opts?.initialData,
|
|
116
|
+
staleTime: opts?.staleTime ??
|
|
117
|
+
(!canSeeTestData && opts?.initialData != null ? Infinity : 0),
|
|
81
118
|
enabled: opts?.enabled,
|
|
82
119
|
});
|
|
83
120
|
const warning = query.data?.warning;
|
|
@@ -147,6 +147,13 @@ function StepMedia({ values, onChange, storeSlug = "store", }) {
|
|
|
147
147
|
}), onChangeField: (media) => onChange({ video: media ?? undefined }), onThumbnailChange: (thumbnailUrl) => onChange({ video: values.video ? { ...values.video, thumbnailUrl } : undefined }), onDurationChange: (duration) => onChange({ video: values.video ? { ...values.video, duration } : undefined }), onUpload: (file) => upload(file, "products", true, {
|
|
148
148
|
type: "product-video",
|
|
149
149
|
name: values.title ?? "product",
|
|
150
|
+
// `category` is REQUIRED — generateProductVideoFilename slugifies it,
|
|
151
|
+
// and slugify(undefined) threw `Cannot read properties of undefined
|
|
152
|
+
// (reading 'toLowerCase')` inside the generator, surfacing as a 500
|
|
153
|
+
// from /api/media/sign. Every product video upload from this panel
|
|
154
|
+
// failed that way; ProductForm's gallery path passed it and worked,
|
|
155
|
+
// which is why only the dedicated video tab was broken.
|
|
156
|
+
category: values.category || "uncategorized",
|
|
150
157
|
store: storeSlug,
|
|
151
158
|
}), kind: "auto", helperText: "The gallery above accepts a video file directly. Use this panel for a YouTube or external URL, or to trim and pick a poster frame." }), _jsx(FormField, { name: "externalVideoUrl", label: "3rd-party video URL (YouTube / Vimeo)", type: "text", value: values.externalVideoUrl ?? "", onChange: (v) => onChange({ externalVideoUrl: v }), placeholder: "https://www.youtube.com/watch?v=\u2026", hint: "External video links are queued for moderation and become visible to buyers after admin approval." }), _jsx(FormField, { name: "youtubeId", label: "YouTube Video ID (optional)", type: "text", value: values.youtubeId ?? "", onChange: (v) => onChange({ youtubeId: v }), placeholder: "e.g. dQw4w9WgXcQ", helpText: "Paste the 11-character ID from the YouTube URL" })] }));
|
|
152
159
|
}
|
|
@@ -95,14 +95,14 @@ export const authored = {
|
|
|
95
95
|
startPage: "/products",
|
|
96
96
|
steps: [
|
|
97
97
|
"Open the network panel and navigate to a product path whose trailing segment is a nonsense word no listing uses.",
|
|
98
|
-
"Read the status code of the document response.",
|
|
99
98
|
"Read what the page shows.",
|
|
100
|
-
"View the source and
|
|
101
|
-
"Repeat with a nonsense store path and a nonsense
|
|
99
|
+
"View the source and read every robots meta tag.",
|
|
100
|
+
"Repeat with a nonsense store path and a nonsense auction path.",
|
|
101
|
+
"Open a REAL product page and read its robots meta tags too.",
|
|
102
102
|
],
|
|
103
|
-
expectedBehaviour: "A missing page
|
|
104
|
-
expectedUiState: "
|
|
105
|
-
expectedData: {
|
|
103
|
+
expectedBehaviour: "A missing page renders the site's 404 view and marks itself NOINDEX. Judge the noindex, not the status code: Next streams the response, so the headers are already sent by the time the not-found is raised and the status can no longer be changed. It injects a noindex robots tag into the streamed HTML instead, and that is what actually keeps the URL out of the index. A 200 here is expected and is not the finding.",
|
|
104
|
+
expectedUiState: "All three missing paths render the 404 view and their source carries a robots tag whose content is noindex. The real product page carries no noindex — check it, because a blanket noindex would satisfy the first half while quietly de-indexing the whole catalogue.",
|
|
105
|
+
expectedData: { missingPageIsNoindex: true, realPageIsNoindex: false },
|
|
106
106
|
endResult: "Read-only.",
|
|
107
107
|
},
|
|
108
108
|
"checklist-seo-page-metadata-title-length-reasonable": {
|
|
@@ -95,6 +95,19 @@ export interface SessionProviderProps {
|
|
|
95
95
|
}
|
|
96
96
|
export declare function SessionProvider({ children, initialUser, endpoints: endpointOverrides, onSignOutInvalidate, signOutInvalidationKeys, }: SessionProviderProps): React.JSX.Element;
|
|
97
97
|
export declare function useSession(): SessionContextValue;
|
|
98
|
+
/**
|
|
99
|
+
* The session if a provider is mounted, `null` if not — never throws.
|
|
100
|
+
*
|
|
101
|
+
* `useSession` throwing is right for a component that genuinely cannot work
|
|
102
|
+
* signed-out. It is wrong for library code that merely wants to ADJUST for the
|
|
103
|
+
* viewer, because requiring a provider there turns an optional refinement into a
|
|
104
|
+
* breaking contract change for every consumer (Root Cause #20).
|
|
105
|
+
*
|
|
106
|
+
* Use this only where "no provider" has a sensible, fail-closed answer — e.g.
|
|
107
|
+
* `useProducts`, which treats an absent session as "not a tester" and keeps the
|
|
108
|
+
* public cached view.
|
|
109
|
+
*/
|
|
110
|
+
export declare function useOptionalSession(): SessionContextValue | null;
|
|
98
111
|
export declare function useAuth(): {
|
|
99
112
|
user: SessionUser | null;
|
|
100
113
|
loading: boolean;
|
|
@@ -394,6 +394,21 @@ export function useSession() {
|
|
|
394
394
|
}
|
|
395
395
|
return context;
|
|
396
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* The session if a provider is mounted, `null` if not — never throws.
|
|
399
|
+
*
|
|
400
|
+
* `useSession` throwing is right for a component that genuinely cannot work
|
|
401
|
+
* signed-out. It is wrong for library code that merely wants to ADJUST for the
|
|
402
|
+
* viewer, because requiring a provider there turns an optional refinement into a
|
|
403
|
+
* breaking contract change for every consumer (Root Cause #20).
|
|
404
|
+
*
|
|
405
|
+
* Use this only where "no provider" has a sensible, fail-closed answer — e.g.
|
|
406
|
+
* `useProducts`, which treats an absent session as "not a tester" and keeps the
|
|
407
|
+
* public cached view.
|
|
408
|
+
*/
|
|
409
|
+
export function useOptionalSession() {
|
|
410
|
+
return useContext(SessionContext) ?? null;
|
|
411
|
+
}
|
|
397
412
|
export function useAuth() {
|
|
398
413
|
const { user, loading, refreshUser } = useSession();
|
|
399
414
|
return { user, loading, refreshUser };
|
|
@@ -83,7 +83,26 @@ export function ImageLightbox({ images, activeIndex, onClose, onNavigate, showTh
|
|
|
83
83
|
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
|
|
84
84
|
}, [adjustZoom]);
|
|
85
85
|
const handleKeyDown = useCallback((e) => {
|
|
86
|
+
/*
|
|
87
|
+
* Escape unwinds ONE level: fullscreen first, the lightbox second.
|
|
88
|
+
*
|
|
89
|
+
* Unconditionally calling `onClose()` meant Escape in fullscreen tore the
|
|
90
|
+
* whole lightbox down while the browser was natively exiting fullscreen at
|
|
91
|
+
* the same moment — the user asked to leave fullscreen and lost the image
|
|
92
|
+
* they were looking at. The Exit-fullscreen BUTTON behaved correctly, which
|
|
93
|
+
* is what made this read as a keyboard quirk rather than a bug.
|
|
94
|
+
*
|
|
95
|
+
* Exiting explicitly rather than relying on the browser's own handling: the
|
|
96
|
+
* `fullscreenchange` listener above is what clears `isFullscreen`, so going
|
|
97
|
+
* through the same path keeps the button label and this state in agreement
|
|
98
|
+
* whichever way the user leaves.
|
|
99
|
+
*/
|
|
86
100
|
if (e.key === "Escape") {
|
|
101
|
+
if (isFullscreen) {
|
|
102
|
+
const doc = document;
|
|
103
|
+
(document.exitFullscreen?.() ?? doc.webkitExitFullscreen?.())?.catch?.(() => { });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
87
106
|
onClose();
|
|
88
107
|
return;
|
|
89
108
|
}
|
|
@@ -112,7 +131,11 @@ export function ImageLightbox({ images, activeIndex, onClose, onNavigate, showTh
|
|
|
112
131
|
setRotation(0);
|
|
113
132
|
return;
|
|
114
133
|
}
|
|
115
|
-
},
|
|
134
|
+
},
|
|
135
|
+
// `isFullscreen` is a real dependency now — Escape branches on it, and a
|
|
136
|
+
// stale closure would send the user back to the pre-fix behaviour exactly
|
|
137
|
+
// when they are in fullscreen.
|
|
138
|
+
[onClose, navigate, adjustZoom, isFullscreen]);
|
|
116
139
|
// Focus overlay on open for keyboard to work
|
|
117
140
|
useEffect(() => {
|
|
118
141
|
if (isOpen)
|