NoteRow.tsx

5.4 KB · raw

1import { createSignal, onCleanup, onMount } from "solid-js";2import type { VaultEntry } from "../../shared/types";34function vibrate(ms: number) {5  try { navigator.vibrate?.(ms); } catch { /* unsupported */ }6}78function toRoman(n: number): string {9  const VALS: [number, string][] = [10    [1000,"M"],[900,"CM"],[500,"D"],[400,"CD"],[100,"C"],[90,"XC"],11    [50,"L"],[40,"XL"],[10,"X"],[9,"IX"],[5,"V"],[4,"IV"],[1,"I"],12  ];13  let result = "";14  for (const [val, sym] of VALS) {15    while (n >= val) { result += sym; n -= val; }16  }17  return result;18}1920function formatNoteDate(ts: number): string {21  const now = Date.now();22  const diff = now - ts;23  const DAY = 86_400_000;24  if (diff < DAY) return "Today";25  if (diff < 2 * DAY) return "Yesterday";26  const d = new Date(ts);27  return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });28}2930const SWIPE_THRESHOLD = 80;31const SWIPE_CONFIRM_THRESHOLD = 120;3233interface NoteRowProps {34  note: VaultEntry;35  index: number;36  searchQuery?: string;37  onTap: (note: VaultEntry, el: HTMLElement) => void;38  onDelete: (note: VaultEntry) => void;39  onArchive: (note: VaultEntry) => void;40  fadingClass?: string;41}4243export function NoteRow(props: NoteRowProps) {44  let rowEl!: HTMLDivElement;45  const [swipeX, setSwipeX] = createSignal(0);46  const [swiping, setSwping] = createSignal(false);47  const [confirmDelete, setConfirmDelete] = createSignal(false);4849  let touchStartX = 0;50  let touchStartY = 0;51  let captured = false;52  let longPressTimer: number | undefined;5354  function handleTouchStart(e: TouchEvent) {55    const t = e.touches[0];56    if (!t) return;57    touchStartX = t.clientX;58    touchStartY = t.clientY;59    captured = false;6061    longPressTimer = window.setTimeout(() => {62      vibrate(60);63      // Long press: context menu (future)64    }, 500);65  }6667  function handleTouchMove(e: TouchEvent) {68    const t = e.touches[0];69    if (!t) return;70    const dx = t.clientX - touchStartX;71    const dy = Math.abs(t.clientY - touchStartY);7273    if (!captured) {74      if (dy > 8 && dy > Math.abs(dx)) {75        // vertical scroll — abort76        clearTimeout(longPressTimer);77        return;78      }79      if (Math.abs(dx) > 8) {80        captured = true;81        clearTimeout(longPressTimer);82        setSwping(true);83      }84      return;85    }8687    setSwipeX(dx);88  }8990  function handleTouchEnd(e: TouchEvent) {91    clearTimeout(longPressTimer);92    if (!captured) {93      // Tap94      props.onTap(props.note, rowEl);95      vibrate(12);96      setSwping(false);97      return;98    }99100    const dx = swipeX();101    setSwping(false);102103    if (dx < -SWIPE_CONFIRM_THRESHOLD) {104      // Committed delete swipe105      vibrate(40);106      setConfirmDelete(true);107      setSwipeX(0);108      setTimeout(() => setConfirmDelete(false), 4000);109    } else if (dx > SWIPE_CONFIRM_THRESHOLD) {110      // Archive111      vibrate(12);112      props.onArchive(props.note);113      setSwipeX(0);114    } else {115      // Snap back116      setSwipeX(0);117    }118    captured = false;119  }120121  function handleTouchCancel() {122    clearTimeout(longPressTimer);123    setSwipeX(0);124    setSwping(false);125    captured = false;126  }127128  // Highlight search match in title129  function renderTitle() {130    const title = props.note.title || "Untitled";131    const q = props.searchQuery?.trim().toLowerCase();132    if (!q) return <span class="peach-note-title">{title}</span>;133    const idx = title.toLowerCase().indexOf(q);134    if (idx === -1) return <span class="peach-note-title">{title}</span>;135    return (136      <span class="peach-note-title">137        {title.slice(0, idx)}138        <span class="peach-note-title-match">{title.slice(idx, idx + q.length)}</span>139        {title.slice(idx + q.length)}140      </span>141    );142  }143144  return (145    <div146      ref={rowEl}147      class={`peach-note-row${props.fadingClass ? " " + props.fadingClass : ""}`}148      style={(() => {149        const dx = swipeX();150        if (dx === 0) return undefined;151        return { transform: `translateX(${dx}px)`, transition: swiping() ? "none" : "transform 250ms ease" };152      })()}153      onTouchStart={handleTouchStart}154      onTouchMove={handleTouchMove}155      onTouchEnd={handleTouchEnd}156      onTouchCancel={handleTouchCancel}157      onClick={() => {158        if (!("ontouchstart" in window)) {159          props.onTap(props.note, rowEl);160        }161      }}162    >163      {/* Delete underlay */}164      <div class="peach-note-row-underlay peach-note-row-underlay--delete" style={{ opacity: Math.min(1, Math.max(0, -swipeX() / SWIPE_THRESHOLD)) }}>165        DELETE166      </div>167      {/* Archive underlay */}168      <div class="peach-note-row-underlay peach-note-row-underlay--archive" style={{ opacity: Math.min(1, Math.max(0, swipeX() / SWIPE_THRESHOLD)) }}>169        ARCHIVE170      </div>171172      <span class="peach-note-numeral">{toRoman(props.index + 1)}.</span>173      <span class="peach-note-title-gap" />174      {renderTitle()}175      <span class="peach-note-date">{formatNoteDate(props.note.modifiedAt)}</span>176177      {confirmDelete() && (178        <div class="peach-note-delete-confirm">179          <span>Move to trash?</span>180          <button class="peach-note-delete-confirm-btn" onClick={(e) => {181            e.stopPropagation();182            vibrate(60);183            props.onDelete(props.note);184            setConfirmDelete(false);185          }}>Delete</button>186          <button class="peach-note-delete-cancel-btn" onClick={(e) => {187            e.stopPropagation();188            setConfirmDelete(false);189          }}>Cancel</button>190        </div>191      )}192    </div>193  );194}