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 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 74 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 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 100 vibrate(40);101 setConfirmDelete(true);102 setSwipeX(0);103 setTimeout(() => setConfirmDelete(false), 4000);104 } else if (dx > SWIPE_CONFIRM_THRESHOLD) {105 106 vibrate(12);107 props.onArchive(props.note);108 setSwipeX(0);109 } else {110 111 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 125 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 {}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 {}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}