Files
nidus/internal/web/templates/contacts.templ
T
arnefandCopilot 9f8bfba420 Improve mobile layout of the dashboard, contacts, and calendar pages
- Layout nav wraps gracefully on narrow screens instead of overflowing
  (username truncates, links/logout reflow).
- Dashboard resource cards and share rows wrap instead of clipping
  when names/usernames are long.
- Contacts list hides secondary columns (organization/email below
  md, phone below sm) so the name and actions stay usable on phones.
- Calendar month/week grids get a horizontally scrollable wrapper
  with a sane minimum width and smaller cell padding on mobile so the
  7-day grid stays legible instead of being squeezed unreadably thin.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 09:16:01 +02:00

321 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.
type AddressValue struct {
Type string
Street string
City string
Region string
PostalCode string
Country 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
FullName 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">&larr; 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 phone/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 == "" }>Sonstige</option>
<option value="home" selected?={ selected == "home" }>Privat</option>
<option value="work" selected?={ selected == "work" }>Geschäftlich</option>
</select>
}
templ phoneRow(p LabeledValue) {
<div class="form-row flex gap-2 items-center">
@typeSelect("phone_type", p.Type)
<input name="phone_value" type="tel" value={ p.Value } placeholder="Telefonnummer"
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">Entfernen</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="E-Mail-Adresse"
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">Entfernen</button>
</div>
}
templ addressRow(a AddressValue) {
<div class="form-row grid grid-cols-2 gap-2 items-start bg-gray-50 rounded-md p-3">
<div class="col-span-2">
@typeSelect("address_type", a.Type)
</div>
<input name="address_street" type="text" value={ a.Street } placeholder="Straße und Hausnummer"
class="col-span-2 rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
<input name="address_city" type="text" value={ a.City } placeholder="Stadt"
class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
<input name="address_postal_code" type="text" value={ a.PostalCode } placeholder="PLZ"
class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
<input name="address_region" type="text" value={ a.Region } placeholder="Bundesland/Region"
class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
<input name="address_country" type="text" value={ a.Country } placeholder="Land"
class="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 col-span-2 text-left">Entfernen</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">&larr; { 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">
Foto wählen
<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"/>
Foto entfernen
</label>
}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Full name</label>
<input name="full_name" type="text" required value={ data.FullName }
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">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">Geburtstag</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">Telefonnummern</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">
+ Telefonnummer hinzufügen
</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">E-Mail-Adressen</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">
+ E-Mail-Adresse hinzufügen
</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Adressen</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">
+ Adresse hinzufügen
</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")
}