Add web UI for WebDAV files and CardDAV contacts

Files browser (web/files):
- Alphabetical listing grouped folders-then-files
- Upload via button or drag-and-drop, including whole folders
- Folder creation
- Fixed layout for long filenames without spaces (table-fixed + break-all)

Contacts (web/contacts):
- Full CRUD for CardDAV contacts (create/edit/delete)
- VCF import (multi-card files) and export (single/all)
- Photo upload with preview, birthday field
- TYPE labels (private/business) for phone, email, address
- Repeatable multi-input rows for phones/emails/addresses instead of textareas
- Sanitizes a known malformed TYPE parameter pattern from some vCard
  exporters (e.g. Nextcloud Contacts) that otherwise caused phone numbers
  to be silently dropped on import

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 15:06:39 +02:00
co-authored by Copilot
parent 65cd24a192
commit 3fa0297715
15 changed files with 3174 additions and 8 deletions
+722
View File
@@ -0,0 +1,722 @@
package web
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
vcard "github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/store"
"github.com/yourusername/caldav-server/internal/web/templates"
)
// contactIDRe validates a contact's object ID as it appears in a URL path
// segment: a filename like "3f9a2b8c1d4e5f60ab12cd34ef56ab78.vcf" produced
// by newContactID.
var contactIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}\.vcf$`)
// brokenMultiTypeRe matches a malformed TYPE parameter value seen in some
// exported vCards (e.g. from certain Nextcloud Contacts versions): a
// comma-separated pair of quoted strings where the quote characters
// themselves were mistakenly emitted as RFC 6868 caret-encoded quotes
// (^') rather than actually delimiting the two values, e.g.
// `TYPE="WORK^'","^'VOICE"`. go-vcard's decoder can't parse this as a
// valid parameter and silently drops the whole field (no error), which is
// why phone numbers using this pattern vanish on import. The intended
// value is simply the two types joined by a comma, so it's rewritten to
// the unambiguous, valid form TYPE=WORK,VOICE before decoding.
var brokenMultiTypeRe = regexp.MustCompile(`TYPE="([A-Za-z0-9]+)\^'","\^'([A-Za-z0-9]+)"`)
// sanitizeVCardBytes repairs known-malformed-but-recoverable syntax
// produced by some real-world vCard exporters before handing the data to
// go-vcard, so fields that would otherwise be silently dropped survive
// import.
func sanitizeVCardBytes(data []byte) []byte {
return brokenMultiTypeRe.ReplaceAll(data, []byte(`TYPE=$1,$2`))
}
// newContactID generates a random filename for a new contact object,
// mirroring the "<id>.vcf" convention used by the CardDAV backend
// (internal/carddav).
func newContactID() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf) + ".vcf", nil
}
// ownsAddressBook reports whether book is one of username's own address
// books (never a book shared with them by another user — the web contacts
// UI only manages a user's own books, consistent with the file browser and
// dashboard resource cards).
func (s *Server) ownsAddressBook(username, book string) (bool, error) {
if !resourceNameRe.MatchString(book) {
return false, nil
}
names, err := s.dbase.ListAddressBooks(username)
if err != nil {
return false, err
}
for _, n := range names {
if n == book {
return true, nil
}
}
return false, nil
}
func (s *Server) handleContactsHome(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
names, err := s.dbase.ListAddressBooks(username)
if err != nil {
s.logger.Error("listing address books", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
sort.Strings(names)
var books []templates.AddressBookSummary
for _, name := range names {
ids, err := s.store.ListObjects(username, "card-"+name)
if err != nil {
s.logger.Warn("listing contacts", "book", name, "error", err)
}
books = append(books, templates.AddressBookSummary{Name: name, Count: len(ids)})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.ContactsHome(username, books).Render(context.Background(), w)
}
func (s *Server) handleContactsList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
book := r.PathValue("book")
ok, err := s.ownsAddressBook(username, book)
if err != nil {
s.logger.Error("checking address book ownership", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
ids, err := s.store.ListObjects(username, "card-"+book)
if err != nil {
s.logger.Error("listing contacts", "book", book, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var contacts []templates.ContactSummary
for _, id := range ids {
data, err := s.store.GetObject(username, "card-"+book, id)
if err != nil {
continue
}
contacts = append(contacts, summarizeCard(id, data))
}
sort.Slice(contacts, func(i, j int) bool {
return strings.ToLower(contacts[i].FullName) < strings.ToLower(contacts[j].FullName)
})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.ContactsList(username, book, contacts).Render(context.Background(), w)
}
func summarizeCard(id string, data []byte) templates.ContactSummary {
dec := vcard.NewDecoder(bytes.NewReader(data))
card, err := dec.Decode()
if err != nil {
return templates.ContactSummary{ID: id, FullName: id}
}
fullName := card.PreferredValue(vcard.FieldFormattedName)
if fullName == "" {
fullName = id
}
return templates.ContactSummary{
ID: id,
FullName: fullName,
Organization: card.PreferredValue(vcard.FieldOrganization),
Phone: card.PreferredValue(vcard.FieldTelephone),
Email: card.PreferredValue(vcard.FieldEmail),
PhotoDataURL: photoDataURL(card),
}
}
func (s *Server) handleContactNew(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context())
book := r.PathValue("book")
ok, err := s.ownsAddressBook(username, book)
if err != nil {
s.logger.Error("checking address book ownership", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
form := templates.ContactFormData{
Book: book,
Phones: ensureAtLeastOne[templates.LabeledValue](nil),
Emails: ensureAtLeastOne[templates.LabeledValue](nil),
Addresses: ensureAtLeastOne[templates.AddressValue](nil),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.ContactForm(username, form, "").Render(context.Background(), w)
case http.MethodPost:
s.saveContactFromForm(w, r, username, book, "")
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleContactEdit(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context())
book := r.PathValue("book")
id := r.PathValue("id")
ok, err := s.ownsAddressBook(username, book)
if err != nil {
s.logger.Error("checking address book ownership", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok || !contactIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
data, err := s.store.GetObject(username, "card-"+book, id)
if err != nil {
http.NotFound(w, r)
return
}
form, err := contactFormFromCard(book, id, data)
if err != nil {
s.logger.Error("decoding contact", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.ContactForm(username, form, "").Render(context.Background(), w)
case http.MethodPost:
s.saveContactFromForm(w, r, username, book, id)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// contactFormInput holds the parsed, validated-ish values submitted by the
// contact form, before they're translated into vCard fields.
type contactFormInput struct {
FullName string
Organization string
Note string
Birthday string
Phones []templates.LabeledValue
Emails []templates.LabeledValue
Addresses []templates.AddressValue
PhotoData []byte
PhotoMime string
RemovePhoto bool
}
// ensureAtLeastOne returns items unchanged if non-empty, or a single
// zero-value element otherwise, so the repeatable-row UI always renders at
// least one row per section.
func ensureAtLeastOne[T any](items []T) []T {
if len(items) > 0 {
return items
}
var zero T
return []T{zero}
}
func formValue(values []string, i int) string {
if i < 0 || i >= len(values) {
return ""
}
return values[i]
}
// parseContactForm reads the multipart contact form (required because it
// may include a photo upload) into a contactFormInput, zipping the
// parallel type/value fields for phones, emails, and addresses by index.
func parseContactForm(r *http.Request) (contactFormInput, error) {
if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
return contactFormInput{}, err
}
var form map[string][]string
if r.MultipartForm != nil {
form = r.MultipartForm.Value
}
get := func(key string) []string { return form[key] }
in := contactFormInput{
FullName: strings.TrimSpace(formValue(get("full_name"), 0)),
Organization: strings.TrimSpace(formValue(get("organization"), 0)),
Note: strings.TrimSpace(formValue(get("note"), 0)),
Birthday: strings.TrimSpace(formValue(get("birthday"), 0)),
RemovePhoto: formValue(get("remove_photo"), 0) != "",
}
phoneTypes, phoneValues := get("phone_type"), get("phone_value")
for i, v := range phoneValues {
v = strings.TrimSpace(v)
if v == "" {
continue
}
in.Phones = append(in.Phones, templates.LabeledValue{Type: formValue(phoneTypes, i), Value: v})
}
emailTypes, emailValues := get("email_type"), get("email_value")
for i, v := range emailValues {
v = strings.TrimSpace(v)
if v == "" {
continue
}
in.Emails = append(in.Emails, templates.LabeledValue{Type: formValue(emailTypes, i), Value: v})
}
addrTypes := get("address_type")
streets := get("address_street")
cities := get("address_city")
postalCodes := get("address_postal_code")
regions := get("address_region")
countries := get("address_country")
for i := range streets {
street := strings.TrimSpace(formValue(streets, i))
city := strings.TrimSpace(formValue(cities, i))
postalCode := strings.TrimSpace(formValue(postalCodes, i))
region := strings.TrimSpace(formValue(regions, i))
country := strings.TrimSpace(formValue(countries, i))
if street == "" && city == "" && postalCode == "" && region == "" && country == "" {
continue
}
in.Addresses = append(in.Addresses, templates.AddressValue{
Type: formValue(addrTypes, i), Street: street, City: city,
Region: region, PostalCode: postalCode, Country: country,
})
}
if r.MultipartForm != nil {
if files := r.MultipartForm.File["photo"]; len(files) > 0 {
fh := files[0]
f, err := fh.Open()
if err == nil {
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, 10<<20))
if err == nil && len(data) > 0 {
in.PhotoData = data
in.PhotoMime = fh.Header.Get("Content-Type")
}
}
}
}
return in, nil
}
// saveContactFromForm handles both contact creation (id == "") and
// editing (id preserves the existing contact's ID and any vCard fields
// not set from the form, such as UID and an unchanged photo).
func (s *Server) saveContactFromForm(w http.ResponseWriter, r *http.Request, username, book, id string) {
in, err := parseContactForm(r)
if err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
if in.FullName == "" {
form := templates.ContactFormData{
Book: book,
ID: id,
FullName: in.FullName,
Organization: in.Organization,
Note: in.Note,
Birthday: in.Birthday,
Phones: ensureAtLeastOne(in.Phones),
Emails: ensureAtLeastOne(in.Emails),
Addresses: ensureAtLeastOne(in.Addresses),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.ContactForm(username, form, "Full name is required").Render(context.Background(), w)
return
}
var uid, existingPhoto string
if id == "" {
newID, err := newContactID()
if err != nil {
s.logger.Error("generating contact id", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
id = newID
uid = strings.TrimSuffix(id, ".vcf")
} else {
data, err := s.store.GetObject(username, "card-"+book, id)
if err != nil {
http.NotFound(w, r)
return
}
dec := vcard.NewDecoder(bytes.NewReader(data))
existing, err := dec.Decode()
if err != nil {
s.logger.Error("decoding existing contact", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
uid = existing.Value(vcard.FieldUID)
existingPhoto = existing.Value(vcard.FieldPhoto)
}
card := buildCard(uid, in, existingPhoto)
if err := s.saveCard(username, book, id, card); err != nil {
s.logger.Error("saving contact", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/web/contacts/"+book, http.StatusSeeOther)
}
// buildCard builds a fresh vCard from the submitted form input. uid and
// existingPhoto (both optional) are carried over from an existing contact
// when editing, so re-saving doesn't drop its identity or a previously
// uploaded photo the user didn't change.
func buildCard(uid string, in contactFormInput, existingPhoto string) vcard.Card {
card := make(vcard.Card)
card.SetValue(vcard.FieldVersion, "4.0")
if uid != "" {
card.SetValue(vcard.FieldUID, uid)
}
card.SetValue(vcard.FieldFormattedName, in.FullName)
name := &vcard.Name{}
parts := strings.Fields(in.FullName)
if len(parts) > 0 {
name.GivenName = parts[0]
}
if len(parts) > 1 {
name.FamilyName = strings.Join(parts[1:], " ")
}
card.SetName(name)
if in.Organization != "" {
card.SetValue(vcard.FieldOrganization, in.Organization)
}
if in.Note != "" {
card.SetValue(vcard.FieldNote, in.Note)
}
if in.Birthday != "" {
card.SetValue(vcard.FieldBirthday, in.Birthday)
}
for _, p := range in.Phones {
f := &vcard.Field{Value: p.Value}
if p.Type != "" {
f.Params = vcard.Params{vcard.ParamType: {p.Type}}
}
card.Add(vcard.FieldTelephone, f)
}
for _, e := range in.Emails {
f := &vcard.Field{Value: e.Value}
if e.Type != "" {
f.Params = vcard.Params{vcard.ParamType: {e.Type}}
}
card.Add(vcard.FieldEmail, f)
}
for _, a := range in.Addresses {
addr := &vcard.Address{
Field: &vcard.Field{},
StreetAddress: a.Street,
Locality: a.City,
Region: a.Region,
PostalCode: a.PostalCode,
Country: a.Country,
}
if a.Type != "" {
addr.Params = vcard.Params{vcard.ParamType: {a.Type}}
}
card.AddAddress(addr)
}
switch {
case in.RemovePhoto:
// Leave PHOTO unset.
case len(in.PhotoData) > 0:
mime := in.PhotoMime
if mime == "" {
mime = http.DetectContentType(in.PhotoData)
}
encoded := base64.StdEncoding.EncodeToString(in.PhotoData)
card.SetValue(vcard.FieldPhoto, "data:"+mime+";base64,"+encoded)
case existingPhoto != "":
card.SetValue(vcard.FieldPhoto, existingPhoto)
}
return card
}
func (s *Server) saveCard(username, book, id string, card vcard.Card) error {
var buf strings.Builder
enc := vcard.NewEncoder(&buf)
if err := enc.Encode(card); err != nil {
return fmt.Errorf("encoding vcard: %w", err)
}
return s.store.PutObject(username, "card-"+book, id, []byte(buf.String()))
}
// photoDataURL returns card's PHOTO value if it's already a data: URI
// (how we always store newly uploaded photos), so it can be used directly
// as an <img src>. Legacy vCards may store photos differently (e.g. a bare
// base64 blob with an ENCODING param, or a remote http(s) URL); those are
// preserved on save but not rendered as a preview.
func photoDataURL(card vcard.Card) string {
v := card.Value(vcard.FieldPhoto)
if strings.HasPrefix(v, "data:") {
return v
}
return ""
}
func labeledValues(card vcard.Card, key string) []templates.LabeledValue {
fields := card[key]
out := make([]templates.LabeledValue, 0, len(fields))
for _, f := range fields {
typ := ""
if f.Params != nil {
typ = f.Params.Get(vcard.ParamType)
}
out = append(out, templates.LabeledValue{Type: typ, Value: f.Value})
}
return out
}
func addressValues(card vcard.Card) []templates.AddressValue {
addrs := card.Addresses()
out := make([]templates.AddressValue, 0, len(addrs))
for _, a := range addrs {
typ := ""
if a.Params != nil {
typ = a.Params.Get(vcard.ParamType)
}
out = append(out, templates.AddressValue{
Type: typ, Street: a.StreetAddress, City: a.Locality,
Region: a.Region, PostalCode: a.PostalCode, Country: a.Country,
})
}
return out
}
func contactFormFromCard(book, id string, data []byte) (templates.ContactFormData, error) {
dec := vcard.NewDecoder(bytes.NewReader(data))
card, err := dec.Decode()
if err != nil {
return templates.ContactFormData{}, err
}
return templates.ContactFormData{
Book: book,
ID: id,
FullName: card.PreferredValue(vcard.FieldFormattedName),
Organization: card.PreferredValue(vcard.FieldOrganization),
Note: card.PreferredValue(vcard.FieldNote),
Birthday: card.PreferredValue(vcard.FieldBirthday),
PhotoDataURL: photoDataURL(card),
Phones: ensureAtLeastOne(labeledValues(card, vcard.FieldTelephone)),
Emails: ensureAtLeastOne(labeledValues(card, vcard.FieldEmail)),
Addresses: ensureAtLeastOne(addressValues(card)),
}, nil
}
func (s *Server) handleContactDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
book := r.PathValue("book")
id := r.PathValue("id")
ok, err := s.ownsAddressBook(username, book)
if err != nil {
s.logger.Error("checking address book ownership", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok || !contactIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
if err := s.store.DeleteObject(username, "card-"+book, id); err != nil && !errors.Is(err, store.ErrNotFound) {
s.logger.Error("deleting contact", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/web/contacts/"+book, http.StatusSeeOther)
}
func (s *Server) handleContactExportOne(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
book := r.PathValue("book")
id := r.PathValue("id")
ok, err := s.ownsAddressBook(username, book)
if err != nil {
s.logger.Error("checking address book ownership", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok || !contactIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
data, err := s.store.GetObject(username, "card-"+book, id)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/vcard; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+id+`"`)
_, _ = w.Write(data)
}
func (s *Server) handleContactExportAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
book := r.PathValue("book")
ok, err := s.ownsAddressBook(username, book)
if err != nil {
s.logger.Error("checking address book ownership", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
ids, err := s.store.ListObjects(username, "card-"+book)
if err != nil {
s.logger.Error("listing contacts", "book", book, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/vcard; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+book+`.vcf"`)
for _, id := range ids {
data, err := s.store.GetObject(username, "card-"+book, id)
if err != nil {
continue
}
// Each stored object is already a complete, self-contained vCard
// (ending in "END:VCARD"), so concatenating their raw bytes
// produces a valid multi-card .vcf file.
_, _ = w.Write(data)
}
}
func (s *Server) handleContactImport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
book := r.PathValue("book")
ok, err := s.ownsAddressBook(username, book)
if err != nil {
s.logger.Error("checking address book ownership", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
http.Error(w, "invalid upload", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "no file provided", http.StatusBadRequest)
return
}
defer file.Close()
body, err := io.ReadAll(file)
if err != nil {
s.logger.Error("reading import file", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
body = sanitizeVCardBytes(body)
dec := vcard.NewDecoder(bytes.NewReader(body))
imported := 0
for {
card, err := dec.Decode()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
s.logger.Warn("stopping vcard import on parse error", "book", book, "error", err)
break
}
id, err := newContactID()
if err != nil {
s.logger.Error("generating contact id", "error", err)
continue
}
if err := s.saveCard(username, book, id, card); err != nil {
s.logger.Warn("saving imported contact", "error", err)
continue
}
imported++
}
http.Redirect(w, r, "/web/contacts/"+book+"?imported="+strconv.Itoa(imported), http.StatusSeeOther)
}
+301
View File
@@ -0,0 +1,301 @@
package web
import (
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/yourusername/caldav-server/internal/web/templates"
)
// maxUploadMemory bounds how much of a multipart upload is buffered in
// memory before spilling to a temp file on disk (see multipart.Reader).
const maxUploadMemory = 32 << 20 // 32 MiB
// filesRoot returns the on-disk directory a user's web file browser is
// rooted at. This is intentionally the same directory the WebDAV handler
// (internal/webdav) serves at /files/, so the web UI is just another view
// onto the same files.
func (s *Server) filesRoot(username string) string {
return filepath.Join(s.cfg.Storage.DataDir, "files", username)
}
// sanitizeRelPath cleans a slash-separated relative path (as received from
// a URL or an uploaded file's name) and rejects any attempt to escape the
// root via ".." segments. The returned path never has a leading slash.
func sanitizeRelPath(p string) (string, error) {
p = strings.ReplaceAll(p, "\\", "/")
clean := path.Clean("/" + p)
clean = strings.TrimPrefix(clean, "/")
if clean == "." || clean == "" {
return "", nil
}
for _, seg := range strings.Split(clean, "/") {
if seg == ".." || seg == "" {
return "", fmt.Errorf("invalid path %q", p)
}
}
return clean, nil
}
// isWithinRoot reports whether p is root itself or a descendant of it,
// guarding against path traversal escaping the user's own file storage.
func isWithinRoot(root, p string) bool {
rp, err := filepath.Abs(root)
if err != nil {
return false
}
pp, err := filepath.Abs(p)
if err != nil {
return false
}
rel, err := filepath.Rel(rp, pp)
if err != nil {
return false
}
return rel == "." || (!strings.HasPrefix(rel, "..") && rel != "..")
}
// handleFiles serves the /files/{path...} route mounted under /web/: GET
// lists a directory (or downloads a file), POST uploads one or more files
// (optionally nested in folders, via each multipart file's name carrying
// a relative path) into the current directory.
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context())
root := s.filesRoot(username)
if err := os.MkdirAll(root, 0o755); err != nil {
s.logger.Error("creating user files dir", "user", username, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
relPath, err := sanitizeRelPath(r.PathValue("path"))
if err != nil {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
fullPath := filepath.Join(root, filepath.FromSlash(relPath))
if !isWithinRoot(root, fullPath) {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
s.handleFilesGet(w, r, username, relPath, fullPath)
case http.MethodPost:
if r.URL.Query().Has("mkdir") {
s.handleFilesMkdir(w, r, root, fullPath)
return
}
s.handleFilesUpload(w, r, root, fullPath)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleFilesMkdir creates a new subdirectory named by the "name" form
// field directly inside fullPath (the current directory), mounted at
// POST /files/{path}?mkdir=1.
func (s *Server) handleFilesMkdir(w http.ResponseWriter, r *http.Request, root, fullPath string) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.PostForm.Get("name"))
if !resourceNameRe.MatchString(name) {
http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest)
return
}
destPath := filepath.Join(fullPath, name)
if !isWithinRoot(root, destPath) {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
if _, err := os.Stat(destPath); err == nil {
http.Error(w, "a file or folder with that name already exists", http.StatusConflict)
return
} else if !errors.Is(err, os.ErrNotExist) {
s.logger.Error("stat new folder", "path", destPath, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := os.Mkdir(destPath, 0o755); err != nil {
s.logger.Error("creating folder", "path", destPath, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "created folder %q", name)
}
func (s *Server) handleFilesGet(w http.ResponseWriter, r *http.Request, username, relPath, fullPath string) {
info, err := os.Stat(fullPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.NotFound(w, r)
return
}
s.logger.Error("stat file", "path", fullPath, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !info.IsDir() {
f, err := os.Open(fullPath)
if err != nil {
s.logger.Error("opening file", "path", fullPath, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
defer f.Close()
w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(fullPath)+`"`)
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
return
}
dirEntries, err := os.ReadDir(fullPath)
if err != nil {
s.logger.Error("reading dir", "path", fullPath, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// os.ReadDir already returns entries sorted by filename, so filtering
// into two passes keeps each group (directories, then files)
// alphabetically sorted while grouping directories first.
var dirs, files []templates.FileEntry
for _, de := range dirEntries {
entryInfo, err := de.Info()
if err != nil {
continue
}
childRel := path.Join(relPath, de.Name())
entry := templates.FileEntry{
Name: de.Name(),
IsDir: de.IsDir(),
ModTime: entryInfo.ModTime().Format("2006-01-02 15:04"),
RelPath: childRel,
}
if de.IsDir() {
dirs = append(dirs, entry)
} else {
entry.Size = humanSize(entryInfo.Size())
files = append(files, entry)
}
}
entries := append(dirs, files...)
var breadcrumbs []templates.Breadcrumb
if relPath != "" {
segs := strings.Split(relPath, "/")
for i, seg := range segs {
breadcrumbs = append(breadcrumbs, templates.Breadcrumb{
Name: seg,
Path: strings.Join(segs[:i+1], "/"),
})
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.FilesPage(username, breadcrumbs, entries, relPath).Render(context.Background(), w)
}
func (s *Server) handleFilesUpload(w http.ResponseWriter, r *http.Request, root, fullPath string) {
info, err := os.Stat(fullPath)
if err != nil || !info.IsDir() {
http.Error(w, "upload target is not a directory", http.StatusBadRequest)
return
}
if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
http.Error(w, "invalid upload", http.StatusBadRequest)
return
}
if r.MultipartForm == nil {
http.Error(w, "no files", http.StatusBadRequest)
return
}
defer r.MultipartForm.RemoveAll()
fileHeaders := r.MultipartForm.File["files"]
if len(fileHeaders) == 0 {
http.Error(w, "no files", http.StatusBadRequest)
return
}
// The stdlib strips any directory components from fh.Filename (per RFC
// 7578 §4.2), so folder uploads/drops send each file's relative path
// separately in a parallel "paths" field (same order as "files") for
// the server to recreate the directory structure.
relPaths := r.MultipartForm.Value["paths"]
for i, fh := range fileHeaders {
relFile := fh.Filename
if i < len(relPaths) && relPaths[i] != "" {
relFile = relPaths[i]
}
relFile, err := sanitizeRelPath(relFile)
if err != nil || relFile == "" {
s.logger.Warn("skipping upload with invalid filename", "filename", fh.Filename)
continue
}
destPath := filepath.Join(fullPath, filepath.FromSlash(relFile))
if !isWithinRoot(root, destPath) {
s.logger.Warn("skipping upload escaping root", "filename", fh.Filename)
continue
}
if err := s.saveUploadedFile(fh, destPath); err != nil {
s.logger.Error("saving uploaded file", "path", destPath, "error", err)
http.Error(w, "failed to save "+fh.Filename, http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "uploaded %d file(s)", len(fileHeaders))
}
func (s *Server) saveUploadedFile(fh *multipart.FileHeader, destPath string) error {
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return err
}
src, err := fh.Open()
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(destPath)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
}
func humanSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
}
+9
View File
@@ -45,6 +45,15 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
mux.HandleFunc("/resources/calendar", s.requireLogin(s.handleCalendarResource))
mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor))
mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource))
mux.HandleFunc("/files/{path...}", s.requireLogin(s.handleFiles))
mux.HandleFunc("/contacts", s.requireLogin(s.handleContactsHome))
mux.HandleFunc("/contacts/{book}", s.requireLogin(s.handleContactsList))
mux.HandleFunc("/contacts/{book}/new", s.requireLogin(s.handleContactNew))
mux.HandleFunc("/contacts/{book}/import", s.requireLogin(s.handleContactImport))
mux.HandleFunc("/contacts/{book}/export", s.requireLogin(s.handleContactExportAll))
mux.HandleFunc("/contacts/{book}/{id}/edit", s.requireLogin(s.handleContactEdit))
mux.HandleFunc("/contacts/{book}/{id}/delete", s.requireLogin(s.handleContactDelete))
mux.HandleFunc("/contacts/{book}/{id}/export", s.requireLogin(s.handleContactExportOne))
return mux
}
+318
View File
@@ -0,0 +1,318 @@
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>
<table class="w-full text-sm bg-white rounded-lg border border-gray-200">
<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="py-2 px-3 font-medium">Organization</th>
<th class="py-2 px-3 font-medium">Phone</th>
<th class="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="py-2 px-3 text-gray-500 break-words">{ c.Organization }</td>
<td class="py-2 px-3 text-gray-500 whitespace-nowrap">{ c.Phone }</td>
<td class="py-2 px-3 text-gray-500 break-words">{ c.Email }</td>
<td class="py-2 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-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>
}
}
// 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")
}
+991
View File
@@ -0,0 +1,991 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
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
}
func ContactsHome(username string, books []AddressBookSummary) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-2xl font-semibold mb-6\">Contacts</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(books) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<ul class=\"divide-y divide-gray-200 bg-white rounded-lg border border-gray-200\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, b := range books {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<li class=\"px-4 py-3 flex items-center justify-between text-sm\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + b.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 67, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"font-medium text-indigo-600 hover:underline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(b.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 67, Col: 115}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a> <span class=\"text-gray-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d contact(s)", b.Count))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 68, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
templ_7745c5c3_Err = Layout("Contacts", username).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// 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).
func avatar(photoDataURL, sizeClass string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if photoDataURL != "" {
var templ_7745c5c3_Var7 = []any{sizeClass + " rounded-full object-cover"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(photoDataURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 81, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var10 = []any{sizeClass + " rounded-full bg-gray-100 flex items-center justify-center text-gray-400"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" aria-hidden=\"true\">👤</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func ContactsList(username, book string, contacts []ContactSummary) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
if templ_7745c5c3_Var12 == nil {
templ_7745c5c3_Var12 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var13 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 94, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1></div><div class=\"flex gap-2 items-center flex-wrap\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/new"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 97, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/export"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 101, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/import"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 107, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" 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><table class=\"w-full text-sm bg-white rounded-lg border border-gray-200\"><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=\"py-2 px-3 font-medium\">Organization</th><th class=\"py-2 px-3 font-medium\">Phone</th><th class=\"py-2 px-3 font-medium\">Email</th><th class=\"py-2 px-3 font-medium\"></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, c := range contacts {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<tr class=\"border-b border-gray-50 hover:bg-gray-50\"><td class=\"py-2 px-3 w-10\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = avatar(c.PhotoDataURL, "w-8 h-8").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td class=\"py-2 px-3 break-words\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 templ.SafeURL
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/edit"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 136, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"text-indigo-600 hover:underline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(c.FullName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 136, Col: 129}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</a></td><td class=\"py-2 px-3 text-gray-500 break-words\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(c.Organization)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 138, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(c.Phone)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 139, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"py-2 px-3 text-gray-500 break-words\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 140, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td><td class=\"py-2 px-3 text-right whitespace-nowrap\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/export"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 142, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"text-gray-500 hover:underline text-xs mr-3\">Export</a><form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 templ.SafeURL
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + book + "/" + c.ID + "/delete"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 143, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" 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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(contacts) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<tr><td colspan=\"6\" class=\"py-6 px-3 text-center text-gray-400\">No contacts yet — add one above.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("Contacts", username).Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// typeSelect renders the TYPE dropdown shared by phone/email/address rows.
func typeSelect(name, selected string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
if templ_7745c5c3_Var25 == nil {
templ_7745c5c3_Var25 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<select name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 161, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if selected == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Sonstige</option> <option value=\"home\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if selected == "home" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Privat</option> <option value=\"work\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if selected == "work" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Geschäftlich</option></select>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func phoneRow(p LabeledValue) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var27 := templ.GetChildren(ctx)
if templ_7745c5c3_Var27 == nil {
templ_7745c5c3_Var27 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"form-row flex gap-2 items-center\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = typeSelect("phone_type", p.Type).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<input name=\"phone_value\" type=\"tel\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 171, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func emailRow(p LabeledValue) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var29 := templ.GetChildren(ctx)
if templ_7745c5c3_Var29 == nil {
templ_7745c5c3_Var29 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"form-row flex gap-2 items-center\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = typeSelect("email_type", p.Type).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<input name=\"email_value\" type=\"email\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 180, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func addressRow(a AddressValue) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var31 := templ.GetChildren(ctx)
if templ_7745c5c3_Var31 == nil {
templ_7745c5c3_Var31 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"form-row grid grid-cols-2 gap-2 items-start bg-gray-50 rounded-md p-3\"><div class=\"col-span-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = typeSelect("address_type", a.Type).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div><input name=\"address_street\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Street)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 191, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.City)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 193, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" placeholder=\"Stadt\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <input name=\"address_postal_code\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.PostalCode)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 195, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" placeholder=\"PLZ\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <input name=\"address_region\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Region)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 197, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" placeholder=\"Bundesland/Region\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"> <input name=\"address_country\" type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Country)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 199, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ContactForm(username string, data ContactFormData, errMsg string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var37 := templ.GetChildren(ctx)
if templ_7745c5c3_Var37 == nil {
templ_7745c5c3_Var37 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var38 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 templ.SafeURL
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + data.Book))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 207, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" class=\"text-sm text-indigo-600 hover:underline\">&larr; ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(data.Book)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 207, Col: 120}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</a><h1 class=\"text-2xl font-semibold mt-2 mb-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.ID == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "New contact")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Edit contact")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<p class=\"mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 216, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " <form method=\"POST\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 templ.SafeURL
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(contactFormAction(data))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 220, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" 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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = avatar(data.PhotoDataURL, "w-20 h-20").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.PhotoDataURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</div></div><div><label class=\"block text-sm font-medium text-gray-700\">Full name</label> <input name=\"full_name\" type=\"text\" required value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.FullName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 245, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Organization)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 250, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Birthday)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 255, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" 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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, p := range data.Phones {
templ_7745c5c3_Err = phoneRow(p).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, e := range data.Emails {
templ_7745c5c3_Err = emailRow(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, a := range data.Addresses {
templ_7745c5c3_Err = addressRow(a).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(data.Note)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 298, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var47 templ.SafeURL
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/contacts/" + data.Book))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/contacts.templ`, Line: 304, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("Contacts", username).Render(templ.WithChildren(ctx, templ_7745c5c3_Var38), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
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")
}
var _ = templruntime.GeneratedTemplate
+98
View File
@@ -0,0 +1,98 @@
package templates
// Breadcrumb is one segment of the current directory path shown above the
// file listing, e.g. "docs" linking to "/web/files/docs/".
type Breadcrumb struct {
Name string
Path string // relative path (no leading slash) used to build the link
}
// FileEntry is a single row (directory or file) in the file browser.
type FileEntry struct {
Name string
IsDir bool
Size string // human-readable, empty for directories
ModTime string
RelPath string // relative path (no leading slash) used to build the link
}
templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, currentPath string) {
@Layout("Files", username) {
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<h1 class="text-2xl font-semibold">Files</h1>
<div class="flex gap-2">
<button id="new-folder-button" type="button"
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">
New folder
</button>
<label class="cursor-pointer bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
Upload files
<input id="upload-files-input" type="file" multiple class="hidden"/>
</label>
<label class="cursor-pointer bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
Upload folder
<input id="upload-folder-input" type="file" webkitdirectory multiple class="hidden"/>
</label>
</div>
</div>
<nav class="text-sm text-gray-500 mb-4 flex flex-wrap items-center gap-1">
<a href="/web/files/" class="hover:underline text-indigo-600">home</a>
for _, bc := range breadcrumbs {
<span>/</span>
<a href={ templ.URL("/web/files/" + bc.Path + "/") } class="hover:underline text-indigo-600">{ bc.Name }</a>
}
</nav>
<div
id="file-drop-zone"
data-current-path={ currentPath }
data-upload-url={ "/web/files/" + currentPath }
class="bg-white rounded-lg border-2 border-dashed border-gray-200 p-2 transition-colors"
>
<table class="w-full text-sm table-fixed">
<colgroup>
<col class="w-auto"/>
<col class="w-20"/>
<col class="w-36"/>
</colgroup>
<thead>
<tr class="text-left text-gray-400 border-b border-gray-100">
<th class="py-2 px-3 font-medium">Name</th>
<th class="py-2 px-3 font-medium">Size</th>
<th class="py-2 px-3 font-medium">Modified</th>
</tr>
</thead>
<tbody>
for _, e := range entries {
<tr class="border-b border-gray-50 hover:bg-gray-50">
<td class="py-2 px-3 break-all">
if e.IsDir {
<a href={ templ.URL("/web/files/" + e.RelPath + "/") } class="flex items-center gap-2 text-indigo-600 hover:underline">
<span aria-hidden="true">📁</span>{ e.Name }
</a>
} else {
<a href={ templ.URL("/web/files/" + e.RelPath) } download class="flex items-center gap-2 text-gray-700 hover:underline">
<span aria-hidden="true">📄</span>{ e.Name }
</a>
}
</td>
<td class="py-2 px-3 text-gray-500 whitespace-nowrap">{ e.Size }</td>
<td class="py-2 px-3 text-gray-500 whitespace-nowrap">{ e.ModTime }</td>
</tr>
}
if len(entries) == 0 {
<tr>
<td colspan="3" class="py-6 px-3 text-center text-gray-400">
This folder is empty drag &amp; drop files or folders here, or use the upload buttons above.
</td>
</tr>
}
</tbody>
</table>
</div>
<p id="upload-status" class="mt-3 text-sm text-gray-500"></p>
<script type="module" src="/web/static/files.js"></script>
}
}
+245
View File
@@ -0,0 +1,245 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// Breadcrumb is one segment of the current directory path shown above the
// file listing, e.g. "docs" linking to "/web/files/docs/".
type Breadcrumb struct {
Name string
Path string // relative path (no leading slash) used to build the link
}
// FileEntry is a single row (directory or file) in the file browser.
type FileEntry struct {
Name string
IsDir bool
Size string // human-readable, empty for directories
ModTime string
RelPath string // relative path (no leading slash) used to build the link
}
func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, currentPath string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between mb-6 gap-4 flex-wrap\"><h1 class=\"text-2xl font-semibold\">Files</h1><div class=\"flex gap-2\"><button id=\"new-folder-button\" type=\"button\" 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\">New folder</button> <label class=\"cursor-pointer bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Upload files <input id=\"upload-files-input\" type=\"file\" multiple class=\"hidden\"></label> <label class=\"cursor-pointer bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Upload folder <input id=\"upload-folder-input\" type=\"file\" webkitdirectory multiple class=\"hidden\"></label></div></div><nav class=\"text-sm text-gray-500 mb-4 flex flex-wrap items-center gap-1\"><a href=\"/web/files/\" class=\"hover:underline text-indigo-600\">home</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, bc := range breadcrumbs {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span>/</span> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + bc.Path + "/"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 43, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"hover:underline text-indigo-600\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(bc.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 43, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</nav><div id=\"file-drop-zone\" data-current-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 49, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-upload-url=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/web/files/" + currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 50, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"bg-white rounded-lg border-2 border-dashed border-gray-200 p-2 transition-colors\"><table class=\"w-full text-sm table-fixed\"><colgroup><col class=\"w-auto\"> <col class=\"w-20\"> <col class=\"w-36\"></colgroup> <thead><tr class=\"text-left text-gray-400 border-b border-gray-100\"><th class=\"py-2 px-3 font-medium\">Name</th><th class=\"py-2 px-3 font-medium\">Size</th><th class=\"py-2 px-3 font-medium\">Modified</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, e := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<tr class=\"border-b border-gray-50 hover:bg-gray-50\"><td class=\"py-2 px-3 break-all\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.IsDir {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath + "/"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 71, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"flex items-center gap-2 text-indigo-600 hover:underline\"><span aria-hidden=\"true\">📁</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 72, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/web/files/" + e.RelPath))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 75, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" download class=\"flex items-center gap-2 text-gray-700 hover:underline\"><span aria-hidden=\"true\">📄</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 76, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(e.Size)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 80, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td><td class=\"py-2 px-3 text-gray-500 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.ModTime)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 81, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(entries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<tr><td colspan=\"3\" class=\"py-6 px-3 text-center text-gray-400\">This folder is empty — drag &amp; drop files or folders here, or use the upload buttons above.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</tbody></table></div><p id=\"upload-status\" class=\"mt-3 text-sm text-gray-500\"></p><script type=\"module\" src=\"/web/static/files.js\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("Files", username).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+7 -1
View File
@@ -13,7 +13,13 @@ templ Layout(title string, username string) {
<body class="h-full text-gray-900">
<nav class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="/web/" class="font-semibold text-lg tracking-tight">nidus</a>
<div class="flex items-center gap-6">
<a href="/web/" class="font-semibold text-lg tracking-tight">nidus</a>
if username != "" {
<a href="/web/files/" class="text-sm text-gray-600 hover:text-indigo-600">Files</a>
<a href="/web/contacts" class="text-sm text-gray-600 hover:text-indigo-600">Contacts</a>
}
</div>
if username != "" {
<div class="flex items-center gap-4 text-sm text-gray-600">
<span>{ username }</span>
+16 -6
View File
@@ -42,30 +42,40 @@ func Layout(title string, username string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · nidus</title><link rel=\"stylesheet\" href=\"/web/static/app.css\"><script src=\"/web/static/htmx.min.js\" defer></script></head><body class=\"h-full text-gray-900\"><nav class=\"bg-white border-b border-gray-200\"><div class=\"max-w-4xl mx-auto px-4 py-3 flex items-center justify-between\"><a href=\"/web/\" class=\"font-semibold text-lg tracking-tight\">nidus</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · nidus</title><link rel=\"stylesheet\" href=\"/web/static/app.css\"><script src=\"/web/static/htmx.min.js\" defer></script></head><body class=\"h-full text-gray-900\"><nav class=\"bg-white border-b border-gray-200\"><div class=\"max-w-4xl mx-auto px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-6\"><a href=\"/web/\" class=\"font-semibold text-lg tracking-tight\">nidus</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if username != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"flex items-center gap-4 text-sm text-gray-600\"><span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a href=\"/web/files/\" class=\"text-sm text-gray-600 hover:text-indigo-600\">Files</a> <a href=\"/web/contacts\" class=\"text-sm text-gray-600 hover:text-indigo-600\">Contacts</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if username != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"flex items-center gap-4 text-sm text-gray-600\"><span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 19, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 25, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span> <a href=\"/web/logout\" class=\"text-red-600 hover:underline\">Logout</a></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span> <a href=\"/web/logout\" class=\"text-red-600 hover:underline\">Logout</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></nav><main class=\"max-w-4xl mx-auto px-4 py-8\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div></nav><main class=\"max-w-4xl mx-auto px-4 py-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -73,7 +83,7 @@ func Layout(title string, username string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</main></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}