Files
TREK/client/src/mobile/screens/admin/MAdminUsersSection.tsx
T
Maurice 27c19b5ff5 feat(mobile): admin, settings picker and place sheets in the common style, all sheets on glass
Admin sheets: their frame draws the icon tile header (an icon per sheet, the
plugin sheets' icons moved out of the title into the tile), the soft scroll
edge and 42 px footer buttons; admin fields use the sheets' eyebrow labels
and round inputs. The settings picker, the bulk category sheet and the save
to list sheet get the common header (TileHeader may now leave out the icon
for a plain picker). Every phone sheet now uses the glass material, as the
trip settings do; it stays 94 % opaque, so forms over the map remain
legible.
2026-11-06 16:45:09 +01:00

233 lines
10 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>
<div className="mb-1 flex items-center gap-2">
<span className="text-[0.875rem] font-extrabold text-m-ink">{t('admin.tabs.users')}</span>
<span className="font-geist text-[0.625rem] font-bold text-m-faint">
{users.length} {t('admin.stats.users', { count: users.length })}
</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 ${
u.role === 'admin'
? 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] text-[color:var(--m-st-danger)]'
: 'bg-[color:var(--m-ic)] text-m-muted'
}`}
>
{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>
)
}