Contacts UI: address + phone inputs, search filter, address-only adds

The contacts manager in Settings was stuck at name+email inline only —
no address field, no phone input on add, no search to find anything in
a list of 100+ contacts.

UI:
- Add form gets phone and address inputs alongside name/email. The
  email-required gate becomes name-OR-email so address/phone-only
  entries are creatable.
- Edit form gets an address input, threaded into the PUT body.
- Search input above the list filters client-side by name / emails /
  phones / address (debounced 80ms). Count badge shows N/M when a
  filter is active.

Backend:
- /api/contacts/{uid} PUT now accepts address and routes it through
  _update_contact (which already supports it after the previous
  commit). Validation loosened: name OR email OR address.
- /api/contacts/add POST now accepts phone + address. Phone goes
  through an immediate _update_contact since _create_contact's
  signature only takes name+email+address.
This commit is contained in:
pewdiepie-archdaemon
2026-06-11 09:23:14 +09:00
parent f42cee8512
commit 2049eb7713
2 changed files with 109 additions and 45 deletions
+32 -12
View File
@@ -735,16 +735,35 @@ def setup_contacts_routes():
"""Add a new contact."""
name = (data.get("name") or "").strip()
email = (data.get("email") or "").strip()
if not email:
return {"success": False, "error": "Email required"}
# Check if already exists
contacts = _fetch_contacts()
for c in contacts:
if email.lower() in [e.lower() for e in c["emails"]]:
return {"success": True, "message": "Already exists", "contact": c}
phone = (data.get("phone") or "").strip()
address = (data.get("address") or "").strip()
if not email and not name:
return {"success": False, "error": "Name or email required"}
# Check if already exists by email
if email:
contacts = _fetch_contacts()
for c in contacts:
if email.lower() in [e.lower() for e in c["emails"]]:
return {"success": True, "message": "Already exists", "contact": c}
if not name:
name = email.split("@")[0]
ok = _create_contact(name, email)
ok = _create_contact(name, email, address)
# If a phone was provided, do an immediate update to thread it
# through (the simple _create_contact signature only takes name +
# email + address; phones happen via update).
if ok and phone:
try:
fresh = _fetch_contacts(force=True)
created = next((c for c in fresh if name == c.get("name") and (not email or email in c.get("emails", []))), None)
if created:
_update_contact(
created["uid"], name,
created.get("emails", []),
[phone],
address,
)
except Exception:
pass
return {"success": ok}
@router.post("/import")
@@ -820,7 +839,7 @@ def setup_contacts_routes():
# match PUT /{uid} with uid="config".
@router.put("/{uid}")
async def edit_contact(uid: str, data: dict, _admin: str = Depends(require_admin)):
"""Edit an existing contact — name / emails / phones."""
"""Edit an existing contact — name / emails / phones / address."""
name = (data.get("name") or "").strip()
emails = data.get("emails")
phones = data.get("phones")
@@ -828,11 +847,12 @@ def setup_contacts_routes():
emails = [data["email"]]
emails = [e.strip() for e in (emails or []) if e and e.strip()]
phones = [p.strip() for p in (phones or []) if p and p.strip()]
if not name and not emails:
return {"success": False, "error": "Name or email required"}
address = (data.get("address") or "").strip()
if not name and not emails and not address:
return {"success": False, "error": "Name, email, or address required"}
if not name and emails:
name = emails[0].split("@")[0]
ok = _update_contact(uid, name, emails, phones)
ok = _update_contact(uid, name, emails, phones, address)
return {"success": ok}
@router.delete("/{uid}")
+77 -33
View File
@@ -4011,11 +4011,14 @@ async function initUnifiedIntegrations() {
<button class="admin-btn-sm" id="cm-add-toggle">+ Add</button>
<input type="file" id="cm-import-file" accept=".vcf,.csv,text/vcard,text/csv" multiple style="display:none">
</div>
<div id="cm-add-row" class="contacts-add-row" style="display:none;">
<input id="cm-add-name" class="settings-input" placeholder="Name" style="flex:1;min-width:0;">
<input id="cm-add-email" class="settings-input" placeholder="email@example.com" style="flex:1;min-width:0;">
<button class="admin-btn-sm" id="cm-add-save">Save</button>
<div id="cm-add-row" class="contacts-add-row" style="display:none;flex-direction:column;gap:4px;">
<input id="cm-add-name" class="settings-input" placeholder="Name">
<input id="cm-add-email" class="settings-input" placeholder="email@example.com">
<input id="cm-add-phone" class="settings-input" placeholder="Phone (optional)">
<input id="cm-add-address" class="settings-input" placeholder="Address (optional)">
<div style="display:flex;gap:6px;justify-content:flex-end;"><button class="admin-btn-sm" id="cm-add-save">Save</button></div>
</div>
<input type="text" id="cm-search" class="settings-input" placeholder="Search contacts (name, email, phone, address)" style="margin-top:6px;">
<div id="cm-list" class="contacts-list"><div style="opacity:0.4;font-size:11px;padding:8px 2px;">Loading</div></div>
</div>`;
try {
@@ -4051,11 +4054,18 @@ async function initUnifiedIntegrations() {
el('cm-add-save')?.addEventListener('click', async () => {
const name = el('cm-add-name').value.trim();
const email = el('cm-add-email').value.trim();
if (!email) { el('cm-add-email').focus(); return; }
const phone = el('cm-add-phone')?.value.trim() || '';
const address = el('cm-add-address')?.value.trim() || '';
// Need at least a name or email; address-only entries without a
// name aren't useful as a contact.
if (!name && !email) { (name ? el('cm-add-email') : el('cm-add-name')).focus(); return; }
try {
await fetch('/api/contacts/add', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email }) });
await fetch('/api/contacts/add', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email, phone, address }) });
} catch (_) {}
el('cm-add-name').value = ''; el('cm-add-email').value = '';
el('cm-add-name').value = '';
el('cm-add-email').value = '';
if (el('cm-add-phone')) el('cm-add-phone').value = '';
if (el('cm-add-address')) el('cm-add-address').value = '';
el('cm-add-row').style.display = 'none';
await _renderContactsManager();
});
@@ -4154,33 +4164,66 @@ async function initUnifiedIntegrations() {
}
// Sort by name for a stable list.
contacts.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
list.innerHTML = contacts.map(c => {
const emails = (c.emails || []).join(', ');
const phones = (c.phones || []).join(', ');
const sub = [emails, phones].filter(Boolean).join(' · ');
return `<div class="contact-row" data-uid="${esc(c.uid)}">
<div class="contact-row-view" style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;min-width:0;">
<div class="contact-name" style="font-size:12px;font-weight:600;">${esc(c.name || '(no name)')}</div>
<div class="contact-sub" style="font-size:10px;opacity:0.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(sub)}</div>
// Live filter — search across name/emails/phones/address.
const searchInput = el('cm-search');
const q = (searchInput?.value || '').trim().toLowerCase();
const filtered = !q ? contacts : contacts.filter(c => {
const hay = [
c.name || '',
(c.emails || []).join(' '),
(c.phones || []).join(' '),
c.address || '',
].join(' ').toLowerCase();
return hay.includes(q);
});
if (cnt) cnt.textContent = contacts.length ? `(${filtered.length}/${contacts.length})` : '';
if (!filtered.length) {
list.innerHTML = `<div style="opacity:0.4;font-size:11px;padding:8px 2px;">${q ? 'No matches.' : 'No contacts yet.'}</div>`;
} else {
list.innerHTML = filtered.map(c => {
const emails = (c.emails || []).join(', ');
const phones = (c.phones || []).join(', ');
const address = c.address || '';
const sub = [emails, phones, address].filter(Boolean).join(' · ');
return `<div class="contact-row" data-uid="${esc(c.uid)}">
<div class="contact-row-view" style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;min-width:0;">
<div class="contact-name" style="font-size:12px;font-weight:600;">${esc(c.name || '(no name)')}</div>
<div class="contact-sub" style="font-size:10px;opacity:0.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(sub)}</div>
</div>
<button class="admin-btn-sm contact-edit" title="Edit" style="display:inline-flex;align-items:center;gap:4px;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 35%, var(--border));">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit
</button>
<button class="admin-btn-sm contact-del" title="Delete" style="opacity:0.85;display:inline-flex;align-items:center;gap:4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete
</button>
</div>
<button class="admin-btn-sm contact-edit" title="Edit" style="display:inline-flex;align-items:center;gap:4px;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 35%, var(--border));">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit
</button>
<button class="admin-btn-sm contact-del" title="Delete" style="opacity:0.85;display:inline-flex;align-items:center;gap:4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete
</button>
</div>
<div class="contact-row-edit" style="display:none;flex-direction:column;gap:4px;">
<input class="settings-input contact-edit-name" value="${esc(c.name || '')}" placeholder="Name">
<input class="settings-input contact-edit-emails" value="${esc(emails)}" placeholder="email1, email2">
<input class="settings-input contact-edit-phones" value="${esc(phones)}" placeholder="phone1, phone2">
<div style="display:flex;gap:6px;"><button class="admin-btn-sm contact-save">Save</button><button class="admin-btn-sm contact-cancel" style="opacity:0.7;">Cancel</button></div>
</div>
</div>`;
}).join('');
<div class="contact-row-edit" style="display:none;flex-direction:column;gap:4px;">
<input class="settings-input contact-edit-name" value="${esc(c.name || '')}" placeholder="Name">
<input class="settings-input contact-edit-emails" value="${esc(emails)}" placeholder="email1, email2">
<input class="settings-input contact-edit-phones" value="${esc(phones)}" placeholder="phone1, phone2">
<input class="settings-input contact-edit-address" value="${esc(address)}" placeholder="Address">
<div style="display:flex;gap:6px;"><button class="admin-btn-sm contact-save">Save</button><button class="admin-btn-sm contact-cancel" style="opacity:0.7;">Cancel</button></div>
</div>
</div>`;
}).join('');
}
// Wire the search input — debounced so we don't refetch on every key.
if (searchInput && !searchInput._wired) {
searchInput._wired = true;
let _t;
searchInput.addEventListener('input', () => {
clearTimeout(_t);
_t = setTimeout(() => _renderContactsManager(), 80);
});
}
// Stash latest contacts so the search input doesn't have to refetch.
list._lastContacts = contacts;
// Wire each row's edit / delete / save / cancel.
list.querySelectorAll('.contact-row').forEach(row => {
const uid = row.dataset.uid;
@@ -4199,6 +4242,7 @@ async function initUnifiedIntegrations() {
name: row.querySelector('.contact-edit-name').value.trim(),
emails: row.querySelector('.contact-edit-emails').value.split(',').map(s => s.trim()).filter(Boolean),
phones: row.querySelector('.contact-edit-phones').value.split(',').map(s => s.trim()).filter(Boolean),
address: row.querySelector('.contact-edit-address')?.value.trim() || '',
};
try {
await fetch('/api/contacts/' + encodeURIComponent(uid), { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });