Files
TREK/client/src/mobile/screens/admin/MAdminUsersSection.tsx
T
Maurice 5346182807 fix(mobile): the rail, the docks and the top-level screens on a large screen
From the large-screen review (visual findings of the shell and the top-level
screens):

- Rail: 80px wide, one caption size for every name (no shrinking per item),
  balanced two-line names with every entry as tall as the tallest, short rail
  names where a language's word is too long (mobileNav.rail*, all locales),
  and near-opaque glass so a map's colours no longer smear through it.
- Rail head: always one 56px circle, so the entries never jump between pages:
  the create action, the screen's own action (the vacation calendar's
  year/editor switch moves there instead of floating over the days) or the
  quiet logo on settings and administration. It stays put while a short rail
  scrolls.
- Vacation calendar: weekday letters over each month on a wide window; the
  editor's mode switch sits at the foot with room kept for it.
- Docks: More is marked with a ring rather than the centre button's fill, the
  dock glass is denser, and lists fade out under the dock instead of showing
  in the gap below it.
- Dashboard: trip names get the card's width with the dates under them, the
  hero's facts wrap as whole chips, the filter chips scroll with a fade, the
  hero ends above the dock on a phone on its side and its buttons sit on a
  dark ground; the header bar stands in the content's column.
- Journeys: on a wide window the header is the same glass bar, in the column
  of the cards, and the other journeys' label starts with them.
- Notifications: the round chevron back button, in the list's column.
- Settings and administration share one frame and width. Admin's new-user
  action sits under the section list's title, the users card header wraps
  inside the card, stat labels ellipsize, role badges read in dark mode.
- Collections: one card per place with the status inside it, worded in a
  readable mix of its colour.
- Atlas and the journey map frame clear of the rail and the header; the
  journey's day bar sits on a glass plate, and a phone on its side gets
  smaller cards.
- Rail label tests rewritten for the new rule; FE-MOB-PANES-016 duplicate
  renumbered to 017.
2026-11-07 23:25:15 +01:00

