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:
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">← 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">← { 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")
|
||||
}
|
||||
@@ -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\">← 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\">← ")
|
||||
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
|
||||
@@ -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 & 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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user