SPB Git

Repository Public

Items.tsx 12.0 KB 180 lines tsx

1// One entry of the listing, as a table-like row (list) or a card (grid). Memoised: a selection change re-renders2// only the rows whose state changed, which keeps folders with thousands of entries responsive.3import { formatBytes } from '@spd/shared';4import { memo, type MouseEvent, type DragEvent, useRef, useState } from 'react';5import { Glyph } from '../glyphs.tsx';6import { fmtDate, fmtRelative, getLocale, useT } from '../i18n.ts';7import { Thumb } from '../preview/Thumb.tsx';8import { ownerSource } from '../sources.ts';9import type { Item, Tag } from '../types.ts';10import { Badge, cx } from '../ui.tsx';11import { tagStyle } from './lib.ts';1213/** Callbacks from the explorer. The object is stable for the life of the explorer so rows never re-render for it. */14export interface RowApi {15  click(e: MouseEvent, id: string, touch: boolean): void;16  open(id: string): void;17  menu(id: string, at: { x: number; y: number } | DOMRect): void;18  longPress(id: string): void;19  focus(id: string): void;20  dragStart(e: DragEvent, id: string): void;21  dragEnd(): void;22  canDrop(id: string): boolean;23  drop(id: string): void;24}25export type ViewMode = 'grid' | 'list';2627// Which kind of pointer started the current gesture: a tap opens, a mouse click selects.28let pointer = 'mouse';29let swallowClickUntil = 0;3031export function TagChip({ tag, small }: { tag: Tag; small?: boolean }) {32  return <span className={cx('inline-flex max-w-32 shrink-0 items-center truncate rounded-full font-medium', small ? 'px-1.5 py-px text-[11px]' : 'px-2 py-0.5 text-xs', tagStyle(tag.color).chip)}>{tag.name}</span>;33}3435interface Props { item: Item; mode: ViewMode; selected: boolean; tabbable: boolean; tags: Map<string, Tag>; api: RowApi; index: number }36export const ItemRow = memo(function ItemRow({ item, mode, selected, tabbable, tags, api, index }: Props) {37  const t = useT();38  const [over, setOver] = useState(false);39  const press = useRef<{ timer: ReturnType<typeof setTimeout>; x: number; y: number } | null>(null);40  const file = item.type === 'file' ? item : null;41  const infected = file?.scan === 'infected';42  const endPress = () => { if (press.current) { clearTimeout(press.current.timer); press.current = null; } };43  const longPress = () => { endPress(); swallowClickUntil = Date.now() + 700; navigator.vibrate?.(10); api.longPress(item.id); };4445  const row = {46    role: 'row', 'aria-selected': selected, 'aria-rowindex': index + 1, 'data-id': item.id, 'data-type': item.type, tabIndex: tabbable ? 0 : -1,47    draggable: true,48    onFocus: (e: React.FocusEvent) => { if (e.target === e.currentTarget) api.focus(item.id); },49    onPointerDown: (e: React.PointerEvent) => {50      pointer = e.pointerType;51      if (e.pointerType !== 'touch') return;52      endPress();53      press.current = { x: e.clientX, y: e.clientY, timer: setTimeout(longPress, 480) };54    },55    onPointerMove: (e: React.PointerEvent) => { if (press.current && Math.hypot(e.clientX - press.current.x, e.clientY - press.current.y) > 10) endPress(); },56    onPointerUp: endPress, onPointerCancel: endPress,57    onClick: (e: MouseEvent) => { if (Date.now() < swallowClickUntil) { swallowClickUntil = 0; return; } api.click(e, item.id, pointer === 'touch'); },58    onDoubleClick: () => { if (pointer !== 'touch') api.open(item.id); },59    onContextMenu: (e: MouseEvent) => {60      e.preventDefault();61      if (pointer === 'touch') { if (press.current) longPress(); return; } // Android fires contextmenu on a long press62      api.menu(item.id, { x: e.clientX, y: e.clientY });63    },64    onDragStart: (e: DragEvent) => { if (pointer === 'touch') { e.preventDefault(); return; } api.dragStart(e, item.id); },65    onDragEnd: () => api.dragEnd(),66    ...(item.type === 'folder' ? {67      onDragOver: (e: DragEvent) => { if (!api.canDrop(item.id)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; if (!over) setOver(true); },68      onDragLeave: () => setOver(false),69      onDrop: (e: DragEvent) => { if (!api.canDrop(item.id)) return; e.preventDefault(); e.stopPropagation(); setOver(false); api.drop(item.id); },70    } : {}),71  };72  const state = cx('group relative cursor-default select-none outline-none [-webkit-touch-callout:none] [&_img]:pointer-events-none focus-visible:ring-2 focus-visible:ring-accent',73    over ? 'bg-accent-soft! ring-2 ring-accent' : selected ? 'bg-accent-soft!' : '[@media(hover:hover)]:hover:bg-hover active:bg-hover');7475  const thumb = (cls: string) => (76    <Thumb name={item.name} kind={file ? file.kind : 'folder'} className={cls}77      src={file && file.thumb && !infected ? ownerSource(file).previewUrl('thumb') : undefined} />78  );79  const fileTags = file ? file.tagIds.map((id) => tags.get(id)).filter((x): x is Tag => !!x) : [];80  const marks = (81    <>82      {item.starred ? <span className="shrink-0 text-amber-500" title={t('explorer.starred')}><Glyph name="star" size={14} fill="currentColor" /><span className="sr-only">{t('explorer.starred')}</span></span> : null}83      {infected ? <Badge tone="danger"><Glyph name="warning" size={12} />{t('explorer.infected')}</Badge> : null}84      {file && file.versions > 1 ? <span className="shrink-0 rounded-full bg-hover px-1.5 py-px text-[11px] font-medium text-ink-2" title={t('explorer.versionsN', { n: file.versions })}>v{file.versions}</span> : null}85      {file?.origin === 'request' ? <span className="shrink-0 text-ink-3" title={t('explorer.viaPortal')}><Glyph name="inbox" size={14} /><span className="sr-only">{t('explorer.viaPortal')}</span></span> : null}86    </>87  );88  const more = (89    <button type="button" tabIndex={-1} aria-label={t('explorer.actionsFor', { name: item.name })} aria-haspopup="menu" data-more90      onClick={(e) => { e.stopPropagation(); api.menu(item.id, e.currentTarget.getBoundingClientRect()); }} onDoubleClick={(e) => e.stopPropagation()} onPointerDown={(e) => { pointer = e.pointerType; e.stopPropagation(); }}91      className="inline-flex h-11 w-10 shrink-0 items-center justify-center rounded-full text-ink-3 transition hover:bg-line hover:text-ink focus-visible:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus:opacity-100 [@media(hover:hover)]:group-aria-selected:opacity-100">92      <Glyph name="dots" size={22} strokeWidth={2.8} />93    </button>94  );95  const when = item.trashedAt ?? item.updatedAt;96  const size = file ? formatBytes(file.size, getLocale()) : null;9798  if (mode === 'list') return (99    <div {...row} className={cx(state, 'grid min-h-[68px] grid-cols-[minmax(0,1fr)_2.5rem] items-center gap-x-3 pr-1.5 pl-3 transition-colors after:absolute after:right-0 after:bottom-0 after:left-[4.5rem] after:h-px after:bg-line last:after:hidden sm:min-h-[58px] sm:grid-cols-[minmax(0,1fr)_9.5rem_5.5rem_2.5rem] sm:pr-3 sm:pl-4 sm:after:left-[4.75rem] [contain-intrinsic-size:auto_4.25rem] [content-visibility:auto]')}>100      <div role="gridcell" className="flex min-w-0 items-center gap-3.5">101        {thumb('h-11 w-11 shrink-0 overflow-hidden rounded-[13px]! sm:h-10 sm:w-10')}102        <div className="min-w-0 flex-1">103          <div className="flex min-w-0 items-center gap-1.5">104            <span className="truncate text-[16px] font-medium tracking-[-0.01em] text-ink sm:text-sm sm:tracking-normal" data-name>{item.name}</span>105            {marks}106            <span className="hidden min-w-0 items-center gap-1 lg:flex">{fileTags.slice(0, 3).map((g) => <TagChip key={g.id} tag={g} small />)}</span>107          </div>108          <div className="mt-0.5 flex items-center gap-1.5 truncate text-[13px] text-ink-3 sm:hidden">109            <span className="truncate">{size ? `${size} · ` : ''}{fmtRelative(when)}</span>110            {fileTags.slice(0, 4).map((g) => <span key={g.id} title={g.name} className={cx('h-2 w-2 shrink-0 rounded-full', tagStyle(g.color).dot)} />)}111          </div>112        </div>113      </div>114      <div role="gridcell" className="hidden truncate text-sm text-ink-2 sm:block" title={fmtDate(when)}>{fmtRelative(when)}</div>115      <div role="gridcell" className="hidden text-right text-sm text-ink-2 tabular-nums sm:block">{size ?? '—'}</div>116      <div role="gridcell" className="flex justify-end">{more}</div>117    </div>118  );119  if (!file) return (120    <div {...row} className={cx(state, 'press rounded-card bg-surface shadow-(--shadow-card)', selected && 'ring-2 ring-accent')}>121      <div role="gridcell" className="flex h-full min-h-[112px] flex-col justify-between pt-3 pb-3 pl-3.5">122        <div className="flex items-start justify-between">123          {thumb('h-11 w-11 shrink-0 rounded-[13px]!')}124          <div className="-mt-1.5 flex items-center gap-1 pr-0.5">{marks}{more}</div>125        </div>126        <span className="line-clamp-2 pr-3 text-[15px] leading-snug font-semibold tracking-[-0.01em] break-words text-ink" data-name>{item.name}</span>127      </div>128    </div>129  );130  return (131    <div {...row} className={cx(state, 'press overflow-hidden rounded-card bg-surface shadow-(--shadow-card)', selected && 'ring-2 ring-accent')}>132      <div role="gridcell" className="flex h-full flex-col">133        {thumb('aspect-[4/3] w-full overflow-hidden rounded-none!')}134        <div className="flex items-center gap-0.5 py-2 pr-0.5 pl-3.5">135          <div className="min-w-0 flex-1">136            <div className="flex min-w-0 items-center gap-1.5"><span className="truncate text-[15px] font-semibold tracking-[-0.01em] text-ink sm:text-sm" data-name>{item.name}</span>{marks}</div>137            <div className="flex items-center gap-1.5 text-[13px] text-ink-3 sm:text-xs">138              <span className="truncate">{size}<span className="max-sm:hidden"> · {fmtRelative(when)}</span></span>139              {fileTags.slice(0, 4).map((g) => <span key={g.id} title={g.name} className={cx('h-2 w-2 shrink-0 rounded-full', tagStyle(g.color).dot)} />)}140            </div>141          </div>142          {more}143        </div>144      </div>145    </div>146  );147});148149export function ListHeader({ sort, dir, onSort }: { sort?: string; dir?: string; onSort?: (key: 'name' | 'updated' | 'size') => void }) {150  const t = useT();151  const col = (key: 'name' | 'updated' | 'size', label: string, cls: string) => (152    <div role="columnheader" aria-sort={onSort && sort === key ? (dir === 'desc' ? 'descending' : 'ascending') : undefined} className={cls}>153      {onSort ? (154        <button type="button" onClick={() => onSort(key)} className="inline-flex h-8 items-center gap-1 rounded-lg px-1 tracking-wide uppercase hover:text-ink">155          {label}{sort === key ? <Glyph name={dir === 'desc' ? 'arrowDown' : 'arrowUp'} size={12} /> : null}156        </button>157      ) : <span className="px-1">{label}</span>}158    </div>159  );160  return (161    <div role="row" className="hidden h-11 grid-cols-[minmax(0,1fr)_9.5rem_5.5rem_2.5rem] items-center gap-x-3 border-b border-line pr-3 pl-4 text-xs font-semibold tracking-wide text-ink-3 uppercase sm:grid">162      {col('name', t('explorer.col.name'), '')}163      {col('updated', t('explorer.col.updated'), '-ml-1')}164      {col('size', t('explorer.col.size'), 'text-right')}165      <div role="columnheader"><span className="sr-only">{t('common.more')}</span></div>166    </div>167  );168}169170export function Skeleton({ mode }: { mode: ViewMode }) {171  const t = useT();172  return (173    <div role="status" aria-label={t('common.loading')} className={cx(mode === 'grid' ? 'grid grid-cols-2 gap-3 sm:grid-cols-[repeat(auto-fill,minmax(12.5rem,1fr))] sm:gap-4' : 'card flex flex-col overflow-hidden')}>174      {Array.from({ length: mode === 'grid' ? 8 : 9 }, (_, i) => mode === 'grid'175        ? <div key={i} className="overflow-hidden rounded-card bg-surface shadow-(--shadow-card)"><div className="skeleton aspect-[4/3]" /><div className="skeleton mx-3.5 mt-3 h-3.5 w-2/3 rounded-full" /><div className="skeleton mx-3.5 mt-2 mb-3.5 h-3 w-1/3 rounded-full" /></div>176        : <div key={i} className="flex h-[68px] items-center gap-3.5 px-3 sm:h-[58px] sm:px-4"><div className="skeleton h-11 w-11 shrink-0 rounded-[13px] sm:h-10 sm:w-10" /><div className="flex-1"><div className="skeleton h-3.5 rounded-full" style={{ width: `${30 + ((i * 17) % 40)}%` }} /><div className="skeleton mt-2 h-3 w-24 rounded-full" /></div></div>)}177    </div>178  );179}180
Files main
Menu

Theme