239 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Copy, Link2, Trash2, UserPlus, Ticket } from 'lucide-react'
import type { TranslationFn } from '../../../types'
import type { useAdmin } from '../../../pages/admin/useAdmin'
import { inviteMeta, inviteStatus } from '../../../pages/admin/adminModel'
import MAdminPermissionsPanel from './MAdminPermissionsPanel'
import MSheet from '../../components/MSheet'
import MChip from '../../components/MChip'
import MConfirmSheet from '../settings/MConfirmSheet'
import { MAdminButton, MAdminCard, MAdminField, MAdminSheetFrame } from './MAdminUi'
interface MAdminUsersSectionProps {
admin: ReturnType<typeof useAdmin>
t: TranslationFn
locale: string
}
const INVITE_USES = [1, 2, 3, 4, 5, 0]
const INVITE_EXPIRY: { value: number | ''; label: string }[] = [
{ value: 1, label: '1d' },
{ value: 3, label: '3d' },
{ value: 7, label: '7d' },
{ value: 14, label: '14d' },
{ value: '', label: '∞' },
]
// Users section: user list with role badges (design §6.4), invite links and the
// permissions matrix. Rows open the edit sheet; Create opens the create sheet.
export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSectionProps) {
const {
currentUser, users, isLoading,
setShowCreateUser, handleEditUser,
invites, inviteTrips, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
copyInviteLink, handleCreateInvite, handleDeleteInvite, inviteToDelete, setInviteToDelete,
} = admin
return (
<div className="space-y-3">
<MAdminCard>
{/* Wraps rather than overflows: in a long language the button moves under
the title and its count, inside the card, instead of past its edge. */}
<div className="mb-1 flex flex-wrap items-center gap-x-2 gap-y-2">
<span className="flex min-w-0 items-baseline gap-2">
<span className="text-[0.875rem] font-extrabold text-m-ink">{t('admin.tabs.users')}</span>
<span className="whitespace-nowrap font-geist text-[0.625rem] font-bold text-m-faint">
{users.length} {t('admin.stats.users', { count: users.length })}
</span>
</span>
<MAdminButton className="ms-auto" onClick={() => setShowCreateUser(true)}>
<UserPlus size={12} strokeWidth={2.2} />
{t('mobileAdmin.create')}
</MAdminButton>
</div>
{isLoading ? (
<div className="flex justify-center py-6">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-[color:var(--m-ink)]" />
</div>
) : (
users.map((u) => (
<button
key={u.id}
type="button"
onClick={() => handleEditUser(u)}
className="flex w-full items-center gap-[11px] border-t border-[color:var(--m-rowbr)] py-[11px] text-start"
>
{u.avatar_url ? (
<img src={u.avatar_url} alt="" className="h-[34px] w-[34px] flex-none rounded-full object-cover" />
) : (
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-[0.75rem] font-extrabold text-m-ink">
{u.username.charAt(0).toUpperCase()}
</span>
)}
<span className="min-w-0 flex-1">
<span className="flex items-center gap-[5px]">
<span className="truncate text-[0.8125rem] font-bold text-m-ink">{u.username}</span>
{u.id === currentUser?.id && (
<span className="font-geist text-[0.5625rem] text-m-faint">{t('admin.you')}</span>
)}
</span>
<span className="block truncate font-geist text-[0.625rem] text-m-muted">{u.email}</span>
</span>
<span
className={`flex-none whitespace-nowrap rounded-full px-[9px] py-[3px] font-geist text-[0.5625rem] font-extrabold ${
// Dark mode lifts the words towards the ink: the plain status red on a
// dark red ground and grey on grey were both below 4.5:1 there.
u.role === 'admin'
? 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] text-[color:var(--m-st-danger)] dark:bg-[color:color-mix(in_srgb,var(--m-st-danger)_18%,transparent)] dark:text-[color:color-mix(in_srgb,var(--m-st-danger)_55%,var(--m-ink))]'
: 'bg-[color:var(--m-ic)] text-m-muted dark:text-m-ink'
}`}
>
{u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser')}
</span>
</button>
))
)}
</MAdminCard>
<MAdminCard>
<div className="mb-1 flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-[0.875rem] font-extrabold text-m-ink">{t('admin.invite.title')}</div>
<div className="mt-[2px] font-geist text-[0.625rem] text-m-muted">{t('admin.invite.subtitle')}</div>
</div>
<MAdminButton onClick={() => setShowCreateInvite(true)}>{t('admin.invite.create')}</MAdminButton>
</div>
{invites.length === 0 ? (
<div className="py-4 text-center font-geist text-[0.6875rem] text-m-faint">{t('admin.invite.empty')}</div>
) : (
invites.map((inv) => {
const { isActive, labelKey } = inviteStatus(inv)
return (
<div
key={inv.id}
className="flex items-center gap-[10px] border-t border-[color:var(--m-rowbr)] py-[11px]"
>
<Link2 size={14} strokeWidth={2} className={`flex-none ${isActive ? 'text-m-ink' : 'text-m-faint'}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-[6px]">
<code className="truncate font-geist text-[0.6875rem] text-m-ink">{inv.token.slice(0, 12)}…</code>
<span
className={`flex-none rounded-full px-[7px] py-[2px] font-geist text-[0.5625rem] font-extrabold ${
isActive
? 'bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_12%,transparent)] text-[color:var(--m-st-confirmed)]'
: 'bg-[color:var(--m-ic)] text-m-faint'
}`}
>
{t(labelKey)}
</span>
</div>
<div className="mt-[2px] truncate font-geist text-[0.59375rem] text-m-muted">
{inviteMeta(inv, t, locale)}
</div>
</div>
{isActive && (
<button
type="button"
title={t('admin.invite.copyLink')}
onClick={() => copyInviteLink(inv.token)}
className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
>
<Copy size={13} strokeWidth={2} />
</button>
)}
<button
type="button"
title={t('common.delete')}
onClick={() => setInviteToDelete(inv.id)}
className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)]"
>
<Trash2 size={13} strokeWidth={2} />
</button>
</div>
)
})
)}
</MAdminCard>
<MAdminPermissionsPanel />
{/* Delete invite confirm */}
<MConfirmSheet
open={inviteToDelete !== null}
onClose={() => setInviteToDelete(null)}
title={t('admin.invite.deleteTitle')}
message={t('admin.invite.deleteConfirm')}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
danger
onConfirm={() => inviteToDelete !== null && handleDeleteInvite(inviteToDelete)}
/>
{/* Create invite sheet */}
<MSheet open={showCreateInvite} onClose={() => setShowCreateInvite(false)} ariaLabel={t('admin.invite.create')}>
<MAdminSheetFrame
icon={<Ticket size={19} strokeWidth={1.8} />}
title={t('admin.invite.create')}
onClose={() => setShowCreateInvite(false)}
footer={
<>
<MAdminButton variant="ghost" onClick={() => setShowCreateInvite(false)}>
{t('common.cancel')}
</MAdminButton>
<MAdminButton onClick={handleCreateInvite}>{t('admin.invite.createAndCopy')}</MAdminButton>
</>
}
>
<div className="space-y-4">
<MAdminField label={t('admin.invite.maxUses')}>
<div className="flex flex-wrap gap-[6px]">
{INVITE_USES.map((n) => (
<MChip
key={n}
active={inviteForm.max_uses === n}
onClick={() => setInviteForm((f) => ({ ...f, max_uses: n }))}
>
{n === 0 ? '∞' : `${n}×`}
</MChip>
))}
</div>
</MAdminField>
<MAdminField label={t('admin.invite.expiry')}>
<div className="flex flex-wrap gap-[6px]">
{INVITE_EXPIRY.map((opt) => (
<MChip
key={String(opt.value)}
active={inviteForm.expires_in_days === opt.value}
onClick={() => setInviteForm((f) => ({ ...f, expires_in_days: opt.value }))}
>
{opt.label}
</MChip>
))}
</div>
</MAdminField>
{inviteTrips.length > 0 && (
<MAdminField label={t('admin.invite.tripLabel')} hint={t('admin.invite.tripHint')}>
<select
value={inviteForm.trip_id}
onChange={(e) =>
setInviteForm((f) => ({ ...f, trip_id: e.target.value === '' ? '' : Number(e.target.value) }))
}
className="h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none"
>
<option value="">{t('admin.invite.tripNone')}</option>
{inviteTrips.map((tr) => (
<option key={tr.id} value={tr.id}>
{tr.title}
</option>
))}
</select>
</MAdminField>
)}
</div>
</MAdminSheetFrame>
</MSheet>
</div>
)
}