chore: clean up and restructure repository
This commit is contained in:
97
src/components/admin/PageShell.jsx
Normal file
97
src/components/admin/PageShell.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Shared page furniture for the Admin product.
|
||||
*
|
||||
* Admin pages are dense and numerous, so the header, the section labels and the
|
||||
* vertical rhythm are defined once here. That is what makes seven independently
|
||||
* built pages read as one product rather than seven takes on a dashboard.
|
||||
*/
|
||||
|
||||
/**
|
||||
* AdminPage — the standard page frame: title, subtitle, right-aligned controls,
|
||||
* then stacked sections.
|
||||
*
|
||||
* Controls live in the page rather than in the global chrome, which is why the
|
||||
* Admin header only needs one 56px bar.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
||||
Live System
|
||||
</span>
|
||||
</div>
|
||||
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2.5">{actions}</div>}
|
||||
</header>
|
||||
|
||||
{tabs}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SectionTitle — the label above a region.
|
||||
*
|
||||
* Sentence-case and small rather than a heavy all-caps overline: on a page with
|
||||
* four or five sections, loud labels compete with the data they introduce.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
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">
|
||||
<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>
|
||||
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="inline-flex items-center gap-0.5 rounded text-caption font-medium text-krow-blue transition-colors hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{action.label}
|
||||
<ChevronRight className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar — the controls row above a table or list.
|
||||
*
|
||||
* Search takes the remaining space, filters keep their natural width, and the
|
||||
* primary action is pushed to the end, so every management page has the same
|
||||
* control geometry.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
{search && <div className="min-w-0 lg:max-w-xs lg:flex-1">{search}</div>}
|
||||
{filters && <div className="flex flex-wrap items-center gap-2">{filters}</div>}
|
||||
{actions && <div className="flex items-center gap-2 lg:ml-auto">{actions}</div>}
|
||||
</div>
|
||||
{meta && <p className="text-caption text-ink-4">{meta}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/admin/RoleGlyph.jsx
Normal file
73
src/components/admin/RoleGlyph.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* RoleGlyph — the visual identity on a position card.
|
||||
*
|
||||
* Positions in this dataset have no photograph, and there is nothing to
|
||||
* photograph anyway: a role is not a person. The two obvious fallbacks are both
|
||||
* worse than this one — two-letter initials ("BA", "SE") read as a placeholder
|
||||
* someone forgot to replace, and stock photography attaches an invented face to a
|
||||
* job requisition, which is misleading on a page about hiring real people.
|
||||
*
|
||||
* So the glyph is the role's own trade: a chef's hat, a wine glass, a hard hat.
|
||||
* It is drawn from the existing icon language and the existing brand tint, it is
|
||||
* stable for a given category, and it tells you what kind of work the position is
|
||||
* from across the room — which is the actual job of an image in a card grid.
|
||||
*/
|
||||
|
||||
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
||||
/** @type {[RegExp, React.ComponentType<any>][]} */
|
||||
const GLYPHS = [
|
||||
[/chef|cook|kitchen/i, ChefHat],
|
||||
[/bartend|bar|mixolog/i, Wine],
|
||||
[/server|wait|host|banquet|concierge/i, ConciergeBell],
|
||||
[/security|guard|officer/i, ShieldCheck],
|
||||
[/pick|warehouse|stock|inventory/i, PackageSearch],
|
||||
[/driver|delivery|logistic/i, Truck],
|
||||
[/construct|labour|labor|site/i, HardHat],
|
||||
];
|
||||
|
||||
export function iconForRole(category, title = '') {
|
||||
const subject = `${category || ''} ${title || ''}`;
|
||||
const match = GLYPHS.find(([pattern]) => pattern.test(subject));
|
||||
return match ? match[1] : Briefcase;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: { box: 'h-9 w-9 rounded-lg', icon: 'h-4 w-4' },
|
||||
md: { box: 'h-11 w-11 rounded-xl', icon: 'h-5 w-5' },
|
||||
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
|
||||
const Icon = iconForRole(category, title);
|
||||
const s = SIZES[size] || SIZES.md;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'relative grid shrink-0 place-items-center bg-krow-blue-tint text-krow-blue ring-1 ring-krow-blue/10',
|
||||
s.box,
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className={s.icon} />
|
||||
|
||||
{/* AI-built positions carry the same spark used everywhere else for
|
||||
generated content, so provenance is legible without a second badge
|
||||
competing for room in the card header. */}
|
||||
{aiGenerated && (
|
||||
<span className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-yellow text-ink-1 ring-2 ring-white">
|
||||
<Sparkles className="h-2 w-2" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleGlyph;
|
||||
Reference in New Issue
Block a user