From 02eb48af99e19485638fd4940c62754914a4c4f4 Mon Sep 17 00:00:00 2001 From: Aravind Date: Mon, 24 Aug 2026 13:14:22 +0530 Subject: [PATCH] fix mobile screen issues --- .env.example | 41 +++ .gitignore | 3 + src/api/base44Client.js | 283 ++++++++++++---- src/api/httpClient.js | 308 ++++++++++++++++++ src/components/ProtectedRoute.jsx | 22 +- src/components/admin/PageShell.jsx | 17 +- .../ai-assistant/AssistantPanel.jsx | 169 +++++++--- .../ai-assistant/AssistantPanelContext.jsx | 32 +- src/components/ai-assistant/viewport.js | 102 ++++++ .../charts/DepartmentPerformance.jsx | 2 +- src/components/ds/DataTable.jsx | 2 +- src/components/ds/MetricStrip.jsx | 9 +- src/components/ds/Modal.jsx | 6 +- src/components/krow/ActivityLogTable.jsx | 2 +- src/components/krow/CandidateCard.jsx | 150 +++++---- src/components/krow/TalentDetailModal.jsx | 2 +- src/components/skills/SkillSections.jsx | 2 +- src/components/ui/dialog.jsx | 2 +- src/index.css | 37 +++ src/layouts/AdminLayout.jsx | 21 +- src/lib/AuthContext.jsx | 42 ++- src/lib/admin/session.js | 77 +++-- src/lib/positionModel.js | 5 +- src/pages/CreatePosition.jsx | 21 +- src/pages/admin/AdminRoute.jsx | 24 +- src/pages/admin/Analytics.jsx | 2 +- src/pages/admin/CandidatesAnalysis.jsx | 2 +- src/pages/admin/ControlCenter.jsx | 2 +- src/pages/admin/Login.jsx | 50 ++- src/pages/admin/Profile.jsx | 10 +- src/pages/admin/Settings.jsx | 8 +- src/pages/admin/Workspace.jsx | 6 +- src/pages/admin/WorkspaceAgents.jsx | 9 +- vite.config.js | 35 ++ 34 files changed, 1214 insertions(+), 291 deletions(-) create mode 100644 .env.example create mode 100644 src/api/httpClient.js create mode 100644 src/components/ai-assistant/viewport.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5575a35 --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# ============================================================================ +# Krow frontend — example environment +# +# Copy to .env and adjust. .env is gitignored. +# +# cp .env.example .env +# +# Vite only exposes variables prefixed with VITE_ to client code, and it reads +# these at build/dev-server start — changing one needs a restart, not a reload. +# ============================================================================ + +# Where the browser sends API requests. +# +# A SAME-ORIGIN PATH, not a host. The browser asks its own origin for +# /api/v1/..., and something on that origin forwards it to the Go API: +# in development the Vite proxy (see `server.proxy` in vite.config.js), in +# production the same web server that serves the built assets (see nginx.conf). +# +# browser → localhost:5173/api/v1 → Vite proxy → 127.0.0.1:8080/api/v1 +# +# THIS MUST STAY A PATH. Pointing it at http://127.0.0.1:8080/api/v1 makes every +# request cross-site, and the session cookie stops working in two separate ways: +# +# 1. The cookie is SameSite=Lax, and a Lax cookie is not sent on a cross-site +# subresource request. A browser treats localhost:5173 and 127.0.0.1:8080 +# as different sites, so the cookie would be set at login and then never +# sent again. +# 2. Because the transport sends `credentials: 'include'`, the browser +# requires `Access-Control-Allow-Credentials: true` on the preflight +# response. The API does not send it — deliberately, because the supported +# arrangement is same-origin — so Chrome discards the preflight and never +# dispatches the real request. The symptom is an OPTIONS that answers 204 +# followed by a POST that never reaches the server at all. +# +# Both failures are silent from the page's point of view, which is why this +# comment is longer than the value. +VITE_API_BASE_URL=/api/v1 + +# Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by +# client code. Change this if the Go API is not on its default address. +# VITE_API_PROXY_TARGET=http://127.0.0.1:8080 diff --git a/.gitignore b/.gitignore index bb8b6de..29844e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ #env .env .env.* +# ...but the template belongs in the repository: it is the one place the API's +# location is documented, and a checkout with no .env needs it. +!.env.example # Logs /logs diff --git a/src/api/base44Client.js b/src/api/base44Client.js index 89b3e05..401beaf 100644 --- a/src/api/base44Client.js +++ b/src/api/base44Client.js @@ -3,16 +3,24 @@ * * The reference app talks to a Base44 backend through this module. The demo * keeps the module path, the export name, and the full method contract, and - * swaps the transport for the local store in `store.js` and the local AI engine + * swaps the transport for the Go API in `httpClient.js` and the local AI engine * in `aiEngine.js`. Nothing downstream — hooks, pages, components — knows or * cares, which is exactly the point: the seam stays where it was. + * + * Phase 2D moved the transport from a localStorage-backed store to HTTP: + * + * React → base44Client.js → HTTP → Go API → PostgreSQL + * + * The entity surface is unchanged. `store.js` and `seed.js` are no longer the + * source of data — the seeded dataset now lives in PostgreSQL, loaded by the + * backend's `make seed`. `seed.js` is still imported for one thing: the shape + * of the demo user's default preferences, which the synchronous accessor below + * needs before the first response arrives. */ -import { createEntity, initStore, resetStore } from './store'; +import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient'; import { invokeLLM, uploadFile } from './aiEngine'; -import { DEMO_USER, seedData } from './seed'; - -initStore(seedData); +import { DEMO_USER } from './seed'; const ENTITY_NAMES = [ 'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile', @@ -33,9 +41,28 @@ const entities = Object.fromEntries( /* ── Auth ──────────────────────────────────────────────────────────────── */ +/** + * The last user the API returned. + * + * This is a **cache of `GET /me`**, not a store. The record itself lives in + * PostgreSQL; this exists for one reason, and it is not offline support. + * + * `auth.preferences()` is synchronous, and it has to stay synchronous: + * `AssistantPanelContext` decides whether Owliver starts open in a `useState` + * initialiser, during the first render, and `krowHooks.js:42` merges the same + * accessor under the async user so the first paint already has real values. One + * tick later is a visible flash of the wrong workspace — the panel opening on + * an account that turned it off, then closing. + * + * So the last known user is mirrored to localStorage and read back at module + * load, and `GET /me` refreshes it. On a return visit the synchronous read is + * already correct; on a first-ever visit it is the seeded defaults for exactly + * as long as the request takes, which is the same thing the old store did with + * an empty key. + */ const SESSION_KEY = 'krow_demo_user'; -function loadUser() { +function readCachedUser() { try { const raw = localStorage.getItem(SESSION_KEY); return raw ? { ...DEMO_USER, ...JSON.parse(raw) } : { ...DEMO_USER }; @@ -44,98 +71,207 @@ function loadUser() { } } -let currentUser = loadUser(); - /** - * Writes the session user, and says whether it actually landed. + * Mirrors the current user for the next page load's synchronous read. * - * The old version was `try { setItem } catch {}` — a swallowed - * `QuotaExceededError` or a private-browsing refusal, and the caller was handed - * a user object indistinguishable from a successful write. For preferences that - * is invisible; for `customSkills`, which is where every account-authored skill - * definition lives, it is the whole "I saved it and it was gone" report: the - * toast said added, the list showed it, the reload did not. - * - * The read-back matters as much as the catch. A write can be accepted and then - * evicted, and a serialisation can land truncated; comparing what came back - * with what went in is the only way to know the record is really there. + * Failures are ignored, which is a real change from the old `persistUser` and a + * safe one. That function checked its write and reported failure because + * localStorage was the *only* copy — a swallowed `QuotaExceededError` was how + * account-authored skills silently disappeared. Now the only copy is in + * PostgreSQL, and a refused mirror costs one render of default preferences, not + * data. */ -function persistUser() { - const payload = JSON.stringify(currentUser); +function cacheUser() { try { - localStorage.setItem(SESSION_KEY, payload); - } catch (error) { - return { persisted: false, error }; + localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser)); + } catch { + // Private browsing or quota — the server still has the record. } - try { - if (localStorage.getItem(SESSION_KEY) !== payload) { - return { persisted: false, error: new Error('The session record did not survive the write.') }; - } - } catch (error) { - return { persisted: false, error }; - } - return { persisted: true, error: null }; } +/** + * Drops the mirrored user. + * + * Signing out must not leave the next page load rendering the previous + * account's name and preferences out of localStorage while it waits for a + * `GET /me` that is going to 401. + */ +function forgetUser() { + try { + localStorage.removeItem(SESSION_KEY); + } catch { + // Ignore. + } + currentUser = { ...DEMO_USER }; +} + +let currentUser = readCachedUser(); + +/** + * Whether the last `GET /me` succeeded. + * + * A cache of the server's answer, not a decision. Nothing here grants access: + * the API refuses an unauthenticated request whatever this says, and a user who + * edits it in the console has changed a boolean in their own tab and nothing + * else. It exists because `isAuthenticated()` is synchronous. + */ +let authenticated = false; + +/** + * The first `GET /me`, shared. + * + * Started at module load so the synchronous accessor is corrected as early as + * possible, and shared so the eleven `me()` call sites that fire during the + * first render make one request between them rather than eleven. + * + * A 401 here is the ordinary state of a signed-out visitor, not a failure: the + * app opens on the login page and this request is how it finds that out. + */ +let hydration = request('GET', '/me') + .then((user) => { + currentUser = user; + authenticated = true; + cacheUser(); + return user; + }) + .catch(() => { + authenticated = false; + return null; + }); + const auth = { - /** The demo is always signed in as the seeded employer/admin user. */ + /** + * The signed-in user, from the session cookie. + * + * Throws when there is no session — a `KrowApiError` with `status: 401` — and + * that throw is the app's authentication check. `AuthContext` catches it and + * renders the login page. Nothing here decides who the user is; the server + * reads its own session table and answers. + * + * Joins the in-flight hydration if there is one, so the first render's + * callers share a request; refetches afterwards so a change made in another + * tab, or a session that has since expired, is picked up. + */ async me() { - return { ...currentUser }; + if (hydration) { + const user = await hydration; + hydration = null; + if (user) { + authenticated = true; + return { ...user }; + } + } + try { + const user = await request('GET', '/me'); + currentUser = user; + authenticated = true; + cacheUser(); + return { ...user }; + } catch (error) { + if (isUnauthenticated(error)) { + authenticated = false; + forgetUser(); + } + throw error; + } + }, + + /** + * Signs in and starts a session. + * + * The response body is the user. The session token is NOT in it — it arrives + * as an HttpOnly cookie the browser stores and this code cannot read, which + * is what stops a script on the page from stealing it. There is deliberately + * nothing here that writes a token anywhere. + * + * Every credential failure comes back as the same 401 with the same message, + * by design: telling the two apart would say whether an address is + * registered. The caller shows that message as-is. + */ + async login({ email, password, rememberMe = false }) { + const user = await request('POST', '/auth/login', { + body: { email, password, remember_me: Boolean(rememberMe) }, + }); + currentUser = user; + authenticated = true; + hydration = null; + cacheUser(); + return { ...user }; }, async updateMe(patch) { - currentUser = { ...currentUser, ...patch }; - persistUser(); - return { ...currentUser }; + const user = await request('PATCH', '/me', { body: patch }); + currentUser = user; + cacheUser(); + return { ...user }; }, /** * Preferences, read synchronously. * - * `me()` is async because the real client fetches, but the panel provider has - * to decide whether Owliver starts open during its first render — one tick - * later is a visible flash of the wrong workspace. The session user is already - * hydrated from localStorage at module load, so this is a plain read of the - * same record `me()` returns, not a second copy of the state. + * A plain read of the same record `me()` returns, defaulted with the shape + * from `seed.js` so a key the server has never stored still resolves. See the + * note on `currentUser` for why this must not become async. */ preferences() { return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) }; }, /** - * Merges into the stored preferences and persists with the rest of the user. + * Merges into the stored preferences and persists them server-side. * - * Returns the write's outcome alongside the record, rather than the record - * alone. Preferences are where account-authored skills live, so "did this - * survive the reload" is a question the caller has to be able to answer — - * see `persistUser`. + * `PATCH /me/preferences` shallow-merges and returns the whole merged object, + * which is where `customSkills` and `customAgents` — every account-authored + * definition — now live: `user_preferences.extra`, a real column in a real + * database rather than a browser key. + * + * The `{ user, persisted, error }` shape is kept because `saveFeedback.js` + * reads it. Over HTTP a write that did not land is a non-2xx and therefore a + * throw, so the success path is unconditionally `persisted: true` — the + * question the shape exists to answer is now answered by whether this + * function resolved at all. */ async updatePreferences(patch) { - currentUser = { ...currentUser, preferences: { ...auth.preferences(), ...patch } }; - const write = persistUser(); - return { user: { ...currentUser }, ...write }; - }, - - isAuthenticated() { - return true; + const preferences = await request('PATCH', '/me/preferences', { body: patch }); + currentUser = { ...currentUser, preferences }; + cacheUser(); + return { user: { ...currentUser }, persisted: true, error: null }; }, /** - * There is no identity provider to sign out of, so this clears the local - * session and returns to the requested page. + * Whether the last `GET /me` succeeded. + * + * Synchronous, and therefore only ever a cache of what the server last said. + * It is a hint for rendering, never a gate: every protected endpoint is + * refused by the API on its own authority regardless of this value. */ - logout(redirectTo = '/') { + isAuthenticated() { + return authenticated; + }, + + /** + * Signs out and returns to the requested page. + * + * The server revokes the session row and expires the cookie; this clears the + * cached copy of the user so a signed-out tab cannot render a stale name from + * localStorage. The redirect happens either way — a logout that could not + * reach the API must still leave the browser signed out locally, and the + * cookie it keeps will be refused by every request it is sent on. + */ + async logout(redirectTo = '/admin/login') { try { - localStorage.removeItem(SESSION_KEY); + await request('POST', '/auth/logout'); } catch { - // Ignore. + // Already signed out, or the API is unreachable. Neither is a reason to + // keep the user looking at a signed-in page. } - currentUser = { ...DEMO_USER }; - window.location.href = typeof redirectTo === 'string' ? redirectTo : '/'; + authenticated = false; + forgetUser(); + window.location.href = typeof redirectTo === 'string' ? redirectTo : '/admin/login'; }, redirectToLogin() { - window.location.href = '/'; + window.location.href = '/admin/login'; }, }; @@ -158,8 +294,27 @@ const analytics = { export const base44 = { entities, auth, integrations, analytics }; -/** Restores the shipped demo data, discarding local edits. */ +/** Where the entity data actually comes from, for diagnostics. */ +export { API_BASE_URL }; + +/** + * Clears local session state and reloads. + * + * The demo dataset is no longer the browser's to restore: it lives in + * PostgreSQL, and reseeding it is `make seed` in the `krow-backend` repository, + * which upserts the shipped fixture in one transaction. All this can still do + * is drop the cached user and reload, so it says so rather than reporting a + * reset it did not perform. + */ export function resetDemoData() { - resetStore(seedData); + try { + localStorage.removeItem(SESSION_KEY); + } catch { + // Ignore. + } + console.info( + '[krow-demo] Local session cache cleared. Entity data lives in PostgreSQL — ' + + 'restore the shipped dataset with `make seed` in krow-backend.' + ); window.location.reload(); } diff --git a/src/api/httpClient.js b/src/api/httpClient.js new file mode 100644 index 0000000..39ad90c --- /dev/null +++ b/src/api/httpClient.js @@ -0,0 +1,308 @@ +/** + * HTTP transport for the entity API. + * + * This is the module that replaces `store.js`. It exposes the same + * `createEntity(name)` factory with the same six methods and the same + * signatures, so `base44Client.js` swaps one import and nothing above it + * changes — not a hook, not a page, not a component. + * + * Everything here is a faithful translation of what `store.js` did locally into + * what `docs/api-contract.md` specifies over the wire. Where the two could + * differ, the local behaviour wins, because the callers were written against + * it: + * + * - `list`/`filter` return a bare array; `get`/`create`/`update` return a bare + * object; `delete` returns `{ id }`. The API's `{ data, meta }` envelope is + * unwrapped here and never seen above. + * - A failure throws an `Error` whose `message` is the server's message, + * because `store.js` threw and several callers depend on the throw + * (`useQuery`'s `isError`, and half a dozen `.catch(() => …)` fallbacks). + * - The default `sort` and `limit` on every method are the ones `store.js` + * declared, so a call site that passes neither still gets what it always + * got. + * + * The one thing that is genuinely new is the failure mode. A local store could + * not be unreachable; an API can, and "Failed to fetch" names neither the + * problem nor the fix. `request` turns that into a message that says which URL + * did not answer. + */ + +/** + * Where the API lives. + * + * A same-origin path, not a host. The browser asks its own origin for + * `/api/v1/...`; in development the Vite proxy forwards that to the Go API (see + * `vite.config.js`), and in production the same path is served by the same + * origin as the app (see `nginx.conf`). + * + * This is a requirement of the session cookie rather than a preference. The + * cookie is HttpOnly with SameSite=Lax, and a Lax cookie is not sent on a + * cross-site request — so a page on `localhost:5173` fetching + * `http://127.0.0.1:8080` would authenticate once at login and then be a + * stranger on every request after it. + * + * VITE_API_BASE_URL can still point somewhere else, and `credentials` below is + * set so that it works, but the cross-origin path needs CORS credentials + * configured on the server and is not the supported arrangement. + */ +const DEFAULT_BASE_URL = '/api/v1'; + +export const API_BASE_URL = String( + import.meta.env?.VITE_API_BASE_URL || DEFAULT_BASE_URL +).replace(/\/+$/, ''); + +/** + * Shout if the API has been pointed at another origin. + * + * This exists because the failure it catches is silent and misleading. Set + * `VITE_API_BASE_URL` to `http://127.0.0.1:8080/api/v1` and the browser sends a + * preflight, the server answers it `204`, and then the real request is never + * dispatched at all — because `credentials: 'include'` obliges the browser to + * require `Access-Control-Allow-Credentials: true` on that preflight, and the + * API does not send it. The server log shows an OPTIONS and nothing else; the + * page shows a request that never completes. Nothing names the cause. + * + * Even if CORS were opened up, the session cookie is `SameSite=Lax` and would + * not be sent on a cross-site request, so login would appear to succeed once + * and then every subsequent request would arrive as a stranger. + * + * Development only: `import.meta.env.DEV` is statically replaced at build time, + * so this whole block is dropped from the production bundle. + */ +if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) { + const sameOrigin = + typeof window !== 'undefined' && API_BASE_URL.startsWith(window.location.origin); + if (!sameOrigin) { + console.error( + `[krow] VITE_API_BASE_URL is "${API_BASE_URL}", which is a different origin ` + + `from ${typeof window !== 'undefined' ? window.location.origin : 'this page'}. ` + + 'The session cookie will not work: the login POST is blocked at the CORS ' + + 'preflight, and a SameSite=Lax cookie would not be sent cross-site anyway. ' + + 'Set VITE_API_BASE_URL=/api/v1 in .env and restart the dev server so requests ' + + 'go through the Vite proxy.' + ); + } +} + +/** + * Entity name → the contract's resource path (§1: kebab-case plural, mass nouns + * singular). + * + * Declared rather than derived. A rule that turns `AIInterview` into + * `ai-interviews` and `Staff` into `staff` and `UserActivity` into + * `user-activity` is three special cases wearing a trench coat, and a wrong + * guess here is a 404 at runtime instead of a mistake anyone can see. + */ +const RESOURCE_PATHS = { + JobPosting: 'job-postings', + JobApplication: 'job-applications', + AIInterview: 'ai-interviews', + Staff: 'staff', + WorkerProfile: 'worker-profiles', + Course: 'courses', + Badge: 'badges', + LearningPath: 'learning-paths', + Certification: 'certifications', + RoleCategory: 'role-categories', + UserActivity: 'user-activity', + Evidence: 'evidence', + User: 'users', + Assignment: 'assignments', + ShiftRecord: 'shift-records', +}; + +/* ── Request ────────────────────────────────────────────────────────────── */ + +/** + * Builds a query string with the contract's filter encoding (§6). + * + * An array value becomes a repeated parameter — `?status=applied&status=hired` + * — which the server reads as `col = ANY(...)`, matching `store.js`'s + * `want.includes(got)`. `undefined` is omitted entirely: `store.js` compared + * `got === undefined` against real values and matched nothing, and sending the + * string "undefined" would be a filter on a value no column holds. + */ +function queryString(params) { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) { + if (item !== undefined) search.append(key, String(item)); + } + continue; + } + search.append(key, String(value)); + } + const encoded = search.toString(); + return encoded ? `?${encoded}` : ''; +} + +/** + * The error a non-2xx becomes. + * + * `message` is the server's message verbatim, because §5.1 makes it + * load-bearing: `store.js` threw `" not found"` and the API + * reproduces that string exactly. The code, HTTP status and per-field details + * ride along as properties — new information a local store never had, and + * additive, so nothing that only reads `.message` notices. + */ +function apiError(status, payload) { + const body = payload?.error; + const error = new Error(body?.message || `Request failed with status ${status}`); + error.name = 'KrowApiError'; + error.status = status; + error.code = body?.code || 'internal'; + error.details = body?.details || {}; + return error; +} + +/** + * One request, unwrapped. + * + * Returns `payload.data`, so every caller above works in bare records exactly + * as it did against the local store. `meta` is deliberately dropped: nothing + * reads it (§4.2), and surfacing it would mean changing what the six methods + * return, which is the one thing Phase 2D must not do. + */ +async function request(method, path, { query, body } = {}) { + const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`; + + let response; + try { + response = await fetch(url, { + method, + // The session cookie is HttpOnly: this code cannot read it, attach it by + // hand, or store it. `credentials` is the only lever there is, and + // without it `fetch` omits cookies on cross-origin requests entirely. + // Same-origin — the supported arrangement — would send them anyway; + // saying so explicitly means the one line that makes authentication work + // is visible rather than implied. + credentials: 'include', + headers: body === undefined + ? { Accept: 'application/json' } + : { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (cause) { + // A transport failure, not an API response: no status, no envelope. The + // browser's own message for this is "Failed to fetch", which says nothing + // about which server or why, and it is nearly always the same cause — the + // API is not running. + const error = new Error( + `Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` + + `127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` + + `(${method} ${path})` + ); + error.name = 'KrowApiError'; + error.status = 0; + error.code = 'unreachable'; + error.details = {}; + error.cause = cause; + throw error; + } + + const text = await response.text(); + let payload = null; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = null; + } + } + + if (!response.ok) throw apiError(response.status, payload); + + if (payload === null) { + const error = new Error(`${method} ${path} returned no JSON body`); + error.name = 'KrowApiError'; + error.status = response.status; + error.code = 'internal'; + error.details = {}; + throw error; + } + + return payload.data; +} + +/** The request helper, for the `auth` surface in `base44Client.js`. */ +export { request }; + +/** + * True when an error is the API saying "you are not signed in". + * + * A 401 is an ordinary, expected answer here — it is what every request gets + * before the first login and after a session expires — so callers need to tell + * it apart from a real failure rather than treating both as "something broke". + */ +export function isUnauthenticated(error) { + return Boolean(error) && (error.status === 401 || error.code === 'unauthorized'); +} + +/* ── Entity API ─────────────────────────────────────────────────────────── */ + +/** + * Builds the client surface for one entity. + * + * Signature-compatible with `store.js`'s `createEntity`, defaults included. The + * defaults matter more than they look: `store.js` declared + * `list(sort = '-created_date', limit = 100)`, and several call sites rely on + * them rather than passing their own. + */ +export function createEntity(name) { + const path = RESOURCE_PATHS[name]; + if (!path) throw new Error(`No API resource path is declared for entity ${name}`); + const base = `/${path}`; + + return { + entityName: name, + + /** + * `sort` is always sent, even when empty. `?sort=` is not the same as + * omitting it: the contract reads an explicit empty value as "no ordering", + * which is what `applySort` did with a falsy sort, while omitting it would + * apply the endpoint's default. + */ + async list(sort = '-created_date', limit = 100) { + return request('GET', base, { query: { sort, limit } }); + }, + + async filter(query = {}, sort = '-created_date', limit = 100) { + // Spread first so a field genuinely named `sort`, `limit` or `offset` + // could never shadow the reserved parameters (§1 records that no column + // collides with them today; this keeps that true if one ever does). + return request('GET', base, { query: { ...query, sort, limit } }); + }, + + async get(id) { + return request('GET', `${base}/${encodeURIComponent(id)}`); + }, + + async create(data) { + return request('POST', base, { body: data }); + }, + + async update(id, data) { + return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data }); + }, + + async delete(id) { + return request('DELETE', `${base}/${encodeURIComponent(id)}`); + }, + + /** + * Sequential creates, exactly as `store.js` did it. + * + * Not a batch endpoint and not `Promise.all`: the contract has no bulk + * write (§12.1), and doing them one at a time keeps the failure behaviour + * identical — the first rejection stops the run and the records before it + * are already written. + */ + async bulkCreate(records = []) { + const created = []; + for (const record of records) created.push(await this.create(record)); + return created; + }, + }; +} diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx index 6018bc6..51f317f 100644 --- a/src/components/ProtectedRoute.jsx +++ b/src/components/ProtectedRoute.jsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { Outlet } from 'react-router-dom'; +import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { useAuth } from '@/lib/AuthContext'; import UserNotRegisteredError from '@/components/UserNotRegisteredError'; @@ -9,8 +9,24 @@ const DefaultFallback = () => ( ); +/** + * `unauthenticatedElement` defaults to the login page. + * + * It used to default to `undefined`, which renders nothing. That was invisible + * while the data client reported the seeded user as permanently signed in and + * this branch was unreachable; now that `GET /me` can genuinely answer 401, the + * default is what a signed-out visitor actually sees, and a blank screen is not + * an acceptable answer to "you are not signed in". + * + * The attempted path travels in location state so signing in returns the + * visitor to where they were going. + */ export default function ProtectedRoute({ fallback = , unauthenticatedElement }) { + const location = useLocation(); const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth(); + const signedOut = unauthenticatedElement ?? ( + + ); useEffect(() => { if (!authChecked && !isLoadingAuth) { @@ -26,11 +42,11 @@ export default function ProtectedRoute({ fallback = , unauthe if (authError.type === 'user_not_registered') { return ; } - return unauthenticatedElement; + return signedOut; } if (!isAuthenticated) { - return unauthenticatedElement; + return signedOut; } return ; diff --git a/src/components/admin/PageShell.jsx b/src/components/admin/PageShell.jsx index 98efa86..de82471 100644 --- a/src/components/admin/PageShell.jsx +++ b/src/components/admin/PageShell.jsx @@ -23,12 +23,18 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
-
+ {/* Wraps rather than compresses. The row carries three things of very + different lengths — the page name, its count and the status pill — + and on a 390px screen holding them on one line meant the count + breaking mid-phrase and the pill splitting into "Live / System". + Nothing wraps at any width where all three fit, so every desktop + layout is untouched. */} +

