1import { createSignal } from "solid-js";2import type { VaultEntry } from "../../shared/types";34function vibrate(ms: number) {5 try { navigator.vibrate?.(ms); } catch { }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 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 76 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 94 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 105 vibrate(40);106 setConfirmDelete(true);107 setSwipeX(0);108 setTimeout(() => setConfirmDelete(false), 4000);109 } else if (dx > SWIPE_CONFIRM_THRESHOLD) {110 111 vibrate(12);112 props.onArchive(props.note);113 setSwipeX(0);114 } else {115 116 setSwipeX(0);117 }118 captured = false;119 }120121 function handleTouchCancel() {122 clearTimeout(longPressTimer);123 setSwipeX(0);124 setSwping(false);125 captured = false;126 }127128 129 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 {}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 {}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}