diff --git a/.gitignore b/.gitignore index d3a5bd5..0b4c6c0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ data/ config.yaml web/node_modules/ /bin/ +*.vcf diff --git a/internal/web/contacts.go b/internal/web/contacts.go new file mode 100644 index 0000000..4117629 --- /dev/null +++ b/internal/web/contacts.go @@ -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 ".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 . 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) +} diff --git a/internal/web/files.go b/internal/web/files.go new file mode 100644 index 0000000..1587f75 --- /dev/null +++ b/internal/web/files.go @@ -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]) +} diff --git a/internal/web/server.go b/internal/web/server.go index e1dc8b0..70108a1 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -45,6 +45,15 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler { mux.HandleFunc("/resources/calendar", s.requireLogin(s.handleCalendarResource)) mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor)) mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource)) + mux.HandleFunc("/files/{path...}", s.requireLogin(s.handleFiles)) + mux.HandleFunc("/contacts", s.requireLogin(s.handleContactsHome)) + mux.HandleFunc("/contacts/{book}", s.requireLogin(s.handleContactsList)) + mux.HandleFunc("/contacts/{book}/new", s.requireLogin(s.handleContactNew)) + mux.HandleFunc("/contacts/{book}/import", s.requireLogin(s.handleContactImport)) + mux.HandleFunc("/contacts/{book}/export", s.requireLogin(s.handleContactExportAll)) + mux.HandleFunc("/contacts/{book}/{id}/edit", s.requireLogin(s.handleContactEdit)) + mux.HandleFunc("/contacts/{book}/{id}/delete", s.requireLogin(s.handleContactDelete)) + mux.HandleFunc("/contacts/{book}/{id}/export", s.requireLogin(s.handleContactExportOne)) return mux } diff --git a/internal/web/templates/contacts.templ b/internal/web/templates/contacts.templ new file mode 100644 index 0000000..93a7bae --- /dev/null +++ b/internal/web/templates/contacts.templ @@ -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) { +

Contacts

+ if len(books) == 0 { +

+ You don't have any address books yet — create one from the + dashboard first. +

+ } else { + + } + } +} + +// 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 != "" { + + } else { + + } +} + +templ ContactsList(username, book string, contacts []ContactSummary) { + @Layout("Contacts", username) { +
+
+ ← Address books +

{ book }

+
+
+ + New contact + + + Export all (.vcf) + +
+ +
+
+
+ + + + + + + + + + + + + for _, c := range contacts { + + + + + + + + + } + if len(contacts) == 0 { + + + + } + +
NameOrganizationPhoneEmail
+ @avatar(c.PhotoDataURL, "w-8 h-8") + + { c.FullName } + { c.Organization }{ c.Phone }{ c.Email } + Export +
+ +
+
No contacts yet — add one above.
+ } +} + +// typeSelect renders the TYPE dropdown shared by phone/email/address rows. +templ typeSelect(name, selected string) { + +} + +templ phoneRow(p LabeledValue) { +
+ @typeSelect("phone_type", p.Type) + + +
+} + +templ emailRow(p LabeledValue) { +
+ @typeSelect("email_type", p.Type) + + +
+} + +templ addressRow(a AddressValue) { +
+
+ @typeSelect("address_type", a.Type) +
+ + + + + + +
+} + +templ ContactForm(username string, data ContactFormData, errMsg string) { + @Layout("Contacts", username) { + ← { data.Book } +

+ if data.ID == "" { + New contact + } else { + Edit contact + } +

+ if errMsg != "" { +

{ errMsg }

+ } +
+
+ + @avatar(data.PhotoDataURL, "w-20 h-20") + + +
+ + if data.PhotoDataURL != "" { + + } +
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ for _, p := range data.Phones { + @phoneRow(p) + } +
+ +
+ +
+ +
+ for _, e := range data.Emails { + @emailRow(e) + } +
+ +
+ +
+ +
+ for _, a := range data.Addresses { + @addressRow(a) + } +
+ +
+ +
+ + +
+
+ + + Cancel + +
+
+ + } +} + +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") +} diff --git a/internal/web/templates/contacts_templ.go b/internal/web/templates/contacts_templ.go new file mode 100644 index 0000000..84f593a --- /dev/null +++ b/internal/web/templates/contacts_templ.go @@ -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, "

Contacts

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(books) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

You don't have any address books yet — create one from the dashboard first.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + 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, "\"\"") + 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, "👤") + 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, "
← Address books

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, c := range contacts { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(contacts) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
NameOrganizationPhoneEmail
") + 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, "") + 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, "") + 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, "") + 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, "") + 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, "Export
No contacts yet — add one above.
") + 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, "") + 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, "
") + 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, "
") + 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, "
") + 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, "
") + 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, "
") + 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, "
") + 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, "← ") + 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, "

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + 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, " \"\"
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.PhotoDataURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + 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, "
") + 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, "
") + 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, "
Cancel
") + 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 diff --git a/internal/web/templates/files.templ b/internal/web/templates/files.templ new file mode 100644 index 0000000..42d235d --- /dev/null +++ b/internal/web/templates/files.templ @@ -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) { +
+

Files

+
+ + + +
+
+ + + +
+ + + + + + + + + + + + + + + for _, e := range entries { + + + + + + } + if len(entries) == 0 { + + + + } + +
NameSizeModified
+ if e.IsDir { + + { e.Name } + + } else { + + { e.Name } + + } + { e.Size }{ e.ModTime }
+ This folder is empty — drag & drop files or folders here, or use the upload buttons above. +
+
+

+ + } +} + diff --git a/internal/web/templates/files_templ.go b/internal/web/templates/files_templ.go new file mode 100644 index 0000000..5ab70ca --- /dev/null +++ b/internal/web/templates/files_templ.go @@ -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, "

Files

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, e := range entries { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(entries) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
NameSizeModified
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if e.IsDir { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "📁") + 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "📄") + 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + 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, "") + 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, "
This folder is empty — drag & drop files or folders here, or use the upload buttons above.

") + 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 diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ index be152cb..93b3e3e 100644 --- a/internal/web/templates/layout.templ +++ b/internal/web/templates/layout.templ @@ -13,7 +13,13 @@ templ Layout(title string, username string) {