fix mobile screen issues
This commit is contained in:
@@ -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 = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* `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 = <DefaultFallback />, unauthenticatedElement }) {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||
const signedOut = unauthenticatedElement ?? (
|
||||
<Navigate to="/admin/login" replace state={{ from: location.pathname }} />
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked && !isLoadingAuth) {
|
||||
@@ -26,11 +42,11 @@ export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthe
|
||||
if (authError.type === 'user_not_registered') {
|
||||
return <UserNotRegisteredError />;
|
||||
}
|
||||
return unauthenticatedElement;
|
||||
return signedOut;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return unauthenticatedElement;
|
||||
return signedOut;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
|
||||
@@ -23,12 +23,18 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
||||
<div className={cn('space-y-6', className)}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* 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. */}
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
|
||||
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
|
||||
{/* A count of whatever the page is a list of, stated beside its
|
||||
name rather than only inside the list. */}
|
||||
{meta && <span className="text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
{meta && <span className="whitespace-nowrap text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
||||
Live System
|
||||
</span>
|
||||
@@ -55,7 +61,10 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
||||
export function SectionTitle({ id, title, meta, action, className }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
||||
<div className="flex items-baseline gap-2">
|
||||
{/* 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. */}
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
|
||||
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
|
||||
{meta && <span className="text-caption text-ink-4">{meta}</span>}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-x-0 top-0 z-50 h-[100dvh] md:hidden"
|
||||
style={viewport ? { height: viewport.height, top: viewport.offsetTop } : undefined}
|
||||
>
|
||||
{/* 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. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close the Owliver workspace"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in"
|
||||
/>
|
||||
|
||||
{/* `top-14` is the header's own height: the sheet starts under the app
|
||||
bar rather than over it, so navigation is never covered. `min-h-0` is
|
||||
what lets the assistant's internal scroller own the overflow instead
|
||||
of the sheet growing past the viewport. */}
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 top-14 flex min-h-0 flex-col px-3
|
||||
pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Owliver workspace"
|
||||
>
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
/* Expanded is a desktop-only width state; there is no wider to go
|
||||
here, so the control is not offered rather than offered and inert. */
|
||||
expanded={false}
|
||||
onClose={onClose}
|
||||
onExpand={null}
|
||||
onRestore={null}
|
||||
className="h-full min-h-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
? <MobileWorkspace context={context} onClose={close} />
|
||||
: <CollapsedTrigger page={context.page} onRestore={open} />;
|
||||
}
|
||||
|
||||
/* 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 (
|
||||
<aside aria-label="Owliver workspace" className="pb-6 pt-2">
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
expanded={false}
|
||||
onClose={close}
|
||||
onExpand={null}
|
||||
onRestore={null}
|
||||
className="h-[32rem]"
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Owliver workspace"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { resolveAssistantContext } from './placement';
|
||||
import { PHONE_QUERY } from './viewport';
|
||||
import { PageContextProvider } from './PageContext';
|
||||
import { AgentProvider } from './AgentContext';
|
||||
|
||||
@@ -58,6 +59,24 @@ function writeFlag(key, value) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this session is starting on a phone.
|
||||
*
|
||||
* Read once, to pick the *default* — never to decide how the panel renders.
|
||||
* That decision belongs to `AssistantPanel`, which watches the same query and
|
||||
* re-renders when it changes; a resize past the breakpoint must move the panel
|
||||
* between presentations without silently reopening or closing it.
|
||||
*
|
||||
* The same media query the layout switches on, imported rather than repeated.
|
||||
*/
|
||||
function startsOnPhone() {
|
||||
try {
|
||||
return window.matchMedia(PHONE_QUERY).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
/* Two different things, deliberately kept apart: the account preference is
|
||||
whether Owliver *should* open by default, and the session flag is whether
|
||||
@@ -65,8 +84,19 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
panel collapsed for the afternoon does not rewrite the account setting, and
|
||||
an account with Owliver off does not get the panel pushed back at it on
|
||||
every new tab. */
|
||||
/* The default differs by presentation, and only the default.
|
||||
On the desktop workspace Owliver *is* part of the page, so it opens with
|
||||
it. On a phone the workspace is an overlay (see AssistantPanel), and an
|
||||
overlay that opens on load is a sheet between the reader and the page they
|
||||
asked for — so a phone starts on the docked pill, one tap from the same
|
||||
conversation. Everything after that first render is the shared state:
|
||||
opening on a phone is the same `open()` the desktop header calls, and the
|
||||
session remembers it either way. */
|
||||
const [open, setOpen] = React.useState(
|
||||
() => readFlag(OPEN_KEY, base44.auth.preferences().owliverDefault !== false)
|
||||
() => readFlag(
|
||||
OPEN_KEY,
|
||||
!startsOnPhone() && base44.auth.preferences().owliverDefault !== false
|
||||
)
|
||||
);
|
||||
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
|
||||
const [width, setWidth] = React.useState(() => {
|
||||
|
||||
102
src/components/ai-assistant/viewport.js
Normal file
102
src/components/ai-assistant/viewport.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Viewport facts the Owliver workspace is laid out from.
|
||||
*
|
||||
* Separate from both the panel and its context because both need them: the
|
||||
* panel decides which presentation to render, the provider decides which state
|
||||
* to start in, and a breakpoint that two modules each keep their own copy of is
|
||||
* a breakpoint that will eventually disagree with itself. Keeping it here also
|
||||
* keeps the two from importing each other in a cycle.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* 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 stops
|
||||
* being a column at all and becomes an overlay over the page.
|
||||
*
|
||||
* 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
|
||||
* change presentation. Exported so the provider can start a phone with the
|
||||
* workspace closed from the same number the layout switches on — two places
|
||||
* asking "is this a phone" must never be able to disagree.
|
||||
*/
|
||||
export const STACK_BREAKPOINT = 768;
|
||||
export const PHONE_QUERY = `(max-width: ${STACK_BREAKPOINT - 1}px)`;
|
||||
|
||||
/** Tracks viewport width so the panel can be clamped. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the shell is in its phone layout.
|
||||
*
|
||||
* `matchMedia` rather than a width comparison, so the switch happens on exactly
|
||||
* the same boundary the stylesheet uses. `window.innerWidth` is not that
|
||||
* boundary on a phone: when anything on the page overflows, mobile browsers
|
||||
* widen the layout viewport to fit it, `innerWidth` reports the widened value,
|
||||
* and the layout that was supposed to rescue the page reads the viewport as a
|
||||
* tablet and hands it a 400px sidebar instead. A media query is evaluated
|
||||
* against the media, not against the content, so it cannot be talked out of the
|
||||
* mobile layout by the very overflow the mobile layout exists to prevent.
|
||||
*/
|
||||
export function useIsPhone() {
|
||||
const [phone, setPhone] = React.useState(() => {
|
||||
try {
|
||||
return window.matchMedia(PHONE_QUERY).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(PHONE_QUERY);
|
||||
const onChange = (e) => setPhone(e.matches);
|
||||
setPhone(mql.matches);
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return phone;
|
||||
}
|
||||
|
||||
/**
|
||||
* The height the user can actually see, while a keyboard is open.
|
||||
*
|
||||
* `100dvh` follows the address bar but knows nothing about the on-screen
|
||||
* keyboard: with the keyboard up, a `100dvh` sheet keeps its full height and
|
||||
* puts its own composer underneath the keys — the one control the sheet exists
|
||||
* to offer. `visualViewport` is the region left over, so the sheet is sized to
|
||||
* that and the composer stays on screen.
|
||||
*
|
||||
* Returns `null` where the API is missing, and the caller falls back to the
|
||||
* `100dvh` class. Nothing here is a fixed pixel height.
|
||||
*/
|
||||
export function useVisualViewport() {
|
||||
const [box, setBox] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return undefined;
|
||||
const update = () => setBox({ height: Math.round(vv.height), offsetTop: Math.round(vv.offsetTop) });
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return box;
|
||||
}
|
||||
@@ -456,7 +456,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
||||
className="overflow-hidden rounded-xl border border-border bg-surface"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-body-sm">
|
||||
<table className="w-full min-w-[40rem] text-left text-body-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
|
||||
<th className="px-4 py-3">Rank</th>
|
||||
|
||||
@@ -194,7 +194,7 @@ export function DataTable({
|
||||
<LoadingOverlay show={refreshing} />
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<table className="w-full min-w-[36rem] border-collapse">
|
||||
{caption && <caption className="sr-only">{caption}</caption>}
|
||||
|
||||
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
|
||||
|
||||
@@ -83,7 +83,14 @@ export function MetricStrip({ items = [], columns, className, loading = false })
|
||||
<div>
|
||||
{/* Header: Label & Icon */}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<p className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
|
||||
{/* Wraps on a phone, clips on everything wider.
|
||||
Two of these sit side by side at 320px, which leaves about
|
||||
110px for the label — enough for "OPEN POSITIONS" over two
|
||||
lines and not enough for it on one, so a flat `truncate`
|
||||
turned every metric on the Control Center into "OPEN POSI…".
|
||||
From `sm` the strip is three or six across with room for the
|
||||
label on one line, which is where clipping was the intent. */}
|
||||
<p className="min-w-0 line-clamp-2 text-[10px] font-bold uppercase tracking-wider text-ink-4 sm:line-clamp-1">
|
||||
{item.label}
|
||||
</p>
|
||||
{IconComponent && (
|
||||
|
||||
@@ -43,7 +43,11 @@ export function Modal({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn('p-0 gap-0 overflow-hidden max-h-[90vh] flex flex-col', WIDTHS[size], className)}
|
||||
/* `dvh`, not `vh`: on a phone `100vh` is the *large* viewport, which
|
||||
includes the browser chrome that is currently covering the bottom of
|
||||
the screen — so a `90vh` dialog put its footer, and its confirm
|
||||
button, under the address bar. */
|
||||
className={cn('p-0 gap-0 overflow-hidden max-h-[calc(100dvh-2rem)] flex flex-col', WIDTHS[size], className)}
|
||||
onInteractOutside={busy ? (e) => e.preventDefault() : undefined}
|
||||
onEscapeKeyDown={busy ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function ActivityLogTable({ activities }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<table className="w-full min-w-[40rem] text-[13px]">
|
||||
<thead className="bg-[#F9FAFB] text-[#6B7280]">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">Event</th>
|
||||
|
||||
@@ -139,8 +139,16 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
|
||||
return (
|
||||
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
|
||||
{/* The row wraps below `sm`, and only below `sm`.
|
||||
Its fixed furniture — rank, portrait, score ring and the action
|
||||
cluster — adds up to about 400px before the name has a single pixel,
|
||||
so on a 320px phone the flexible middle was being squeezed to nothing
|
||||
and the candidate's name truncated to one character while the buttons
|
||||
hung off the side of the screen. Wrapping gives the name the width and
|
||||
moves the controls to their own line; `sm:flex-nowrap` restores the
|
||||
single desktop row exactly. */}
|
||||
<div
|
||||
className="flex items-center gap-4 cursor-pointer"
|
||||
className="flex flex-wrap items-center gap-3 cursor-pointer sm:flex-nowrap sm:gap-4"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{/* Rank */}
|
||||
@@ -149,7 +157,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
</div>
|
||||
|
||||
{/* Avatar — 88-110px target size */}
|
||||
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
|
||||
<div className="w-16 h-16 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
|
||||
<Image
|
||||
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
|
||||
alt={application.applicant_name}
|
||||
@@ -158,8 +166,10 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Info. `basis-40` rather than the flex default of 0 so the name has a
|
||||
floor to claim on a phone — under it the row wraps instead of
|
||||
shaving the one column that carries meaning. */}
|
||||
<div className="flex-1 basis-40 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-[#111827] text-[14px] truncate">{application.applicant_name}</h3>
|
||||
<span className={cn('px-2 py-0.5 rounded-full text-[10px] font-medium', style.badge)}>{style.label}</span>
|
||||
@@ -201,76 +211,84 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
{status !== 'applied' && <ScoreRing score={score} />}
|
||||
{/* Score and actions.
|
||||
`sm:contents` dissolves this wrapper from the tablet breakpoint up,
|
||||
so above `sm` the ring and the buttons are direct children of the
|
||||
row exactly as they always were — same order, same gaps, same
|
||||
desktop geometry. Below it they travel together onto the row's
|
||||
second line. */}
|
||||
<div className="flex w-full items-center justify-between gap-3 sm:contents">
|
||||
{/* Score */}
|
||||
{status !== 'applied' && <ScoreRing score={score} />}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{/* The full record, at the route that already serves it.
|
||||
Clicking the row expands a summary in place, which is the right
|
||||
default while comparing people — but the complete profile had no
|
||||
way in from this list at all, so the same candidate opened from a
|
||||
recommendation and from here led to two different depths of the
|
||||
same person. One link, one route, one profile. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/candidates/${application.id}`)}
|
||||
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
|
||||
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||
>
|
||||
View profile
|
||||
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{onAction && canTakeAction && (
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{/* The full record, at the route that already serves it.
|
||||
Clicking the row expands a summary in place, which is the right
|
||||
default while comparing people — but the complete profile had no
|
||||
way in from this list at all, so the same candidate opened from a
|
||||
recommendation and from here led to two different depths of the
|
||||
same person. One link, one route, one profile. */}
|
||||
<button
|
||||
onClick={() => onAction(application)}
|
||||
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/candidates/${application.id}`)}
|
||||
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
|
||||
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||
>
|
||||
{status === 'interview' ? 'AI Interview' : 'Interview'}
|
||||
View profile
|
||||
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
|
||||
Actions
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
|
||||
{onAction && canTakeAction && (
|
||||
<button
|
||||
onClick={() => onAction(application)}
|
||||
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
|
||||
>
|
||||
{status === 'interview' ? 'AI Interview' : 'Interview'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
|
||||
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||
<span className="text-[#374151] text-[13px] font-medium">Message</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669] text-[13px] font-medium">Call</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onHire && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
|
||||
Actions
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
|
||||
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||
<span className="text-[#374151] text-[13px] font-medium">Message</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669] text-[13px] font-medium">Call</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onHire && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && <CandidateExpandedDetails application={application} />}
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
||||
const identityClass = identityVerified ? 'bg-[#EEF3FE] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
|
||||
<DialogContent className="max-w-2xl max-h-[calc(100dvh-2rem)] overflow-y-auto p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -164,7 +164,7 @@ function TableSection({ data }) {
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-body-sm">
|
||||
<table className="w-full min-w-[32rem] border-collapse text-body-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{columns.map((c) => (
|
||||
|
||||
@@ -33,7 +33,7 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg max-h-[calc(100dvh-2rem)] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:w-full data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
Reference in New Issue
Block a user