diff --git a/web/src/App.tsx b/web/src/App.tsx index f063bfd..a94215e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import Notes from './components/Notes'; import Lists from './components/Lists'; import Journals from './components/Journals'; import Boards from './components/Boards'; +import Contacts from './components/Contacts'; import { isAuthenticated, clearAuthState, getUsername } from './lib/auth'; import { onSessionExpired } from './lib/api'; import { startSessionMonitoring, stopSessionMonitoring } from './lib/sessionManager'; @@ -74,6 +75,12 @@ function Layout({ children }: { children: React.ReactNode }) { > 📋 Boards + + 👤 Contacts +
@@ -154,6 +161,15 @@ function App() { } /> + + + + + } + /> } /> diff --git a/web/src/components/Contacts.css b/web/src/components/Contacts.css new file mode 100644 index 0000000..66a8774 --- /dev/null +++ b/web/src/components/Contacts.css @@ -0,0 +1,863 @@ +.contacts-container { + display: flex; + height: 100%; + background: var(--color-bg); +} + +/* === SHARED SIDEBAR STYLES === */ +.contacts-sidebar { + width: 280px; + background: var(--color-bg-white); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; +} + +.sidebar-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.btn-new { + padding: var(--spacing-sm) 14px; + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: var(--color-bg-white); + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-new:hover { + background: var(--color-primary-hover); +} + +.sidebar-actions { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + display: flex; + gap: var(--spacing-sm); +} + +.btn-export, +.btn-import { + flex: 1; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + background: var(--color-bg-white); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 400; + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.btn-export:hover, +.btn-import:hover { + border-color: var(--color-primary); + background: var(--color-primary-light); +} + +.view-filters { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.filter-checkbox { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; +} + +.filter-checkbox input[type="checkbox"] { + cursor: pointer; + width: 16px; + height: 16px; + accent-color: var(--color-primary); +} + +/* === GROUP MANAGEMENT === */ +.new-group-form { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); +} + +.group-title-input { + width: 100%; + padding: var(--spacing-sm); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + margin-bottom: var(--spacing-sm); + font-family: var(--font-family); +} + +.group-title-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.form-actions { + display: flex; + gap: var(--spacing-sm); +} + +.btn-save-small, +.btn-cancel-small { + flex: 1; + padding: 6px var(--spacing-md); + border: none; + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-save-small { + background: var(--color-primary); + color: var(--color-bg-white); +} + +.btn-save-small:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-save-small:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-cancel-small { + background: #e5e7eb; + color: var(--color-text-secondary); +} + +.btn-cancel-small:hover:not(:disabled) { + background: #d1d5db; +} + +/* === GROUPS LIST === */ +.groups-list { + flex: 1; + overflow-y: auto; +} + +.group-item { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-light); + cursor: pointer; + transition: background var(--transition-fast); +} + +.group-item:hover { + background: var(--color-bg-hover); +} + +.group-item.active { + background: var(--color-bg-active); + border-left: 3px solid var(--color-primary); +} + +.group-item.archived { + opacity: 0.6; +} + +.group-title { + font-weight: 500; + color: var(--color-text-primary); + margin-bottom: var(--spacing-xs); + font-size: var(--font-size-base); + display: flex; + align-items: center; + gap: var(--spacing-xs); +} + +.archived-badge { + font-size: 0.75rem; +} + +.group-count { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); +} + +/* === MIDDLE PANEL - CONTACTS LIST === */ +.contacts-middle { + width: 320px; + background: var(--color-bg-white); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; +} + +.middle-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.group-header-info { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.middle-header h2 { + margin: 0; + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-text-primary); +} + +.group-archived-label { + font-size: 0.75rem; + padding: 2px var(--spacing-sm); + background: #e5e7eb; + color: var(--color-text-secondary); + border-radius: var(--radius-pill); + font-weight: 500; +} + +.group-actions { + display: flex; + gap: 6px; +} + +.btn-icon, +.btn-icon-danger { + padding: 6px 10px; + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + background: var(--color-bg-white); + font-size: var(--font-size-sm); + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.btn-icon:hover { + border-color: var(--color-primary); + background: var(--color-primary-light); +} + +.btn-icon-danger:hover { + border-color: var(--color-danger); + background: var(--color-danger-light); +} + +.rename-group-form { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.rename-group-input { + width: 100%; + padding: var(--spacing-sm); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + font-family: var(--font-family); +} + +.rename-group-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.rename-actions { + display: flex; + gap: 6px; +} + +.btn-save-rename, +.btn-cancel-rename { + flex: 1; + padding: 6px var(--spacing-md); + border: none; + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: 500; + cursor: pointer; +} + +.btn-save-rename { + background: var(--color-primary); + color: var(--color-bg-white); +} + +.btn-save-rename:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-cancel-rename { + background: #e5e7eb; + color: var(--color-text-secondary); +} + +.btn-cancel-rename:hover:not(:disabled) { + background: #d1d5db; +} + +.middle-actions { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.btn-new-contact { + width: 100%; + padding: var(--spacing-sm) 14px; + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: var(--color-bg-white); + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-new-contact:hover { + background: var(--color-primary-hover); +} + +/* === SEARCH BAR === */ +.search-bar { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.search-input { + width: 100%; + padding: var(--spacing-sm); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-family: var(--font-family); + transition: border-color var(--transition-fast); +} + +.search-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.search-input::placeholder { + color: var(--color-text-disabled); +} + +/* === CONTACTS LIST === */ +.contacts-list { + flex: 1; + overflow-y: auto; +} + +.contact-item { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-light); + cursor: pointer; + transition: background var(--transition-fast); +} + +.contact-item:hover { + background: var(--color-bg-hover); +} + +.contact-item.active { + background: var(--color-bg-active); + border-left: 3px solid var(--color-primary); +} + +.contact-item.archived { + opacity: 0.6; +} + +.contact-header { + margin-bottom: 6px; +} + +.contact-name { + font-weight: 500; + color: var(--color-text-primary); + font-size: var(--font-size-base); + display: flex; + align-items: center; + gap: var(--spacing-xs); +} + +.favorite-icon, +.archived-icon { + font-size: 0.75rem; +} + +.contact-preview { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); + margin-bottom: 4px; + line-height: 1.4; +} + +.birthday-soon { + color: var(--color-primary); + font-weight: 500; +} + +/* === BIRTHDAY VIEW === */ +.birthdays-list { + flex: 1; + overflow-y: auto; + padding: var(--spacing-md) 0; +} + +.birthday-section { + margin-bottom: var(--spacing-lg); +} + +.birthday-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-sm) var(--spacing-lg); + background: var(--color-bg); + border-top: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--color-border-light); +} + +.birthday-section-header h3 { + margin: 0; + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.birthday-count { + font-size: var(--font-size-xs); + padding: 2px 8px; + background: var(--color-primary); + color: var(--color-bg-white); + border-radius: var(--radius-pill); + font-weight: 500; +} + +.birthday-contacts { + display: flex; + flex-direction: column; +} + +.birthday-contact-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-light); + cursor: pointer; + transition: background var(--transition-fast); +} + +.birthday-contact-item:hover { + background: var(--color-bg-hover); +} + +.birthday-contact-item.active { + background: var(--color-bg-active); + border-left: 3px solid var(--color-primary); +} + +.birthday-contact-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.birthday-contact-name { + font-weight: 500; + color: var(--color-text-primary); + font-size: var(--font-size-base); + display: flex; + align-items: center; + gap: var(--spacing-xs); +} + +.birthday-contact-date { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); +} + +.birthday-days-until { + margin-left: var(--spacing-md); +} + +.days-badge { + font-size: var(--font-size-xs); + padding: 4px 10px; + background: var(--color-bg); + color: var(--color-text-secondary); + border-radius: var(--radius-pill); + font-weight: 500; + white-space: nowrap; +} + +.days-badge.today { + background: var(--color-primary); + color: var(--color-bg-white); + animation: pulse 2s ease-in-out infinite; +} + +.days-badge.tomorrow { + background: var(--color-primary-light); + color: var(--color-primary); +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +/* === CONTACT PREVIEW SECTION === */ + +/* === RIGHT PANEL - CONTACT DETAILS === */ +.contacts-content { + flex: 1; + display: flex; + flex-direction: column; + background: var(--color-bg-white); +} + +.editor-header { + padding: var(--spacing-lg) var(--spacing-xl); + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); +} + +.contact-actions-left { + display: flex; + gap: var(--spacing-sm); +} + +.btn-primary { + padding: 9px 18px; + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: var(--color-bg-white); + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + padding: 9px 18px; + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + background: var(--color-bg-white); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: border-color var(--transition-fast), color var(--transition-fast), background var(--transition-fast); +} + +.btn-secondary:hover:not(:disabled) { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.btn-secondary.active { + border-color: var(--color-primary); + background: var(--color-primary-light); + color: var(--color-primary); +} + +.btn-danger { + padding: 9px 18px; + border: none; + border-radius: var(--radius-sm); + background: var(--color-danger); + color: var(--color-bg-white); + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-danger:hover { + background: var(--color-danger-hover); +} + +.editor { + flex: 1; + display: flex; + flex-direction: column; + padding: var(--spacing-2xl); + overflow-y: auto; +} + +.name-input { + border: none; + font-size: var(--font-size-2xl); + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--spacing-xl); + padding: 0; + outline: none; + background: transparent; + font-family: var(--font-family); +} + +.name-input::placeholder { + color: var(--color-border-dark); +} + +.contact-fields { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); +} + +.field-group { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.field-group label { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text-secondary); +} + +.field-input { + padding: var(--spacing-md); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + font-family: var(--font-family); + transition: border-color var(--transition-fast); +} + +.field-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.field-input::placeholder { + color: var(--color-text-disabled); +} + +.field-textarea { + padding: var(--spacing-md); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + font-family: var(--font-family); + line-height: 1.6; + resize: vertical; + transition: border-color var(--transition-fast); +} + +.field-textarea:focus { + outline: none; + border-color: var(--color-primary); +} + +.field-textarea::placeholder { + color: var(--color-text-disabled); +} + +.viewer { + flex: 1; + padding: var(--spacing-2xl); + overflow-y: auto; +} + +.view-header { + margin-bottom: var(--spacing-xl); +} + +.view-name { + font-size: var(--font-size-2xl); + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 10px 0; +} + +.view-badges { + display: flex; + gap: var(--spacing-sm); +} + +.badge-favorite, +.badge-archived { + font-size: 0.75rem; + padding: var(--spacing-xs) 10px; + border-radius: var(--radius-pill); + font-weight: 500; +} + +.badge-favorite { + background: #fef3c7; + color: #92400e; +} + +.badge-archived { + background: #e5e7eb; + color: var(--color-text-secondary); +} + +.view-details { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.detail-row { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + padding-bottom: var(--spacing-lg); + border-bottom: 1px solid var(--color-border-light); +} + +.detail-row:last-child { + border-bottom: none; +} + +.detail-label { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text-secondary); +} + +.detail-value { + font-size: var(--font-size-base); + color: var(--color-text-primary); + line-height: 1.6; +} + +.detail-value a { + color: var(--color-primary); + text-decoration: none; + transition: color var(--transition-fast); +} + +.detail-value a:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +.detail-value.notes-content { + white-space: pre-wrap; +} + +.birthday-info { + color: var(--color-text-tertiary); + font-size: var(--font-size-sm); +} + +.birthday-upcoming { + margin-top: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: #fef3c7; + color: #92400e; + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-weight: 500; +} + +.no-details { + padding: var(--spacing-2xl) 0; + text-align: center; + color: var(--color-text-tertiary); + font-size: var(--font-size-base); +} + +/* === EMPTY STATES === */ +.empty-state { + padding: 40px var(--spacing-xl); + text-align: center; + color: var(--color-text-tertiary); + font-size: var(--font-size-base); +} + +.empty-state-center { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; +} + +.empty-icon { + font-size: 3.5rem; + margin-bottom: var(--spacing-lg); + opacity: 0.5; +} + +.empty-state-center h3 { + margin: 0 0 var(--spacing-lg) 0; + color: var(--color-text-secondary); + font-weight: 500; + font-size: var(--font-size-lg); + text-align: center; +} + +.error-message { + margin: 0 var(--spacing-xl) var(--spacing-xl) var(--spacing-xl); + padding: var(--spacing-md) 14px; + background: var(--color-danger-light); + border: 1px solid #fecaca; + border-radius: var(--radius-sm); + color: var(--color-danger); + font-size: var(--font-size-sm); +} + +.loading { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + color: var(--color-text-tertiary); +} diff --git a/web/src/components/Contacts.tsx b/web/src/components/Contacts.tsx new file mode 100644 index 0000000..94ae6b8 --- /dev/null +++ b/web/src/components/Contacts.tsx @@ -0,0 +1,1208 @@ +import { useState, useEffect, useCallback } from 'react'; +import { loadAuthState } from '../lib/auth'; +import { encryptBlob, decryptBlob } from '../lib/crypto'; +import { upsertBlob, getBlob } from '../lib/api'; +import './Contacts.css'; + +type Contact = { + id: string; + name: string; + email?: string; + phone?: string; + address?: string; + birthday?: string; // YYYY-MM-DD + notes?: string; + favorite?: boolean; + archived?: boolean; + createdAt: number; + updatedAt: number; +}; + +type ContactGroup = { + id: string; + title: string; + createdAt: number; + updatedAt: number; + archived?: boolean; + contacts: Contact[]; +}; + +type ContactsData = { + groups: ContactGroup[]; +}; + +const BLOB_NAME = 'contacts'; + +export default function Contacts() { + const [groups, setGroups] = useState([]); + const [selectedGroup, setSelectedGroup] = useState(null); + const [selectedContact, setSelectedContact] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + // Group management + const [isCreatingGroup, setIsCreatingGroup] = useState(false); + const [newGroupTitle, setNewGroupTitle] = useState(''); + const [isRenamingGroup, setIsRenamingGroup] = useState(false); + const [renameGroupTitle, setRenameGroupTitle] = useState(''); + + // Contact editing + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(''); + const [editEmail, setEditEmail] = useState(''); + const [editPhone, setEditPhone] = useState(''); + const [editAddress, setEditAddress] = useState(''); + const [editBirthday, setEditBirthday] = useState(''); + const [editNotes, setEditNotes] = useState(''); + + // View filters + const [showArchived, setShowArchived] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [showUpcomingBirthdays, setShowUpcomingBirthdays] = useState(false); + + const loadGroups = useCallback(async () => { + setLoading(true); + setError(''); + + try { + const authState = loadAuthState(); + if (!authState) throw new Error('Not authenticated'); + + try { + const response = await getBlob(authState.token, BLOB_NAME); + const decrypted = await decryptBlob( + response.encryptedBlob, + authState.accountKey, + BLOB_NAME + ); + + const data: ContactsData = JSON.parse(decrypted); + const sorted = (data.groups || []).sort((a, b) => b.updatedAt - a.updatedAt); + setGroups(sorted); + } catch (err) { + const error = err as { status?: number }; + if (error.status === 404) { + setGroups([]); + } else { + throw err; + } + } + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to load contacts'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadGroups(); + }, [loadGroups]); + + const saveGroups = async (updatedGroups: ContactGroup[]) => { + const authState = loadAuthState(); + if (!authState) throw new Error('Not authenticated'); + + const data: ContactsData = { groups: updatedGroups }; + const encrypted = await encryptBlob( + data, + authState.accountKey, + BLOB_NAME + ); + + await upsertBlob(authState.token, BLOB_NAME, { + encryptedBlob: encrypted, + }); + }; + + // Group operations + const handleCreateGroup = () => { + setIsCreatingGroup(true); + setNewGroupTitle(''); + }; + + const handleSaveNewGroup = async () => { + if (!newGroupTitle.trim()) { + setError('Group title is required'); + return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const newGroup: ContactGroup = { + id: crypto.randomUUID(), + title: newGroupTitle, + contacts: [], + createdAt: now, + updatedAt: now, + }; + + const updatedGroups = [newGroup, ...groups]; + await saveGroups(updatedGroups); + setGroups(updatedGroups); + setSelectedGroup(newGroup); + setIsCreatingGroup(false); + setNewGroupTitle(''); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to create group'); + } finally { + setSaving(false); + } + }; + + const handleCancelNewGroup = () => { + setIsCreatingGroup(false); + setNewGroupTitle(''); + }; + + const handleSelectGroup = (group: ContactGroup) => { + setSelectedGroup(group); + setSelectedContact(null); + setIsEditing(false); + setSearchQuery(''); + }; + + const handleStartRenameGroup = () => { + if (!selectedGroup) return; + setIsRenamingGroup(true); + setRenameGroupTitle(selectedGroup.title); + }; + + const handleSaveRenameGroup = async () => { + if (!selectedGroup) return; + if (!renameGroupTitle.trim()) { + setError('Group title is required'); + return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedGroup = { + ...selectedGroup, + title: renameGroupTitle, + updatedAt: now, + }; + + const updatedGroups = groups.map((g) => + g.id === selectedGroup.id ? updatedGroup : g + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveGroups(updatedGroups); + setGroups(updatedGroups); + setSelectedGroup(updatedGroup); + setIsRenamingGroup(false); + setRenameGroupTitle(''); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to rename group'); + } finally { + setSaving(false); + } + }; + + const handleCancelRenameGroup = () => { + setIsRenamingGroup(false); + setRenameGroupTitle(''); + }; + + const handleToggleArchiveGroup = async () => { + if (!selectedGroup) return; + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedGroup = { + ...selectedGroup, + archived: !selectedGroup.archived, + updatedAt: now, + }; + + const updatedGroups = groups.map((g) => + g.id === selectedGroup.id ? updatedGroup : g + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveGroups(updatedGroups); + setGroups(updatedGroups); + setSelectedGroup(updatedGroup); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to archive group'); + } finally { + setSaving(false); + } + }; + + const handleDeleteGroup = async () => { + if (!selectedGroup) return; + if (!confirm(`Delete group "${selectedGroup.title}"? This will delete all contacts inside.`)) return; + + setSaving(true); + setError(''); + + try { + const updatedGroups = groups.filter((g) => g.id !== selectedGroup.id); + await saveGroups(updatedGroups); + setGroups(updatedGroups); + setSelectedGroup(null); + setSelectedContact(null); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to delete group'); + } finally { + setSaving(false); + } + }; + + // Contact operations + const handleCreateContact = () => { + if (!selectedGroup) return; + setIsEditing(true); + setSelectedContact(null); + setEditName(''); + setEditEmail(''); + setEditPhone(''); + setEditAddress(''); + setEditBirthday(''); + setEditNotes(''); + }; + + const handleSelectContact = (contact: Contact) => { + setSelectedContact(contact); + setEditName(contact.name); + setEditEmail(contact.email || ''); + setEditPhone(contact.phone || ''); + setEditAddress(contact.address || ''); + setEditBirthday(contact.birthday || ''); + setEditNotes(contact.notes || ''); + setIsEditing(false); + }; + + const handleEdit = () => { + setIsEditing(true); + }; + + const handleSaveContact = async () => { + if (!selectedGroup) return; + if (!editName.trim()) { + setError('Contact name is required'); + return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + let updatedContacts: Contact[]; + + if (selectedContact) { + // Update existing contact + updatedContacts = selectedGroup.contacts.map((contact) => + contact.id === selectedContact.id + ? { + ...contact, + name: editName, + email: editEmail || undefined, + phone: editPhone || undefined, + address: editAddress || undefined, + birthday: editBirthday || undefined, + notes: editNotes || undefined, + updatedAt: now, + } + : contact + ); + } else { + // Create new contact + const newContact: Contact = { + id: crypto.randomUUID(), + name: editName, + email: editEmail || undefined, + phone: editPhone || undefined, + address: editAddress || undefined, + birthday: editBirthday || undefined, + notes: editNotes || undefined, + createdAt: now, + updatedAt: now, + }; + updatedContacts = [newContact, ...selectedGroup.contacts]; + } + + const updatedGroup = { + ...selectedGroup, + contacts: updatedContacts, + updatedAt: now, + }; + + const updatedGroups = groups.map((g) => + g.id === selectedGroup.id ? updatedGroup : g + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveGroups(updatedGroups); + setGroups(updatedGroups); + setSelectedGroup(updatedGroup); + + // Select the saved contact + const savedContact = updatedContacts.find((c) => c.name === editName); + if (savedContact) { + setSelectedContact(savedContact); + } + setIsEditing(false); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to save contact'); + } finally { + setSaving(false); + } + }; + + const handleCancelEdit = () => { + if (selectedContact) { + setEditName(selectedContact.name); + setEditEmail(selectedContact.email || ''); + setEditPhone(selectedContact.phone || ''); + setEditAddress(selectedContact.address || ''); + setEditBirthday(selectedContact.birthday || ''); + setEditNotes(selectedContact.notes || ''); + setIsEditing(false); + } else { + setEditName(''); + setEditEmail(''); + setEditPhone(''); + setEditAddress(''); + setEditBirthday(''); + setEditNotes(''); + setIsEditing(false); + } + }; + + const handleToggleFavoriteContact = async () => { + if (!selectedGroup || !selectedContact) return; + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedContacts = selectedGroup.contacts.map((contact) => + contact.id === selectedContact.id + ? { ...contact, favorite: !contact.favorite, updatedAt: now } + : contact + ); + + const updatedGroup = { + ...selectedGroup, + contacts: updatedContacts, + updatedAt: now, + }; + + const updatedGroups = groups.map((g) => + g.id === selectedGroup.id ? updatedGroup : g + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveGroups(updatedGroups); + setGroups(updatedGroups); + setSelectedGroup(updatedGroup); + + const updatedContact = updatedContacts.find((c) => c.id === selectedContact.id); + if (updatedContact) { + setSelectedContact(updatedContact); + } + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to favorite contact'); + } finally { + setSaving(false); + } + }; + + const handleToggleArchiveContact = async () => { + if (!selectedGroup || !selectedContact) return; + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedContacts = selectedGroup.contacts.map((contact) => + contact.id === selectedContact.id + ? { ...contact, archived: !contact.archived, updatedAt: now } + : contact + ); + + const updatedGroup = { + ...selectedGroup, + contacts: updatedContacts, + updatedAt: now, + }; + + const updatedGroups = groups.map((g) => + g.id === selectedGroup.id ? updatedGroup : g + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveGroups(updatedGroups); + setGroups(updatedGroups); + setSelectedGroup(updatedGroup); + + const updatedContact = updatedContacts.find((c) => c.id === selectedContact.id); + if (updatedContact) { + setSelectedContact(updatedContact); + } + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to archive contact'); + } finally { + setSaving(false); + } + }; + + const handleDeleteContact = async () => { + if (!selectedGroup || !selectedContact) return; + if (!confirm('Delete this contact?')) return; + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedContacts = selectedGroup.contacts.filter((contact) => contact.id !== selectedContact.id); + + const updatedGroup = { + ...selectedGroup, + contacts: updatedContacts, + updatedAt: now, + }; + + const updatedGroups = groups.map((g) => + g.id === selectedGroup.id ? updatedGroup : g + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveGroups(updatedGroups); + setGroups(updatedGroups); + setSelectedGroup(updatedGroup); + setSelectedContact(null); + setIsEditing(false); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to delete contact'); + } finally { + setSaving(false); + } + }; + + // Export/Import + const handleExport = () => { + try { + const exportData: ContactsData = { groups }; + const dataStr = JSON.stringify(exportData, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + + const link = document.createElement('a'); + link.href = url; + link.download = `contacts-export-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to export contacts'); + } + }; + + const handleImport = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + + input.onchange = async (e: Event) => { + const target = e.target as HTMLInputElement; + const file = target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const importData: ContactsData = JSON.parse(text); + + if (!importData.groups || !Array.isArray(importData.groups)) { + throw new Error('Invalid contacts data format'); + } + + if (!confirm(`Import ${importData.groups.length} groups? This will replace all existing data.`)) { + return; + } + + setSaving(true); + setError(''); + + const sorted = importData.groups.sort((a, b) => b.updatedAt - a.updatedAt); + await saveGroups(sorted); + setGroups(sorted); + setSelectedGroup(null); + setSelectedContact(null); + setIsEditing(false); + + alert(`Successfully imported ${sorted.length} groups!`); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to import contacts'); + } finally { + setSaving(false); + } + }; + + input.click(); + }; + + // Birthday helper functions + const getDaysUntilBirthday = (birthday: string): number => { + const today = new Date(); + const [, month, day] = birthday.split('-').map(Number); + + // Create birthday date for this year + let nextBirthday = new Date(today.getFullYear(), month - 1, day); + + // If birthday already passed this year, use next year + if (nextBirthday < today) { + nextBirthday = new Date(today.getFullYear() + 1, month - 1, day); + } + + // Calculate days difference + const diffTime = nextBirthday.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + return diffDays; + }; + + const getAge = (birthday: string): number => { + const today = new Date(); + const [year, month, day] = birthday.split('-').map(Number); + const birthDate = new Date(year, month - 1, day); + + let age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + + return age; + }; + + const getNextAge = (birthday: string): number => { + return getAge(birthday) + 1; + }; + + const formatBirthday = (birthday: string): string => { + const [year, month, day] = birthday.split('-').map(Number); + const date = new Date(year, month - 1, day); + return date.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); + }; + + const formatBirthdayShort = (birthday: string): string => { + const [, month, day] = birthday.split('-').map(Number); + const date = new Date(2000, month - 1, day); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }; + + const groupContactsByUpcomingBirthday = (contacts: Contact[]): Map => { + const grouped = new Map(); + const withBirthdays = contacts.filter(c => c.birthday); + + // Sort by days until birthday + const sorted = withBirthdays.sort((a, b) => { + if (!a.birthday || !b.birthday) return 0; + return getDaysUntilBirthday(a.birthday) - getDaysUntilBirthday(b.birthday); + }); + + // Group by time periods + sorted.forEach(contact => { + if (!contact.birthday) return; + + const daysUntil = getDaysUntilBirthday(contact.birthday); + let groupKey: string; + + if (daysUntil === 0) { + groupKey = 'Today'; + } else if (daysUntil === 1) { + groupKey = 'Tomorrow'; + } else if (daysUntil <= 7) { + groupKey = 'This Week'; + } else if (daysUntil <= 30) { + groupKey = 'This Month'; + } else if (daysUntil <= 60) { + groupKey = 'Next Month'; + } else if (daysUntil <= 90) { + groupKey = 'Next 3 Months'; + } else { + groupKey = 'Later'; + } + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(contact); + }); + + return grouped; + }; + + // Helper functions + const getVisibleGroups = () => { + if (showArchived) { + return groups; + } + return groups.filter(g => !g.archived); + }; + + const getVisibleContacts = () => { + if (!selectedGroup) return []; + + let contacts = showArchived ? selectedGroup.contacts : selectedGroup.contacts.filter(c => !c.archived); + + // Filter by search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + contacts = contacts.filter(c => + c.name.toLowerCase().includes(query) || + c.email?.toLowerCase().includes(query) || + c.phone?.toLowerCase().includes(query) + ); + } + + // Sort: favorites first, then by name (or birthday if in birthday view) + return contacts.sort((a, b) => { + if (a.favorite && !b.favorite) return -1; + if (!a.favorite && b.favorite) return 1; + + // If showing upcoming birthdays, sort by days until birthday + if (showUpcomingBirthdays) { + if (a.birthday && !b.birthday) return -1; + if (!a.birthday && b.birthday) return 1; + if (a.birthday && b.birthday) { + return getDaysUntilBirthday(a.birthday) - getDaysUntilBirthday(b.birthday); + } + } + + return a.name.localeCompare(b.name); + }); + }; + + const getAllContactsWithBirthdays = (): Contact[] => { + const allContacts: Contact[] = []; + groups.forEach(group => { + if (!showArchived && group.archived) return; + group.contacts.forEach(contact => { + if (!showArchived && contact.archived) return; + if (contact.birthday) { + allContacts.push(contact); + } + }); + }); + return allContacts; + }; + + const getGroupContactsCount = (group: ContactGroup) => { + const activeContacts = group.contacts.filter(c => !c.archived); + return activeContacts.length; + }; + + if (loading) { + return ( +
+
Loading contacts...
+
+ ); + } + + const visibleGroups = getVisibleGroups(); + const visibleContacts = getVisibleContacts(); + + return ( +
+ {/* Left Sidebar - Groups */} +
+
+

Contacts

+ +
+ +
+ + +
+ +
+ + +
+ + {isCreatingGroup && ( +
+ setNewGroupTitle(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter' && newGroupTitle.trim()) { + handleSaveNewGroup(); + } + }} + autoFocus + /> +
+ + +
+
+ )} + +
+ {visibleGroups.length === 0 ? ( +
+ {showArchived ? 'No groups yet.' : 'No active groups. Create one!'} +
+ ) : ( + visibleGroups.map((group) => ( +
handleSelectGroup(group)} + > +
+ {group.archived && 📦} + {group.title} +
+
{getGroupContactsCount(group)} contacts
+
+ )) + )} +
+
+ + {/* Middle Panel - Contacts List or Birthday View */} + {showUpcomingBirthdays ? ( +
+
+
+

🎂 Upcoming Birthdays

+
+
+ +
+ {(() => { + const allContactsWithBirthdays = getAllContactsWithBirthdays(); + + if (allContactsWithBirthdays.length === 0) { + return ( +
+ No birthdays on record. Add birthdays to your contacts! +
+ ); + } + + const grouped = groupContactsByUpcomingBirthday(allContactsWithBirthdays); + const orderedKeys = ['Today', 'Tomorrow', 'This Week', 'This Month', 'Next Month', 'Next 3 Months', 'Later']; + + return orderedKeys.map(key => { + const contacts = grouped.get(key); + if (!contacts || contacts.length === 0) return null; + + return ( +
+
+

{key}

+ {contacts.length} +
+
+ {contacts.map(contact => ( +
handleSelectContact(contact)} + > +
+
+ {contact.favorite && } + {contact.name} +
+
+ {contact.birthday && ( + <> + {formatBirthdayShort(contact.birthday)} • Turning {getNextAge(contact.birthday)} + + )} +
+
+
+ {contact.birthday && getDaysUntilBirthday(contact.birthday) === 0 ? ( + Today! + ) : contact.birthday && getDaysUntilBirthday(contact.birthday) === 1 ? ( + Tomorrow + ) : ( + + {contact.birthday && `${getDaysUntilBirthday(contact.birthday)} days`} + + )} +
+
+ ))} +
+
+ ); + }); + })()} +
+
+ ) : selectedGroup ? ( +
+
+ {isRenamingGroup ? ( +
+ setRenameGroupTitle(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter' && renameGroupTitle.trim()) { + handleSaveRenameGroup(); + } + }} + autoFocus + /> +
+ + +
+
+ ) : ( + <> +
+

{selectedGroup.title}

+ {selectedGroup.archived && Archived} +
+
+ + + +
+ + )} +
+ +
+ +
+ +
+ setSearchQuery(e.target.value)} + /> +
+ +
+ {visibleContacts.length === 0 ? ( +
+ {searchQuery ? 'No contacts match your search.' : showArchived ? 'No contacts in this group.' : 'No active contacts. Create one!'} +
+ ) : ( + visibleContacts.map((contact) => ( +
handleSelectContact(contact)} + > +
+
+ {contact.favorite && } + {contact.archived && 📦} + {contact.name} +
+
+ {contact.email && ( +
✉️ {contact.email}
+ )} + {contact.phone && ( +
📱 {contact.phone}
+ )} + {contact.birthday && ( +
+ 🎂 {formatBirthdayShort(contact.birthday)} + {getDaysUntilBirthday(contact.birthday) <= 30 && ( + • {getDaysUntilBirthday(contact.birthday)} days + )} +
+ )} +
+ )) + )} +
+
+ ) : null} + + {/* Right Panel - Contact Details */} +
+ {selectedContact || isEditing ? ( + <> +
+ {isEditing ? ( + <> + + + + ) : ( + <> +
+ + + +
+ + + )} +
+ + {error &&
{error}
} + + {isEditing ? ( +
+ setEditName(e.target.value)} + /> +
+
+ + setEditEmail(e.target.value)} + /> +
+
+ + setEditPhone(e.target.value)} + /> +
+
+ + setEditBirthday(e.target.value)} + /> +
+
+ +