327 lines
12 KiB
Templ
327 lines
12 KiB
Templ
package templates
|
|
|
|
import "fmt"
|
|
|
|
// AddressBookSummary is one of the user's own address books, listed on
|
|
// the contacts home page.
|
|
type AddressBookSummary struct {
|
|
Name string
|
|
Count int
|
|
}
|
|
|
|
// ContactSummary is a single contact row shown in a book's contact list.
|
|
type ContactSummary struct {
|
|
ID string
|
|
FullName string
|
|
Organization string
|
|
Phone string
|
|
Email string
|
|
PhotoDataURL string // "data:image/...;base64,..." if the contact has a photo, else ""
|
|
}
|
|
|
|
// LabeledValue is one TEL/EMAIL entry with its TYPE parameter ("home",
|
|
// "work", or "" for unspecified/other).
|
|
type LabeledValue struct {
|
|
Type string
|
|
Value string
|
|
}
|
|
|
|
// AddressValue is one ADR entry with its TYPE parameter; the full address
|
|
// is captured as a single free-form string.
|
|
type AddressValue struct {
|
|
Type string
|
|
Value string
|
|
}
|
|
|
|
// ContactFormData pre-fills the create/edit contact form. Phones, Emails,
|
|
// and Addresses always contain at least one (possibly blank) entry so the
|
|
// form always renders at least one input row per section.
|
|
type ContactFormData struct {
|
|
Book string
|
|
ID string // empty when creating a new contact
|
|
Forename string
|
|
Surname string
|
|
Organization string
|
|
Birthday string // "YYYY-MM-DD", empty if not set
|
|
Note string
|
|
PhotoDataURL string // existing photo preview, "" if none
|
|
Phones []LabeledValue
|
|
Emails []LabeledValue
|
|
Addresses []AddressValue
|
|
}
|
|
|
|
templ ContactsHome(username string, books []AddressBookSummary) {
|
|
@Layout("Contacts", username) {
|
|
<h1 class="text-2xl font-semibold mb-6">Contacts</h1>
|
|
if len(books) == 0 {
|
|
<p class="text-sm text-gray-500">
|
|
You don't have any address books yet — create one from the
|
|
<a href="/web/" class="text-indigo-600 hover:underline">dashboard</a> first.
|
|
</p>
|
|
} else {
|
|
<ul class="divide-y divide-gray-200 bg-white rounded-lg border border-gray-200">
|
|
for _, b := range books {
|
|
<li class="px-4 py-3 flex items-center justify-between text-sm">
|
|
<a href={ templ.URL("/web/contacts/" + b.Name) } class="font-medium text-indigo-600 hover:underline">{ b.Name }</a>
|
|
<span class="text-gray-400">{ fmt.Sprintf("%d contact(s)", b.Count) }</span>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
}
|
|
}
|
|
|
|
// avatar renders a contact's photo if present, otherwise a generic
|
|
// initial-less placeholder circle, at a given Tailwind size class (e.g.
|
|
// "w-8 h-8" for list rows, "w-24 h-24" for the form preview).
|
|
templ avatar(photoDataURL, sizeClass string) {
|
|
if photoDataURL != "" {
|
|
<img src={ photoDataURL } class={ sizeClass + " rounded-full object-cover" } alt=""/>
|
|
} else {
|
|
<span class={ sizeClass + " rounded-full bg-gray-100 flex items-center justify-center text-gray-400" } aria-hidden="true">
|
|
👤
|
|
</span>
|
|
}
|
|
}
|
|
|
|
templ ContactsList(username, book string, contacts []ContactSummary) {
|
|
@Layout("Contacts", username) {
|
|
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
|
<div>
|
|
<a href="/web/contacts" class="text-sm text-indigo-600 hover:underline">← Address books</a>
|
|
<h1 class="text-2xl font-semibold">{ book }</h1>
|
|
</div>
|
|
<div class="flex gap-2 items-center flex-wrap">
|
|
<a href={ templ.URL("/web/contacts/" + book + "/new") }
|
|
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
|
|
New contact
|
|
</a>
|
|
<a href={ templ.URL("/web/contacts/" + book + "/export") }
|
|
class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
Export all (.vcf)
|
|
</a>
|
|
<form
|
|
method="POST"
|
|
action={ templ.URL("/web/contacts/" + book + "/import") }
|
|
enctype="multipart/form-data"
|
|
class="flex items-center gap-2"
|
|
>
|
|
<label class="cursor-pointer bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
Import .vcf
|
|
<input type="file" name="file" accept=".vcf,text/vcard" required class="hidden" onchange="this.form.requestSubmit()"/>
|
|
</label>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="text-left text-gray-400 border-b border-gray-100">
|
|
<th class="py-2 px-3 font-medium" colspan="2">Name</th>
|
|
<th class="hidden md:table-cell py-2 px-3 font-medium">Organization</th>
|
|
<th class="hidden sm:table-cell py-2 px-3 font-medium">Phone</th>
|
|
<th class="hidden md:table-cell py-2 px-3 font-medium">Email</th>
|
|
<th class="py-2 px-3 font-medium"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, c := range contacts {
|
|
<tr class="border-b border-gray-50 hover:bg-gray-50">
|
|
<td class="py-2 px-3 w-10">
|
|
@avatar(c.PhotoDataURL, "w-8 h-8")
|
|
</td>
|
|
<td class="py-2 px-3 break-words">
|
|
<a href={ templ.URL("/web/contacts/" + book + "/" + c.ID + "/edit") } class="text-indigo-600 hover:underline">{ c.FullName }</a>
|
|
</td>
|
|
<td class="hidden md:table-cell py-2 px-3 text-gray-500 break-words">{ c.Organization }</td>
|
|
<td class="hidden sm:table-cell py-2 px-3 text-gray-500 whitespace-nowrap">{ c.Phone }</td>
|
|
<td class="hidden md:table-cell py-2 px-3 text-gray-500 break-words">{ c.Email }</td>
|
|
<td class="py-2 px-1 sm:px-3 text-right whitespace-nowrap">
|
|
<a href={ templ.URL("/web/contacts/" + book + "/" + c.ID + "/export") } class="text-gray-500 hover:underline text-xs mr-2 sm:mr-3">Export</a>
|
|
<form method="POST" action={ templ.URL("/web/contacts/" + book + "/" + c.ID + "/delete") } class="inline" onsubmit="return confirm('Delete this contact?')">
|
|
<button type="submit" class="text-red-600 hover:underline text-xs">Delete</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
}
|
|
if len(contacts) == 0 {
|
|
<tr>
|
|
<td colspan="6" class="py-6 px-3 text-center text-gray-400">No contacts yet — add one above.</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
// typeSelect renders the TYPE dropdown shared by email/address rows.
|
|
templ typeSelect(name, selected string) {
|
|
<select name={ name } class="rounded-md border-gray-300 border px-2 py-1.5 text-sm">
|
|
<option value="" selected?={ selected == "" }>Other</option>
|
|
<option value="home" selected?={ selected == "home" }>Home</option>
|
|
<option value="work" selected?={ selected == "work" }>Work</option>
|
|
</select>
|
|
}
|
|
|
|
// phoneTypeSelect renders the phone TYPE dropdown. An unspecified number is
|
|
// shown as "Mobile" (the vCard "cell" type), matching how mobile clients
|
|
// label a contact's main number.
|
|
templ phoneTypeSelect(name, selected string) {
|
|
<select name={ name } class="rounded-md border-gray-300 border px-2 py-1.5 text-sm">
|
|
<option value="cell" selected?={ selected == "cell" || selected == "" }>Mobile</option>
|
|
<option value="home" selected?={ selected == "home" }>Home</option>
|
|
<option value="work" selected?={ selected == "work" }>Work</option>
|
|
</select>
|
|
}
|
|
|
|
templ phoneRow(p LabeledValue) {
|
|
<div class="form-row flex gap-2 items-center">
|
|
@phoneTypeSelect("phone_type", p.Type)
|
|
<input name="phone_value" type="tel" value={ p.Value } placeholder="Phone number"
|
|
class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
|
<button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Remove</button>
|
|
</div>
|
|
}
|
|
|
|
templ emailRow(p LabeledValue) {
|
|
<div class="form-row flex gap-2 items-center">
|
|
@typeSelect("email_type", p.Type)
|
|
<input name="email_value" type="email" value={ p.Value } placeholder="Email address"
|
|
class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
|
<button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Remove</button>
|
|
</div>
|
|
}
|
|
|
|
templ addressRow(a AddressValue) {
|
|
<div class="form-row flex gap-2 items-center">
|
|
@typeSelect("address_type", a.Type)
|
|
<input name="address_value" type="text" value={ a.Value } placeholder="Street, city, postal code, country"
|
|
class="flex-1 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
|
<button type="button" class="remove-row-btn text-red-600 hover:underline text-xs shrink-0">Remove</button>
|
|
</div>
|
|
}
|
|
|
|
templ ContactForm(username string, data ContactFormData, errMsg string) {
|
|
@Layout("Contacts", username) {
|
|
<a href={ templ.URL("/web/contacts/" + data.Book) } class="text-sm text-indigo-600 hover:underline">← { data.Book }</a>
|
|
<h1 class="text-2xl font-semibold mt-2 mb-6">
|
|
if data.ID == "" {
|
|
New contact
|
|
} else {
|
|
Edit contact
|
|
}
|
|
</h1>
|
|
if errMsg != "" {
|
|
<p class="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{ errMsg }</p>
|
|
}
|
|
<form
|
|
method="POST"
|
|
action={ contactFormAction(data) }
|
|
enctype="multipart/form-data"
|
|
class="bg-white rounded-lg border border-gray-200 p-6 space-y-6 max-w-xl"
|
|
>
|
|
<div class="flex items-center gap-4">
|
|
<span id="current-avatar">
|
|
@avatar(data.PhotoDataURL, "w-20 h-20")
|
|
</span>
|
|
<img id="photo-preview" src="" alt="" class="w-20 h-20 rounded-full object-cover hidden"/>
|
|
<div class="space-y-1">
|
|
<label class="cursor-pointer inline-block bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
Choose photo
|
|
<input id="photo-input" name="photo" type="file" accept="image/*" class="hidden"/>
|
|
</label>
|
|
if data.PhotoDataURL != "" {
|
|
<label class="flex items-center gap-1 text-xs text-gray-500">
|
|
<input id="remove-photo" name="remove_photo" type="checkbox" value="1"/>
|
|
Remove photo
|
|
</label>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">First name</label>
|
|
<input name="forename" type="text" value={ data.Forename }
|
|
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Last name</label>
|
|
<input name="surname" type="text" value={ data.Surname }
|
|
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Organization</label>
|
|
<input name="organization" type="text" value={ data.Organization }
|
|
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Birthday</label>
|
|
<input name="birthday" type="date" value={ data.Birthday }
|
|
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Phones</label>
|
|
<div id="phones-container" class="space-y-2">
|
|
for _, p := range data.Phones {
|
|
@phoneRow(p)
|
|
}
|
|
</div>
|
|
<button type="button" data-add-target="phones-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline">
|
|
+ Add phone number
|
|
</button>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Emails</label>
|
|
<div id="emails-container" class="space-y-2">
|
|
for _, e := range data.Emails {
|
|
@emailRow(e)
|
|
}
|
|
</div>
|
|
<button type="button" data-add-target="emails-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline">
|
|
+ Add email address
|
|
</button>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Addresses</label>
|
|
<div id="addresses-container" class="space-y-2">
|
|
for _, a := range data.Addresses {
|
|
@addressRow(a)
|
|
}
|
|
</div>
|
|
<button type="button" data-add-target="addresses-container" class="add-row-btn mt-2 text-sm text-indigo-600 hover:underline">
|
|
+ Add address
|
|
</button>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Note</label>
|
|
<textarea name="note" rows="3"
|
|
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm">{ data.Note }</textarea>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button type="submit" class="bg-indigo-600 text-white rounded-md px-4 py-2 text-sm font-medium hover:bg-indigo-700">
|
|
Save
|
|
</button>
|
|
<a href={ templ.URL("/web/contacts/" + data.Book) } class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
Cancel
|
|
</a>
|
|
</div>
|
|
</form>
|
|
<script type="module" src="/web/static/contacts.js"></script>
|
|
}
|
|
}
|
|
|
|
func contactFormAction(data ContactFormData) templ.SafeURL {
|
|
if data.ID == "" {
|
|
return templ.URL("/web/contacts/" + data.Book + "/new")
|
|
}
|
|
return templ.URL("/web/contacts/" + data.Book + "/" + data.ID + "/edit")
|
|
}
|