NoteRow.tsx

5.1 KB · raw

1import { createSignal } 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;5253  function handleTouchStart(e: TouchEvent) {54    const t = e.touches[0];55    if (!t) return;56    touchStartX = t.clientX;57    touchStartY = t.clientY;58    captured = false;59  }6061  function handleTouchMove(e: TouchEvent) {62    const t = e.touches[0];63    if (!t) return;64    const dx = t.clientX - touchStartX;65    const dy = Math.abs(t.clientY - touchStartY);6667    if (!captured) {68      if (dy > 8 && dy > Math.abs(dx)) {69        // vertical scroll — abort70        return;71      }72      if (Math.abs(dx) > 8) {73        captured = true;74        setSwping(true);75      }76      return;77    }7879    setSwipeX(dx);80  }8182  function handleTouchEnd(_e: TouchEvent) {83    if (!captured) {84      // Tap85      props.onTap(props.note, rowEl);86      vibrate(12);87      setSwping(false);88      return;89    }9091    const dx = swipeX();92    setSwping(false);9394    if (dx < -SWIPE_CONFIRM_THRESHOLD) {95      // Committed delete swipe96      vibrate(40);97      setConfirmDelete(true);98      setSwipeX(0);99      setTimeout(() => setConfirmDelete(false), 4000);100    } else if (dx > SWIPE_CONFIRM_THRESHOLD) {101      // Archive102      vibrate(12);103      props.onArchive(props.note);104      setSwipeX(0);105    } else {106      // Snap back107      setSwipeX(0);108    }109    captured = false;110  }111112  function handleTouchCancel() {113    setSwipeX(0);114    setSwping(false);115    captured = false;116  }117118  // Highlight search match in title119  function renderTitle() {120    const title = props.note.title || "Untitled";121    const q = props.searchQuery?.trim().toLowerCase();122    if (!q) return <span class="peach-note-title">{title}</span>;123    const idx = title.toLowerCase().indexOf(q);124    if (idx === -1) return <span class="peach-note-title">{title}</span>;125    return (126      <span class="peach-note-title">127        {title.slice(0, idx)}128        <span class="peach-note-title-match">{title.slice(idx, idx + q.length)}</span>129        {title.slice(idx + q.length)}130      </span>131    );132  }133134  return (135    <div136      ref={rowEl}137      class={`peach-note-row${props.fadingClass ? " " + props.fadingClass : ""}`}138      style={(() => {139        const dx = swipeX();140        if (dx === 0) return undefined;141        return { transform: `translateX(${dx}px)`, transition: swiping() ? "none" : "transform 250ms ease" };142      })()}143      onTouchStart={handleTouchStart}144      onTouchMove={handleTouchMove}145      onTouchEnd={handleTouchEnd}146      onTouchCancel={handleTouchCancel}147      onClick={() => {148        if (!("ontouchstart" in window)) {149          props.onTap(props.note, rowEl);150        }151      }}152    >153      {/* Delete underlay */}154      <div class="peach-note-row-underlay peach-note-row-underlay--delete" style={{ opacity: Math.min(1, Math.max(0, -swipeX() / SWIPE_THRESHOLD)) }}>155        DELETE156      </div>157      {/* Archive underlay */}158      <div class="peach-note-row-underlay peach-note-row-underlay--archive" style={{ opacity: Math.min(1, Math.max(0, swipeX() / SWIPE_THRESHOLD)) }}>159        ARCHIVE160      </div>161162      <span class="peach-note-numeral">{toRoman(props.index + 1)}.</span>163      <span class="peach-note-title-gap" />164      {renderTitle()}165      <span class="peach-note-date">{formatNoteDate(props.note.modifiedAt)}</span>166167      {confirmDelete() && (168        <div class="peach-note-delete-confirm">169          <span>Move to trash?</span>170          <button class="peach-note-delete-confirm-btn" onClick={(e) => {171            e.stopPropagation();172            vibrate(60);173            props.onDelete(props.note);174            setConfirmDelete(false);175          }}>Delete</button>176          <button class="peach-note-delete-cancel-btn" onClick={(e) => {177            e.stopPropagation();178            setConfirmDelete(false);179          }}>Cancel</button>180        </div>181      )}182    </div>183  );184}