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:
@@ -2,3 +2,4 @@ data/
|
|||||||
config.yaml
|
config.yaml
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
/bin/
|
/bin/
|
||||||
|
*.vcf
|
||||||
|
|||||||
@@ -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", s.requireLogin(s.handleCalendarResource))
|
||||||
mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor))
|
mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor))
|
||||||
mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource))
|
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
|
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">
|
<body class="h-full text-gray-900">
|
||||||
<nav class="bg-white border-b border-gray-200">
|
<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="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>
|
<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 != "" {
|
if username != "" {
|
||||||
<div class="flex items-center gap-4 text-sm text-gray-600">
|
<div class="flex items-center gap-4 text-sm text-gray-600">
|
||||||
<span>{ username }</span>
|
<span>{ username }</span>
|
||||||
|
|||||||
@@ -42,30 +42,40 @@ func Layout(title string, username string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if username != "" {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(username)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(username)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -73,7 +83,7 @@ func Layout(title string, username string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
|
|||||||
|
"use strict";
|
||||||
|
// Contact form behavior: adding/removing repeatable phone, email, and
|
||||||
|
// address rows, plus a live preview when choosing a new photo.
|
||||||
|
(function initContactForm() {
|
||||||
|
document.querySelectorAll(".add-row-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const containerId = btn.dataset.addTarget;
|
||||||
|
if (!containerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = container.querySelectorAll(".form-row");
|
||||||
|
const lastRow = rows[rows.length - 1];
|
||||||
|
if (!lastRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clone the last row rather than keeping a separate <template>,
|
||||||
|
// so the JS-added row always matches whatever markup the server
|
||||||
|
// rendered (including the TYPE select's options).
|
||||||
|
const newRow = lastRow.cloneNode(true);
|
||||||
|
newRow.querySelectorAll("input").forEach((el) => {
|
||||||
|
el.value = "";
|
||||||
|
});
|
||||||
|
newRow.querySelectorAll("select").forEach((el) => {
|
||||||
|
el.selectedIndex = 0;
|
||||||
|
});
|
||||||
|
container.appendChild(newRow);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
if (!target.classList.contains("remove-row-btn")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = target.closest(".form-row");
|
||||||
|
const container = row?.parentElement;
|
||||||
|
if (!row || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = container.querySelectorAll(".form-row");
|
||||||
|
if (rows.length > 1) {
|
||||||
|
row.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep at least one row per section — just clear it instead of
|
||||||
|
// removing it entirely.
|
||||||
|
row.querySelectorAll("input").forEach((el) => {
|
||||||
|
el.value = "";
|
||||||
|
});
|
||||||
|
row.querySelectorAll("select").forEach((el) => {
|
||||||
|
el.selectedIndex = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const photoInput = document.getElementById("photo-input");
|
||||||
|
const photoPreview = document.getElementById("photo-preview");
|
||||||
|
const currentAvatar = document.getElementById("current-avatar");
|
||||||
|
const removePhotoCheckbox = document.getElementById("remove-photo");
|
||||||
|
photoInput?.addEventListener("change", () => {
|
||||||
|
const file = photoInput.files?.[0];
|
||||||
|
if (!file || !photoPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
photoPreview.src = reader.result;
|
||||||
|
photoPreview.classList.remove("hidden");
|
||||||
|
currentAvatar?.classList.add("hidden");
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
if (removePhotoCheckbox) {
|
||||||
|
removePhotoCheckbox.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use strict";
|
||||||
|
// File browser page behavior: uploading via the "Upload files"/"Upload
|
||||||
|
// folder" buttons and via drag & drop onto the listing, both re-using the
|
||||||
|
// same upload() call. Folder uploads (button or drop) recreate their
|
||||||
|
// directory structure server-side by encoding the relative path in each
|
||||||
|
// uploaded file's name (see FormData.append's third argument below).
|
||||||
|
(function initFileBrowser() {
|
||||||
|
const dropZone = document.getElementById("file-drop-zone");
|
||||||
|
const filesInput = document.getElementById("upload-files-input");
|
||||||
|
const folderInput = document.getElementById("upload-folder-input");
|
||||||
|
const newFolderButton = document.getElementById("new-folder-button");
|
||||||
|
const status = document.getElementById("upload-status");
|
||||||
|
if (!dropZone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uploadUrl = dropZone.dataset.uploadUrl || window.location.pathname;
|
||||||
|
function setStatus(msg) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function upload(files) {
|
||||||
|
if (files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) {
|
||||||
|
// A folder-selected/dropped file's relative path (e.g.
|
||||||
|
// "photos/2024/img.jpg") is sent as a parallel "paths" field
|
||||||
|
// (same order as "files") since the server strips any
|
||||||
|
// directory component from the file's own filename per the
|
||||||
|
// multipart spec — see internal/web/files.go.
|
||||||
|
const relPath = file.webkitRelativePath || file.name;
|
||||||
|
formData.append("files", file, file.name);
|
||||||
|
formData.append("paths", relPath);
|
||||||
|
}
|
||||||
|
setStatus(`Uploading ${files.length} file(s)…`);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(uploadUrl, { method: "POST", body: formData });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
setStatus(`Upload failed: ${text}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setStatus(`Upload failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filesInput?.addEventListener("change", () => {
|
||||||
|
void upload(Array.from(filesInput.files || []));
|
||||||
|
filesInput.value = "";
|
||||||
|
});
|
||||||
|
folderInput?.addEventListener("change", () => {
|
||||||
|
void upload(Array.from(folderInput.files || []));
|
||||||
|
folderInput.value = "";
|
||||||
|
});
|
||||||
|
const folderNameRe = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||||
|
newFolderButton?.addEventListener("click", async () => {
|
||||||
|
const name = window.prompt("New folder name:");
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!folderNameRe.test(name)) {
|
||||||
|
setStatus("Folder name must be 1-64 letters, digits, '-' or '_'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(`Creating folder "${name}"…`);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${uploadUrl}?mkdir=1`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ name }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
setStatus(`Could not create folder: ${text}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
setStatus(`Could not create folder: ${String(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Recursively walk a dropped DataTransferItem (file or directory) into
|
||||||
|
// a flat list of File objects, using the browser's non-standard but
|
||||||
|
// widely-supported webkitGetAsEntry/FileSystemEntry APIs to support
|
||||||
|
// dragging & dropping whole folders.
|
||||||
|
function readEntry(entry) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (entry.isFile) {
|
||||||
|
entry.file((file) => {
|
||||||
|
Object.defineProperty(file, "webkitRelativePath", {
|
||||||
|
value: entry.fullPath.replace(/^\//, ""),
|
||||||
|
});
|
||||||
|
resolve([file]);
|
||||||
|
}, () => resolve([]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
const reader = entry.createReader();
|
||||||
|
const allEntries = [];
|
||||||
|
const readBatch = () => {
|
||||||
|
reader.readEntries(async (batch) => {
|
||||||
|
if (batch.length === 0) {
|
||||||
|
const nested = await Promise.all(allEntries.map(readEntry));
|
||||||
|
resolve(nested.flat());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allEntries.push(...batch);
|
||||||
|
readBatch();
|
||||||
|
}, () => resolve([]));
|
||||||
|
};
|
||||||
|
readBatch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dropZone.addEventListener("dragover", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add("border-indigo-400", "bg-indigo-50");
|
||||||
|
});
|
||||||
|
dropZone.addEventListener("dragleave", () => {
|
||||||
|
dropZone.classList.remove("border-indigo-400", "bg-indigo-50");
|
||||||
|
});
|
||||||
|
dropZone.addEventListener("drop", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove("border-indigo-400", "bg-indigo-50");
|
||||||
|
const items = e.dataTransfer?.items;
|
||||||
|
if (!items) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = [];
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
const entry = item.webkitGetAsEntry?.();
|
||||||
|
if (entry) {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entries.length === 0) {
|
||||||
|
// Fallback for browsers without webkitGetAsEntry support: flat
|
||||||
|
// files only, no folder traversal.
|
||||||
|
void upload(Array.from(e.dataTransfer?.files || []));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Promise.all(entries.map(readEntry)).then((groups) => upload(groups.flat()));
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Contact form behavior: adding/removing repeatable phone, email, and
|
||||||
|
// address rows, plus a live preview when choosing a new photo.
|
||||||
|
(function initContactForm(): void {
|
||||||
|
document.querySelectorAll<HTMLButtonElement>(".add-row-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const containerId = btn.dataset.addTarget;
|
||||||
|
if (!containerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = container.querySelectorAll<HTMLElement>(".form-row");
|
||||||
|
const lastRow = rows[rows.length - 1];
|
||||||
|
if (!lastRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clone the last row rather than keeping a separate <template>,
|
||||||
|
// so the JS-added row always matches whatever markup the server
|
||||||
|
// rendered (including the TYPE select's options).
|
||||||
|
const newRow = lastRow.cloneNode(true) as HTMLElement;
|
||||||
|
newRow.querySelectorAll("input").forEach((el) => {
|
||||||
|
(el as HTMLInputElement).value = "";
|
||||||
|
});
|
||||||
|
newRow.querySelectorAll("select").forEach((el) => {
|
||||||
|
(el as HTMLSelectElement).selectedIndex = 0;
|
||||||
|
});
|
||||||
|
container.appendChild(newRow);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.classList.contains("remove-row-btn")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = target.closest<HTMLElement>(".form-row");
|
||||||
|
const container = row?.parentElement;
|
||||||
|
if (!row || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = container.querySelectorAll(".form-row");
|
||||||
|
if (rows.length > 1) {
|
||||||
|
row.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep at least one row per section — just clear it instead of
|
||||||
|
// removing it entirely.
|
||||||
|
row.querySelectorAll("input").forEach((el) => {
|
||||||
|
(el as HTMLInputElement).value = "";
|
||||||
|
});
|
||||||
|
row.querySelectorAll("select").forEach((el) => {
|
||||||
|
(el as HTMLSelectElement).selectedIndex = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const photoInput = document.getElementById("photo-input") as HTMLInputElement | null;
|
||||||
|
const photoPreview = document.getElementById("photo-preview") as HTMLImageElement | null;
|
||||||
|
const currentAvatar = document.getElementById("current-avatar") as HTMLElement | null;
|
||||||
|
const removePhotoCheckbox = document.getElementById("remove-photo") as HTMLInputElement | null;
|
||||||
|
|
||||||
|
photoInput?.addEventListener("change", () => {
|
||||||
|
const file = photoInput.files?.[0];
|
||||||
|
if (!file || !photoPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
photoPreview.src = reader.result as string;
|
||||||
|
photoPreview.classList.remove("hidden");
|
||||||
|
currentAvatar?.classList.add("hidden");
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
if (removePhotoCheckbox) {
|
||||||
|
removePhotoCheckbox.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
// File browser page behavior: uploading via the "Upload files"/"Upload
|
||||||
|
// folder" buttons and via drag & drop onto the listing, both re-using the
|
||||||
|
// same upload() call. Folder uploads (button or drop) recreate their
|
||||||
|
// directory structure server-side by encoding the relative path in each
|
||||||
|
// uploaded file's name (see FormData.append's third argument below).
|
||||||
|
(function initFileBrowser(): void {
|
||||||
|
const dropZone = document.getElementById("file-drop-zone") as HTMLDivElement | null;
|
||||||
|
const filesInput = document.getElementById("upload-files-input") as HTMLInputElement | null;
|
||||||
|
const folderInput = document.getElementById("upload-folder-input") as HTMLInputElement | null;
|
||||||
|
const newFolderButton = document.getElementById("new-folder-button") as HTMLButtonElement | null;
|
||||||
|
const status = document.getElementById("upload-status") as HTMLParagraphElement | null;
|
||||||
|
if (!dropZone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadUrl = dropZone.dataset.uploadUrl || window.location.pathname;
|
||||||
|
|
||||||
|
function setStatus(msg: string): void {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(files: File[]): Promise<void> {
|
||||||
|
if (files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) {
|
||||||
|
// A folder-selected/dropped file's relative path (e.g.
|
||||||
|
// "photos/2024/img.jpg") is sent as a parallel "paths" field
|
||||||
|
// (same order as "files") since the server strips any
|
||||||
|
// directory component from the file's own filename per the
|
||||||
|
// multipart spec — see internal/web/files.go.
|
||||||
|
const relPath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||||
|
formData.append("files", file, file.name);
|
||||||
|
formData.append("paths", relPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(`Uploading ${files.length} file(s)…`);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(uploadUrl, { method: "POST", body: formData });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
setStatus(`Upload failed: ${text}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Upload failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filesInput?.addEventListener("change", () => {
|
||||||
|
void upload(Array.from(filesInput.files || []));
|
||||||
|
filesInput.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
folderInput?.addEventListener("change", () => {
|
||||||
|
void upload(Array.from(folderInput.files || []));
|
||||||
|
folderInput.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const folderNameRe = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||||
|
|
||||||
|
newFolderButton?.addEventListener("click", async () => {
|
||||||
|
const name = window.prompt("New folder name:");
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!folderNameRe.test(name)) {
|
||||||
|
setStatus("Folder name must be 1-64 letters, digits, '-' or '_'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(`Creating folder "${name}"…`);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${uploadUrl}?mkdir=1`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ name }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
setStatus(`Could not create folder: ${text}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Could not create folder: ${String(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recursively walk a dropped DataTransferItem (file or directory) into
|
||||||
|
// a flat list of File objects, using the browser's non-standard but
|
||||||
|
// widely-supported webkitGetAsEntry/FileSystemEntry APIs to support
|
||||||
|
// dragging & dropping whole folders.
|
||||||
|
function readEntry(entry: FileSystemEntry): Promise<File[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (entry.isFile) {
|
||||||
|
(entry as FileSystemFileEntry).file((file) => {
|
||||||
|
Object.defineProperty(file, "webkitRelativePath", {
|
||||||
|
value: entry.fullPath.replace(/^\//, ""),
|
||||||
|
});
|
||||||
|
resolve([file]);
|
||||||
|
}, () => resolve([]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||||
|
const allEntries: FileSystemEntry[] = [];
|
||||||
|
const readBatch = (): void => {
|
||||||
|
reader.readEntries(async (batch) => {
|
||||||
|
if (batch.length === 0) {
|
||||||
|
const nested = await Promise.all(allEntries.map(readEntry));
|
||||||
|
resolve(nested.flat());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allEntries.push(...batch);
|
||||||
|
readBatch();
|
||||||
|
}, () => resolve([]));
|
||||||
|
};
|
||||||
|
readBatch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dropZone.addEventListener("dragover", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add("border-indigo-400", "bg-indigo-50");
|
||||||
|
});
|
||||||
|
dropZone.addEventListener("dragleave", () => {
|
||||||
|
dropZone.classList.remove("border-indigo-400", "bg-indigo-50");
|
||||||
|
});
|
||||||
|
dropZone.addEventListener("drop", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove("border-indigo-400", "bg-indigo-50");
|
||||||
|
const items = e.dataTransfer?.items;
|
||||||
|
if (!items) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries: FileSystemEntry[] = [];
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
const entry = item.webkitGetAsEntry?.();
|
||||||
|
if (entry) {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entries.length === 0) {
|
||||||
|
// Fallback for browsers without webkitGetAsEntry support: flat
|
||||||
|
// files only, no folder traversal.
|
||||||
|
void upload(Array.from(e.dataTransfer?.files || []));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Promise.all(entries.map(readEntry)).then((groups) => upload(groups.flat()));
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user