From 532dc61dfed7f6f7032793be2aa968ffdef07a0c Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 15 May 2026 17:32:57 +0100 Subject: [PATCH 01/20] Add address book storage utility with localStorage --- frontend/src/lib/addressBook.js | 167 ++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 frontend/src/lib/addressBook.js diff --git a/frontend/src/lib/addressBook.js b/frontend/src/lib/addressBook.js new file mode 100644 index 00000000..a955752c --- /dev/null +++ b/frontend/src/lib/addressBook.js @@ -0,0 +1,167 @@ +const STORAGE_KEY = 'tipstream_address_book'; + +export class AddressBookEntry { + constructor({ id, label, address, notes = '', createdAt = Date.now(), updatedAt = Date.now() }) { + this.id = id || crypto.randomUUID(); + this.label = label; + this.address = address; + this.notes = notes; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + toJSON() { + return { + id: this.id, + label: this.label, + address: this.address, + notes: this.notes, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } +} + +export class AddressBook { + constructor() { + this.entries = this.load(); + } + + load() { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) return []; + const parsed = JSON.parse(data); + return parsed.map(entry => new AddressBookEntry(entry)); + } catch (error) { + console.error('Failed to load address book:', error); + return []; + } + } + + save() { + try { + const data = JSON.stringify(this.entries.map(entry => entry.toJSON())); + localStorage.setItem(STORAGE_KEY, data); + return true; + } catch (error) { + console.error('Failed to save address book:', error); + return false; + } + } + + add(label, address, notes = '') { + if (!label || !address) { + throw new Error('Label and address are required'); + } + + if (this.findByAddress(address)) { + throw new Error('Address already exists in address book'); + } + + const entry = new AddressBookEntry({ label, address, notes }); + this.entries.push(entry); + this.save(); + return entry; + } + + update(id, updates) { + const index = this.entries.findIndex(entry => entry.id === id); + if (index === -1) { + throw new Error('Entry not found'); + } + + const entry = this.entries[index]; + if (updates.label !== undefined) entry.label = updates.label; + if (updates.address !== undefined) { + const existing = this.findByAddress(updates.address); + if (existing && existing.id !== id) { + throw new Error('Address already exists in address book'); + } + entry.address = updates.address; + } + if (updates.notes !== undefined) entry.notes = updates.notes; + entry.updatedAt = Date.now(); + + this.save(); + return entry; + } + + delete(id) { + const index = this.entries.findIndex(entry => entry.id === id); + if (index === -1) { + throw new Error('Entry not found'); + } + + this.entries.splice(index, 1); + this.save(); + return true; + } + + findById(id) { + return this.entries.find(entry => entry.id === id); + } + + findByAddress(address) { + return this.entries.find(entry => entry.address === address); + } + + search(query) { + if (!query) return this.entries; + + const lowerQuery = query.toLowerCase(); + return this.entries.filter(entry => + entry.label.toLowerCase().includes(lowerQuery) || + entry.address.toLowerCase().includes(lowerQuery) || + entry.notes.toLowerCase().includes(lowerQuery) + ); + } + + getAll() { + return [...this.entries]; + } + + exportData() { + return JSON.stringify(this.entries.map(entry => entry.toJSON()), null, 2); + } + + importData(jsonData) { + try { + const parsed = JSON.parse(jsonData); + if (!Array.isArray(parsed)) { + throw new Error('Invalid data format'); + } + + const imported = []; + const skipped = []; + + for (const item of parsed) { + if (!item.label || !item.address) { + skipped.push(item); + continue; + } + + if (this.findByAddress(item.address)) { + skipped.push(item); + continue; + } + + const entry = new AddressBookEntry(item); + this.entries.push(entry); + imported.push(entry); + } + + this.save(); + return { imported, skipped }; + } catch (error) { + throw new Error(`Import failed: ${error.message}`); + } + } + + clear() { + this.entries = []; + this.save(); + } +} + +export const addressBook = new AddressBook(); From bca85ffe8f20a71a9f7ea8b4340ede96622a1c21 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 15 May 2026 17:33:19 +0100 Subject: [PATCH 02/20] Add address validation utilities --- frontend/src/lib/addressValidation.js | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 frontend/src/lib/addressValidation.js diff --git a/frontend/src/lib/addressValidation.js b/frontend/src/lib/addressValidation.js new file mode 100644 index 00000000..3b5d6003 --- /dev/null +++ b/frontend/src/lib/addressValidation.js @@ -0,0 +1,42 @@ +const STACKS_ADDRESS_PATTERN = /^(SP|ST|SM)[0-9A-Z]{38,40}$/i; + +export function isValidStacksAddress(address) { + if (!address || typeof address !== 'string') { + return false; + } + return STACKS_ADDRESS_PATTERN.test(address.trim()); +} + +export function validateAddressBookEntry(label, address, notes = '') { + const errors = {}; + + if (!label || typeof label !== 'string') { + errors.label = 'Label is required'; + } else if (label.trim().length === 0) { + errors.label = 'Label cannot be empty'; + } else if (label.length > 50) { + errors.label = 'Label must be 50 characters or less'; + } + + if (!address || typeof address !== 'string') { + errors.address = 'Address is required'; + } else if (!isValidStacksAddress(address)) { + errors.address = 'Invalid Stacks address format'; + } + + if (notes && typeof notes === 'string' && notes.length > 200) { + errors.notes = 'Notes must be 200 characters or less'; + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + }; +} + +export function formatAddress(address, length = 8) { + if (!address || address.length <= length * 2) { + return address; + } + return `${address.slice(0, length)}...${address.slice(-length)}`; +} From a1b3873d800978cf885a08ae638731a55e07e28c Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 15 May 2026 17:34:03 +0100 Subject: [PATCH 03/20] Create main AddressBook component --- frontend/src/components/AddressBook.jsx | 189 ++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 frontend/src/components/AddressBook.jsx diff --git a/frontend/src/components/AddressBook.jsx b/frontend/src/components/AddressBook.jsx new file mode 100644 index 00000000..9c75b662 --- /dev/null +++ b/frontend/src/components/AddressBook.jsx @@ -0,0 +1,189 @@ +import { useState, useEffect } from 'react'; +import { addressBook } from '../lib/addressBook'; +import { validateAddressBookEntry, formatAddress } from '../lib/addressValidation'; +import AddressBookEntry from './AddressBookEntry'; +import AddressBookForm from './AddressBookForm'; +import AddressBookSearch from './AddressBookSearch'; +import AddressBookImportExport from './AddressBookImportExport'; + +export default function AddressBook({ onSelectAddress, compact = false }) { + const [entries, setEntries] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [showForm, setShowForm] = useState(false); + const [editingEntry, setEditingEntry] = useState(null); + const [showImportExport, setShowImportExport] = useState(false); + + useEffect(() => { + loadEntries(); + }, []); + + const loadEntries = () => { + setEntries(addressBook.getAll()); + }; + + const handleAdd = (label, address, notes) => { + try { + addressBook.add(label, address, notes); + loadEntries(); + setShowForm(false); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; + + const handleUpdate = (id, updates) => { + try { + addressBook.update(id, updates); + loadEntries(); + setEditingEntry(null); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; + + const handleDelete = (id) => { + if (window.confirm('Are you sure you want to delete this address?')) { + try { + addressBook.delete(id); + loadEntries(); + } catch (error) { + alert(`Failed to delete: ${error.message}`); + } + } + }; + + const handleSelect = (entry) => { + if (onSelectAddress) { + onSelectAddress(entry.address, entry.label); + } + }; + + const handleEdit = (entry) => { + setEditingEntry(entry); + setShowForm(true); + }; + + const handleCancelEdit = () => { + setEditingEntry(null); + setShowForm(false); + }; + + const handleImport = (result) => { + loadEntries(); + setShowImportExport(false); + alert(`Imported ${result.imported.length} addresses. Skipped ${result.skipped.length} duplicates.`); + }; + + const filteredEntries = searchQuery + ? addressBook.search(searchQuery) + : entries; + + if (compact) { + return ( +
+ +
+ {filteredEntries.length === 0 ? ( +

+ {searchQuery ? 'No addresses found' : 'No saved addresses'} +

+ ) : ( + filteredEntries.map(entry => ( + + )) + )} +
+
+ ); + } + + return ( +
+
+

Address Book

+
+ + +
+
+ + {showForm && ( + + )} + + {showImportExport && ( + setShowImportExport(false)} + /> + )} + + + +
+ {filteredEntries.length === 0 ? ( +
+ {searchQuery ? ( +

No addresses found matching "{searchQuery}"

+ ) : ( +
+

Your address book is empty

+

Add frequently used addresses for quick access

+
+ )} +
+ ) : ( + filteredEntries.map(entry => ( + handleSelect(entry) : null} + onEdit={() => handleEdit(entry)} + onDelete={() => handleDelete(entry.id)} + /> + )) + )} +
+ + {entries.length > 0 && ( +
+

{entries.length} address{entries.length !== 1 ? 'es' : ''} saved

+
+ )} +
+ ); +} From 9cf09de62e8c50aa7396a4a9dd81b6dda67d1733 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 15 May 2026 17:34:28 +0100 Subject: [PATCH 04/20] Add AddressBookEntry component for displaying entries --- frontend/src/components/AddressBookEntry.jsx | 67 ++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 frontend/src/components/AddressBookEntry.jsx diff --git a/frontend/src/components/AddressBookEntry.jsx b/frontend/src/components/AddressBookEntry.jsx new file mode 100644 index 00000000..7c79d656 --- /dev/null +++ b/frontend/src/components/AddressBookEntry.jsx @@ -0,0 +1,67 @@ +import { formatAddress } from '../lib/addressValidation'; + +export default function AddressBookEntry({ entry, onSelect, onEdit, onDelete }) { + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(entry.address); + alert('Address copied to clipboard'); + } catch (error) { + console.error('Failed to copy:', error); + } + }; + + const formattedDate = new Date(entry.updatedAt).toLocaleDateString(); + + return ( +
+
+
+

{entry.label}

+

+ {formatAddress(entry.address, 10)} +

+ {entry.notes && ( +

{entry.notes}

+ )} +

Updated: {formattedDate}

+
+
+ {onSelect && ( + + )} + + + +
+
+
+ ); +} From 1af4704fb521b9212b3e15f818129ebb0bcdb6fd Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 15 May 2026 19:01:03 +0100 Subject: [PATCH 05/20] Add AddressBookForm component for adding and editing entries --- frontend/src/components/AddressBookForm.jsx | 126 ++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 frontend/src/components/AddressBookForm.jsx diff --git a/frontend/src/components/AddressBookForm.jsx b/frontend/src/components/AddressBookForm.jsx new file mode 100644 index 00000000..24ee2c83 --- /dev/null +++ b/frontend/src/components/AddressBookForm.jsx @@ -0,0 +1,126 @@ +import { useState, useEffect } from 'react'; +import { validateAddressBookEntry } from '../lib/addressValidation'; + +export default function AddressBookForm({ entry, onSubmit, onCancel }) { + const [label, setLabel] = useState(''); + const [address, setAddress] = useState(''); + const [notes, setNotes] = useState(''); + const [errors, setErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (entry) { + setLabel(entry.label); + setAddress(entry.address); + setNotes(entry.notes || ''); + } + }, [entry]); + + const handleSubmit = async (e) => { + e.preventDefault(); + setErrors({}); + + const validation = validateAddressBookEntry(label.trim(), address.trim(), notes.trim()); + if (!validation.isValid) { + setErrors(validation.errors); + return; + } + + setIsSubmitting(true); + const result = entry + ? await onSubmit(entry.id, { + label: label.trim(), + address: address.trim(), + notes: notes.trim(), + }) + : await onSubmit(label.trim(), address.trim(), notes.trim()); + + setIsSubmitting(false); + + if (result.success) { + setLabel(''); + setAddress(''); + setNotes(''); + setErrors({}); + } else { + setErrors({ submit: result.error }); + } + }; + + return ( +
+

{entry ? 'Edit Address' : 'Add New Address'}

+ +
+ + setLabel(e.target.value)} + placeholder="e.g., Alice, Bob, Main Wallet" + maxLength={50} + disabled={isSubmitting} + className={errors.label ? 'error' : ''} + /> + {errors.label && {errors.label}} +
+ +
+ + setAddress(e.target.value)} + placeholder="SP..." + disabled={isSubmitting} + className={errors.address ? 'error' : ''} + /> + {errors.address && {errors.address}} +
+ +
+ +