{title}

{/* A count of whatever the page is a list of, stated beside its name rather than only inside the list. */} - {meta && {meta}} - + {meta && {meta}} + Live System @@ -55,7 +61,10 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas export function SectionTitle({ id, title, meta, action, className }) { return (
-
+ {/* The count wraps under the section name rather than competing with it + for a 390px line. On any width where both fit — every desktop and + tablet layout — nothing wraps and the row is the row it always was. */} +

{title}

{meta && {meta}}
diff --git a/src/components/ai-assistant/AssistantPanel.jsx b/src/components/ai-assistant/AssistantPanel.jsx index a146393..6fc94eb 100644 --- a/src/components/ai-assistant/AssistantPanel.jsx +++ b/src/components/ai-assistant/AssistantPanel.jsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'; import KrowAssistant from './KrowAssistant'; import { useAssistantPanel } from './AssistantPanelContext'; import { ResizeDivider } from './ResizeDivider'; +import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport'; import OwliverAvatar from '@/components/krow/OwliverAvatar'; const EXPANDED_WIDTH = 620; @@ -18,32 +19,6 @@ const LEAD_PADDING = 8; const GUTTER = HANDLE_WIDTH + LEAD_PADDING; /** Expanded must never dominate: the dashboard stays the primary experience. */ const MAX_VIEWPORT_SHARE = 0.42; -/** - * Below this width there is no room for a column *beside* the dashboard — a - * 380px track on a 375px phone collapses `main` to nothing — so Owliver stacks - * underneath the page instead. - * - * Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline - * column out acceptably, so they keep the two-column workspace and only phones - * stack. - */ -const STACK_BREAKPOINT = 768; - -/** Tracks viewport width so the panel can be clamped and the layout switched. */ -function useViewportWidth() { - const [width, setWidth] = React.useState(() => - typeof window === 'undefined' ? 1440 : window.innerWidth - ); - - React.useEffect(() => { - const onResize = () => setWidth(window.innerWidth); - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); - }, []); - - return width; -} - /** * The collapsed state — a compact docked trigger. * @@ -64,7 +39,12 @@ function CollapsedTrigger({ page, onRestore }) { onClick={onRestore} aria-label={`Show the Owliver workspace for ${page}`} aria-expanded={false} - className="group fixed bottom-5 right-5 z-30 inline-flex items-center gap-2 rounded-full border border-border + /* `bottom` is a `max()` against the bottom safe-area inset rather than a + flat 20px: on a phone with a home indicator a flat offset puts the pill + under the gesture bar, where the tap belongs to the OS. `env()` is 0 + everywhere else, so desktop keeps the offset it always had. */ + className="group fixed bottom-[max(1.25rem,env(safe-area-inset-bottom))] right-[max(1.25rem,env(safe-area-inset-right))] + z-30 inline-flex items-center gap-2 rounded-full border border-border bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base hover:border-krow-blue/40 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50" @@ -77,11 +57,105 @@ function CollapsedTrigger({ page, onRestore }) { } /** - * AssistantPanel — the Owliver workspace column the Admin layout renders. + * MobileWorkspace — Owliver on a phone. * - * Owliver is part of the page on every supported route, so this is a column in - * the layout rather than an overlay: the dashboard reflows beside it instead of - * being covered, and collapsing restores the original layout exactly. + * The same `KrowAssistant`, the same context, the same runtime, the same + * conversation. What changes is only where it is mounted: on a phone the + * workspace is an overlay above the page rather than a column beside it. + * + * That is forced by arithmetic, not taste. The desktop workspace is + * `main + 400px`; at 375px the 400px track leaves `main` negative, so the two + * surfaces stop being a layout and start being a fight over the same pixels — + * which is exactly what the broken state was. An overlay takes the page out of + * that arithmetic entirely: the page stays `width: 100%` whether Owliver is + * open or closed, and there is never a reserved column standing empty. + * + * Three things this is deliberately not: + * + * - Not a second chat. Nothing about the assistant is re-implemented; this + * component is a positioned container and nothing else. + * - Not a takeover. It stops below the app header, so the reader can still see + * where they are and can still leave. + * - Not a fixed height. It is sized to `visualViewport` where that exists and + * to `100dvh` where it does not, so an open keyboard shortens the sheet + * instead of pushing the composer off the bottom of it. + */ +function MobileWorkspace({ context, onClose }) { + const viewport = useVisualViewport(); + + /* The page behind an overlay must not scroll: on a touch screen a drag that + starts on the scrim and lands on the page is otherwise indistinguishable + from scrolling the conversation, and the reader loses their place on both + surfaces at once. Restored exactly as found — another overlay may already + own it. */ + React.useEffect(() => { + const { body } = document; + const previous = body.style.overflow; + body.style.overflow = 'hidden'; + return () => { body.style.overflow = previous; }; + }, []); + + return ( +
+ {/* Tapping the page dismisses, which is what a sheet over a page should + do. A button rather than a bare div so it is reachable without a + pointer. */} +
+ ); +} + +/** + * AssistantPanel — the Owliver workspace the Admin layout renders. + * + * One panel, two presentations, chosen by how much room there is beside the + * page rather than by what kind of device is asking: + * + * ≥ 768px a column in the layout. Owliver is part of the page on every + * supported route, so the dashboard reflows beside it instead of + * being covered, and collapsing restores the original layout + * exactly. This is the protected desktop geometry and everything + * below describes it. + * < 768px an overlay (`MobileWorkspace`), because a 400px track does not fit + * beside anything on a 375px phone. The page is `width: 100%` in + * both states and never participates in a two-column width + * calculation it cannot satisfy. + * + * Both presentations mount the same `KrowAssistant` with the same context and + * read the same open/collapsed state, so there is one assistant in the product + * and one set of actions that change it. * * Five structural details matter, and every one of them was a bug at some point: * @@ -111,10 +185,24 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) { setWidth, resetWidth, open, close, expand, restore, } = useAssistantPanel(); const viewportWidth = useViewportWidth(); + const isPhone = useIsPhone(); // No assistant on this route: no column, no rail, no trace in the layout. if (!context) return null; + /* Phones: Owliver is never a column, in either state. + Closed, the layout is one column and `main` has the whole viewport — there + is no reserved 400px gutter to leave a blank strip down the right. Open, + the workspace is an overlay, so the page keeps that full width underneath + rather than being asked to share it with a track wider than the phone. + Both states are rendered from the same panel state the desktop column uses, + so opening, collapsing and reopening are the same three actions here. */ + if (isPhone) { + return isOpen + ? + : ; + } + /* Expanded overrides the dragged width; otherwise the user's own width wins. Both are clamped against the viewport so the dashboard is never squeezed. */ const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE); @@ -131,25 +219,6 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) { const trackWidth = panelWidth + GUTTER; - /* Phones: stack Owliver under the dashboard. A 380px column beside the page is - not a layout at this width, and an overlay that opens on load would put a - sheet between the user and the page they asked for. */ - if (viewportWidth < STACK_BREAKPOINT) { - return ( - - ); - } - return (