NoteRow.tsx

5.5 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;52  let suppressNextClick = false;53  let touchTapPending = false;5455  function handleTouchStart(e: TouchEvent) {56    const t = e.touches[0];57    if (!t) return;58    touchStartX = t.clientX;59    touchStartY = t.clientY;60    captured = false;61    suppressNextClick = false;62    touchTapPending = false;63  }6465  function handleTouchMove(e: TouchEvent) {66    const t = e.touches[0];67    if (!t) return;68    const dx = t.clientX - touchStartX;69    const dy = Math.abs(t.clientY - touchStartY);7071    if (!captured) {72      if (dy > 8 && dy > Math.abs(dx)) {73        // vertical scroll — abort74        return;75      }76      if (Math.abs(dx) > 8) {77        captured = true;78        setSwping(true);79      }80      return;81    }8283    setSwipeX(dx);84  }8586  function handleTouchEnd(_e: TouchEvent) {87    if (!captured) {88      // Let the browser's click event handle taps and mouse input alike.89      touchTapPending = true;90      setSwping(false);91      return;92    }9394    suppressNextClick = true;95    const dx = swipeX();96    setSwping(false);9798    if (dx < -SWIPE_CONFIRM_THRESHOLD) {99      // Committed delete swipe100      vibrate(40);101      setConfirmDelete(true);102      setSwipeX(0);103      setTimeout(() => setConfirmDelete(false), 4000);104    } else if (dx > SWIPE_CONFIRM_THRESHOLD) {105      // Archive106      vibrate(12);107      props.onArchive(props.note);108      setSwipeX(0);109    } else {110      // Snap back111      setSwipeX(0);112    }113    captured = false;114  }115116  function handleTouchCancel() {117    setSwipeX(0);118    setSwping(false);119    captured = false;120    suppressNextClick = false;121    touchTapPending = false;122  }123124  // Highlight search match in title125  function renderTitle() {126    const title = props.note.title || "Untitled";127    const q = props.searchQuery?.trim().toLowerCase();128    if (!q) return <span class="peach-note-title">{title}</span>;129    const idx = title.toLowerCase().indexOf(q);130    if (idx === -1) return <span class="peach-note-title">{title}</span>;131    return (132      <span class="peach-note-title">133        {title.slice(0, idx)}134        <span class="peach-note-title-match">{title.slice(idx, idx + q.length)}</span>135        {title.slice(idx + q.length)}136      </span>137    );138  }139140  return (141    <div142      ref={rowEl}143      class={`peach-note-row${props.fadingClass ? " " + props.fadingClass : ""}`}144      style={(() => {145        const dx = swipeX();146        if (dx === 0) return undefined;147        return { transform: `translateX(${dx}px)`, transition: swiping() ? "none" : "transform 250ms ease" };148      })()}149      onTouchStart={handleTouchStart}150      onTouchMove={handleTouchMove}151      onTouchEnd={handleTouchEnd}152      onTouchCancel={handleTouchCancel}153      onClick={() => {154        if (suppressNextClick) {155          suppressNextClick = false;156          return;157        }158        props.onTap(props.note, rowEl);159        if (touchTapPending) {160          touchTapPending = false;161          vibrate(12);162        }163      }}164    >165      {/* Delete underlay */}166      <div class="peach-note-row-underlay peach-note-row-underlay--delete" style={{ opacity: Math.min(1, Math.max(0, -swipeX() / SWIPE_THRESHOLD)) }}>167        DELETE168      </div>169      {/* Archive underlay */}170      <div class="peach-note-row-underlay peach-note-row-underlay--archive" style={{ opacity: Math.min(1, Math.max(0, swipeX() / SWIPE_THRESHOLD)) }}>171        ARCHIVE172      </div>173174      <span class="peach-note-numeral">{toRoman(props.index + 1)}.</span>175      <span class="peach-note-title-gap" />176      {renderTitle()}177      <span class="peach-note-date">{formatNoteDate(props.note.modifiedAt)}</span>178179      {confirmDelete() && (180        <div class="peach-note-delete-confirm">181          <span>Move to trash?</span>182          <button class="peach-note-delete-confirm-btn" onClick={(e) => {183            e.stopPropagation();184            vibrate(60);185            props.onDelete(props.note);186            setConfirmDelete(false);187          }}>Delete</button>188          <button class="peach-note-delete-cancel-btn" onClick={(e) => {189            e.stopPropagation();190            setConfirmDelete(false);191          }}>Cancel</button>192        </div>193      )}194    </div>195  );196}